Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton 4637ea7599 refactor(session): remove async facade exports (#22396) 2026-04-14 12:42:51 -04:00
Kit Langton 2fc5b00537 refactor(session): remove prompt async facade exports 2026-04-13 23:03:06 -04:00
Kit Langton 2728f6413e refactor(project): remove async facade exports 2026-04-13 22:49:22 -04:00
Kit Langton fe3303163c refactor(share): remove session share async facade exports 2026-04-13 22:46:34 -04:00
30 changed files with 1260 additions and 1067 deletions
+32 -25
View File
@@ -33,31 +33,38 @@ const seed = async () => {
}), }),
) )
const session = await Session.create({ title }) await AppRuntime.runPromise(
const messageID = MessageID.ascending() Effect.gen(function* () {
const partID = PartID.ascending() const session = yield* Session.Service
const message = { const result = yield* session.create({ title })
id: messageID, const messageID = MessageID.ascending()
sessionID: session.id, const partID = PartID.ascending()
role: "user" as const, const message = {
time: { created: now }, id: messageID,
agent: "build", sessionID: result.id,
model: { role: "user" as const,
providerID: ProviderID.make(providerID), time: { created: now },
modelID: ModelID.make(modelID), agent: "build",
}, model: {
} providerID: ProviderID.make(providerID),
const part = { modelID: ModelID.make(modelID),
id: partID, },
sessionID: session.id, }
messageID, const part = {
type: "text" as const, id: partID,
text, sessionID: result.id,
time: { start: now }, messageID,
} type: "text" as const,
await Session.updateMessage(message) text,
await Session.updatePart(part) time: { start: now },
await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) }
yield* session.updateMessage(message)
yield* session.updatePart(part)
}),
)
await AppRuntime.runPromise(
Project.Service.use((svc) => svc.update({ projectID: Instance.project.id, name: "E2E Project" })),
)
}, },
}) })
} finally { } finally {
+43 -39
View File
@@ -123,45 +123,49 @@ function parseToolParams(input?: string) {
} }
async function createToolContext(agent: Agent.Info) { async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` }) const { session, messageID } = await AppRuntime.runPromise(
const messageID = MessageID.ascending() Effect.gen(function* () {
const model = const session = yield* Session.Service
agent.model ?? const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
(await AppRuntime.runPromise( const messageID = MessageID.ascending()
Effect.gen(function* () { const model = agent.model
const provider = yield* Provider.Service ? agent.model
return yield* provider.defaultModel() : yield* Effect.gen(function* () {
}), const provider = yield* Provider.Service
)) return yield* provider.defaultModel()
const now = Date.now() })
const message: MessageV2.Assistant = { const now = Date.now()
id: messageID, const message: MessageV2.Assistant = {
sessionID: session.id, id: messageID,
role: "assistant", sessionID: result.id,
time: { role: "assistant",
created: now, time: {
}, created: now,
parentID: messageID, },
modelID: model.modelID, parentID: messageID,
providerID: model.providerID, modelID: model.modelID,
mode: "debug", providerID: model.providerID,
agent: agent.name, mode: "debug",
path: { agent: agent.name,
cwd: Instance.directory, path: {
root: Instance.worktree, cwd: Instance.directory,
}, root: Instance.worktree,
cost: 0, },
tokens: { cost: 0,
input: 0, tokens: {
output: 0, input: 0,
reasoning: 0, output: 0,
cache: { reasoning: 0,
read: 0, cache: {
write: 0, read: 0,
}, write: 0,
}, },
} },
await Session.updateMessage(message) }
yield* session.updateMessage(message)
return { session: result, messageID }
}),
)
const ruleset = Permission.merge(agent.permission, session.permission ?? []) const ruleset = Permission.merge(agent.permission, session.permission ?? [])
+12 -2
View File
@@ -6,6 +6,8 @@ import { bootstrap } from "../bootstrap"
import { UI } from "../ui" import { UI } from "../ui"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { EOL } from "os" import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ExportCommand = cmd({ export const ExportCommand = cmd({
command: "export [sessionID]", command: "export [sessionID]",
@@ -67,8 +69,16 @@ export const ExportCommand = cmd({
} }
try { try {
const sessionInfo = await Session.get(sessionID!) const { sessionInfo, messages } = await AppRuntime.runPromise(
const messages = await Session.messages({ sessionID: sessionInfo.id }) 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 = { const exportData = {
info: sessionInfo, info: sessionInfo,
+89 -94
View File
@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git" import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Effect } from "effect"
type GitHubAuthor = { type GitHubAuthor = {
login: string login: string
@@ -551,20 +552,24 @@ export const GithubRunCommand = cmd({
// Setup opencode session // Setup opencode session
const repoData = await fetchRepo() const repoData = await fetchRepo()
session = await Session.create({ session = await AppRuntime.runPromise(
permission: [ Session.Service.use((svc) =>
{ svc.create({
permission: "question", permission: [
action: "deny", {
pattern: "*", permission: "question",
}, action: "deny",
], pattern: "*",
}) },
],
}),
),
)
subscribeSessionEvents() subscribeSessionEvents()
shareId = await (async () => { shareId = await (async () => {
if (share === false) return if (share === false) return
if (!share && repoData.data.private) return if (!share && repoData.data.private) return
await SessionShare.share(session.id) await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
return session.id.slice(-8) return session.id.slice(-8)
})() })()
console.log("opencode session", session.id) console.log("opencode session", session.id)
@@ -937,96 +942,86 @@ export const GithubRunCommand = cmd({
async function chat(message: string, files: PromptFiles = []) { async function chat(message: string, files: PromptFiles = []) {
console.log("Sending message to opencode...") console.log("Sending message to opencode...")
const result = await SessionPrompt.prompt({ return AppRuntime.runPromise(
sessionID: session.id, Effect.gen(function* () {
messageID: MessageID.ascending(), const prompt = yield* SessionPrompt.Service
variant, const result = yield* prompt.prompt({
model: { sessionID: session.id,
providerID, messageID: MessageID.ascending(),
modelID, variant,
}, model: {
// agent is omitted - server will use default_agent from config or fall back to "build" providerID,
parts: [ modelID,
{
id: PartID.ascending(),
type: "text",
text: message,
},
...files.flatMap((f) => [
{
id: PartID.ascending(),
type: "file" as const,
mime: f.mime,
url: `data:${f.mime};base64,${f.content}`,
filename: f.filename,
source: {
type: "file" as const,
text: {
value: f.replacement,
start: f.start,
end: f.end,
},
path: f.filename,
},
}, },
]), // agent is omitted - server will use default_agent from config or fall back to "build"
], parts: [
}) {
id: PartID.ascending(),
type: "text",
text: message,
},
...files.flatMap((f) => [
{
id: PartID.ascending(),
type: "file" as const,
mime: f.mime,
url: `data:${f.mime};base64,${f.content}`,
filename: f.filename,
source: {
type: "file" as const,
text: {
value: f.replacement,
start: f.start,
end: f.end,
},
path: f.filename,
},
},
]),
],
})
// result should always be assistant just satisfying type checker if (result.info.role === "assistant" && result.info.error) {
if (result.info.role === "assistant" && result.info.error) { const err = result.info.error
const err = result.info.error console.error("Agent error:", err)
console.error("Agent error:", err) if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
throw new Error(`${err.name}: ${err.data?.message || ""}`)
}
if (err.name === "ContextOverflowError") { const text = extractResponseText(result.parts)
throw new Error(formatPromptTooLargeError(files)) if (text) return text
}
const errorMsg = err.data?.message || "" console.log("Requesting summary from agent...")
throw new Error(`${err.name}: ${errorMsg}`) const summary = yield* prompt.prompt({
} sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
tools: { "*": false },
parts: [
{
id: PartID.ascending(),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
],
})
const text = extractResponseText(result.parts) if (summary.info.role === "assistant" && summary.info.error) {
if (text) return text const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
throw new Error(`${err.name}: ${err.data?.message || ""}`)
}
// No text part (tool-only or reasoning-only) - ask agent to summarize const summaryText = extractResponseText(summary.parts)
console.log("Requesting summary from agent...") if (!summaryText) throw new Error("Failed to get summary from agent")
const summary = await SessionPrompt.prompt({ return summaryText
sessionID: session.id, }),
messageID: MessageID.ascending(), )
variant,
model: {
providerID,
modelID,
},
tools: { "*": false }, // Disable all tools to force text response
parts: [
{
id: PartID.ascending(),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
],
})
if (summary.info.role === "assistant" && summary.info.error) {
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
const summaryText = extractResponseText(summary.parts)
if (!summaryText) {
throw new Error("Failed to get summary from agent")
}
return summaryText
} }
async function getOidcToken() { async function getOidcToken() {
+3 -2
View File
@@ -11,6 +11,7 @@ import { Process } from "../../util/process"
import { EOL } from "os" import { EOL } from "os"
import path from "path" import path from "path"
import { which } from "../../util/which" import { which } from "../../util/which"
import { AppRuntime } from "@/effect/app-runtime"
function pagerCmd(): string[] { function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"] const lessOptions = ["-R", "-S"]
@@ -60,12 +61,12 @@ export const SessionDeleteCommand = cmd({
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const sessionID = SessionID.make(args.sessionID) const sessionID = SessionID.make(args.sessionID)
try { try {
await Session.get(sessionID) await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
} catch { } catch {
UI.error(`Session not found: ${args.sessionID}`) UI.error(`Session not found: ${args.sessionID}`)
process.exit(1) 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) UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
}) })
}, },
+4 -1
View File
@@ -6,6 +6,7 @@ import { Database } from "../../storage/db"
import { SessionTable } from "../../session/session.sql" import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project" import { Project } from "../../project/project"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { AppRuntime } from "@/effect/app-runtime"
interface SessionStats { interface SessionStats {
totalSessions: number totalSessions: number
@@ -167,7 +168,9 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
const batch = filteredSessions.slice(i, i + BATCH_SIZE) const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (session) => { 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 sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
+4 -1
View File
@@ -3,6 +3,7 @@ import { disposeInstance } from "@/effect/instance-registry"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Effect } from "effect"
import { LocalContext } from "../util/local-context" import { LocalContext } from "../util/local-context"
import { Project } from "./project" import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -29,7 +30,9 @@ function boot(input: { directory: string; init?: () => Promise<any>; worktree?:
worktree: input.worktree, worktree: input.worktree,
project: input.project, project: input.project,
} }
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ : await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(input.directory)).pipe(Effect.provide(Project.defaultLayer)),
).then(({ project, sandbox }) => ({
directory: input.directory, directory: input.directory,
worktree: sandbox, worktree: sandbox,
project, project,
+1 -35
View File
@@ -10,8 +10,7 @@ import { which } from "../util/which"
import { ProjectID } from "./schema" import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, Context, Stream } from "effect" import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodePath } from "@effect/platform-node"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -463,19 +462,6 @@ export namespace Project {
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
// ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise)
// ---------------------------------------------------------------------------
export function fromDirectory(directory: string) {
return runPromise((svc) => svc.fromDirectory(directory))
}
export function discover(input: Info) {
return runPromise((svc) => svc.discover(input))
}
export function list() { export function list() {
return Database.use((db) => return Database.use((db) =>
@@ -498,24 +484,4 @@ export namespace Project {
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
) )
} }
export function initGit(input: { directory: string; project: Info }) {
return runPromise((svc) => svc.initGit(input))
}
export function update(input: UpdateInput) {
return runPromise((svc) => svc.update(input))
}
export function sandboxes(id: ProjectID) {
return runPromise((svc) => svc.sandboxes(id))
}
export function addSandbox(id: ProjectID, directory: string) {
return runPromise((svc) => svc.addSandbox(id, directory))
}
export function removeSandbox(id: ProjectID, directory: string) {
return runPromise((svc) => svc.removeSandbox(id, directory))
}
} }
@@ -276,7 +276,7 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const sandboxes = await Project.sandboxes(Instance.project.id) const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
return c.json(sandboxes) return c.json(sandboxes)
}, },
) )
@@ -302,7 +302,9 @@ export const ExperimentalRoutes = lazy(() =>
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body))) await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
await Project.removeSandbox(Instance.project.id, body.directory) await AppRuntime.runPromise(
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -41,7 +41,7 @@ async function getSessionWorkspace(url: URL) {
const id = getSessionID(url) const id = getSessionID(url)
if (!id) return null 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 return session?.workspaceID
} }
@@ -75,10 +75,9 @@ export const ProjectRoutes = lazy(() =>
async (c) => { async (c) => {
const dir = Instance.directory const dir = Instance.directory
const prev = Instance.project const prev = Instance.project
const next = await Project.initGit({ const next = await AppRuntime.runPromise(
directory: dir, Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
project: prev, )
})
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
await Instance.reload({ await Instance.reload({
directory: dir, directory: dir,
@@ -112,7 +111,7 @@ export const ProjectRoutes = lazy(() =>
async (c) => { async (c) => {
const projectID = c.req.valid("param").projectID const projectID = c.req.valid("param").projectID
const body = c.req.valid("json") const body = c.req.valid("json")
const project = await Project.update({ ...body, projectID }) const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
return c.json(project) return c.json(project)
}, },
), ),
@@ -121,12 +121,12 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ z.object({
sessionID: Session.get.schema, sessionID: Session.GetInput,
}), }),
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID 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) return c.json(session)
}, },
) )
@@ -152,12 +152,12 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ z.object({
sessionID: Session.children.schema, sessionID: Session.ChildrenInput,
}), }),
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID 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) return c.json(session)
}, },
) )
@@ -209,10 +209,10 @@ export const SessionRoutes = lazy(() =>
}, },
}, },
}), }),
validator("json", Session.create.schema), validator("json", Session.CreateInput),
async (c) => { async (c) => {
const body = c.req.valid("json") ?? {} const body = c.req.valid("json") ?? {}
const session = await SessionShare.create(body) const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
return c.json(session) return c.json(session)
}, },
) )
@@ -237,12 +237,12 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ z.object({
sessionID: Session.remove.schema, sessionID: Session.RemoveInput,
}), }),
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID 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) return c.json(true)
}, },
) )
@@ -285,22 +285,27 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json") 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) { if (updates.title !== undefined) {
await Session.setTitle({ sessionID, title: updates.title }) yield* session.setTitle({ sessionID, title: updates.title })
} }
if (updates.permission !== undefined) { if (updates.permission !== undefined) {
await Session.setPermission({ yield* session.setPermission({
sessionID, sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission), permission: Permission.merge(current.permission ?? [], updates.permission),
}) })
} }
if (updates.time?.archived !== undefined) { if (updates.time?.archived !== undefined) {
await Session.setArchived({ sessionID, time: updates.time.archived }) yield* session.setArchived({ sessionID, time: updates.time.archived })
} }
const session = await Session.get(sessionID) return yield* session.get(sessionID)
}),
)
return c.json(session) return c.json(session)
}, },
) )
@@ -341,13 +346,17 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
await SessionPrompt.command({ await AppRuntime.runPromise(
sessionID, SessionPrompt.Service.use((svc) =>
messageID: body.messageID, svc.command({
model: body.providerID + "/" + body.modelID, sessionID,
command: Command.Default.INIT, messageID: body.messageID,
arguments: "", model: body.providerID + "/" + body.modelID,
}) command: Command.Default.INIT,
arguments: "",
}),
),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -371,14 +380,14 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ 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) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
const result = await Session.fork({ ...body, sessionID }) const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
return c.json(result) return c.json(result)
}, },
) )
@@ -407,7 +416,7 @@ export const SessionRoutes = lazy(() =>
}), }),
), ),
async (c) => { async (c) => {
await SessionPrompt.cancel(c.req.valid("param").sessionID) await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
return c.json(true) return c.json(true)
}, },
) )
@@ -437,8 +446,14 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
await SessionShare.share(sessionID) const session = await AppRuntime.runPromise(
const session = await Session.get(sessionID) Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session) return c.json(session)
}, },
) )
@@ -511,8 +526,14 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
await SessionShare.unshare(sessionID) const session = await AppRuntime.runPromise(
const session = await Session.get(sessionID) Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session) return c.json(session)
}, },
) )
@@ -645,15 +666,14 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const query = c.req.valid("query") const query = c.req.valid("query")
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
if (query.limit === undefined) { if (query.limit === undefined || query.limit === 0) {
await Session.get(sessionID) const messages = await AppRuntime.runPromise(
const messages = await Session.messages({ sessionID }) Effect.gen(function* () {
return c.json(messages) const session = yield* Session.Service
} yield* session.get(sessionID)
return yield* session.messages({ sessionID })
if (query.limit === 0) { }),
await Session.get(sessionID) )
const messages = await Session.messages({ sessionID })
return c.json(messages) return c.json(messages)
} }
@@ -781,11 +801,15 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
await Session.removePart({ await AppRuntime.runPromise(
sessionID: params.sessionID, Session.Service.use((svc) =>
messageID: params.messageID, svc.removePart({
partID: params.partID, sessionID: params.sessionID,
}) messageID: params.messageID,
partID: params.partID,
}),
),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -823,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}'`, `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) return c.json(part)
}, },
) )
@@ -863,7 +887,9 @@ export const SessionRoutes = lazy(() =>
return stream(c, async (stream) => { return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
const msg = await SessionPrompt.prompt({ ...body, sessionID }) const msg = await AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
)
stream.write(JSON.stringify(msg)) stream.write(JSON.stringify(msg))
}) })
}, },
@@ -892,7 +918,7 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err }) log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, { Bus.publish(Session.Event.Error, {
sessionID, sessionID,
@@ -936,7 +962,7 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
const msg = await SessionPrompt.command({ ...body, sessionID }) const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
return c.json(msg) return c.json(msg)
}, },
) )
@@ -968,7 +994,7 @@ export const SessionRoutes = lazy(() =>
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
const msg = await SessionPrompt.shell({ ...body, sessionID }) const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
return c.json(msg) return c.json(msg)
}, },
) )
+2 -1
View File
@@ -4,6 +4,7 @@ import z from "zod"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { Session } from "../../session" import { Session } from "../../session"
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/cli/cmd/tui/event"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "../../util/queue" import { AsyncQueue } from "../../util/queue"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
@@ -370,7 +371,7 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties), validator("json", TuiEvent.SessionSelect.properties),
async (c) => { async (c) => {
const { sessionID } = c.req.valid("json") 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 }) await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true) return c.json(true)
}, },
+24 -65
View File
@@ -19,7 +19,6 @@ import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { fn } from "@/util/fn"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema" import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema" import { WorkspaceID } from "../control-plane/schema"
@@ -29,7 +28,6 @@ import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Global } from "@/global" import { Global } from "@/global"
import { Effect, Layer, Option, Context } from "effect" import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
export namespace Session { export namespace Session {
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -179,6 +177,30 @@ export namespace Session {
}) })
export type GlobalInfo = z.output<typeof GlobalInfo> 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 = { export const Event = {
Created: SyncEvent.define({ Created: SyncEvent.define({
type: "session.created", type: "session.created",
@@ -682,48 +704,6 @@ export namespace Session {
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) 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?: { export function* list(input?: {
directory?: string directory?: string
workspaceID?: WorkspaceID workspaceID?: WorkspaceID
@@ -835,25 +815,4 @@ export namespace Session {
yield { ...fromRow(row), project } 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))
}
} }
-27
View File
@@ -46,7 +46,6 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool, type TaskPromptOps } from "@/tool/task" import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state" import { SessionRunState } from "./run-state"
@@ -1708,8 +1707,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
), ),
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export const PromptInput = z.object({ export const PromptInput = z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
messageID: MessageID.zod.optional(), messageID: MessageID.zod.optional(),
@@ -1777,26 +1774,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) })
export type PromptInput = z.infer<typeof PromptInput> export type PromptInput = z.infer<typeof PromptInput>
export async function prompt(input: PromptInput) {
return runPromise((svc) => svc.prompt(PromptInput.parse(input)))
}
export async function resolvePromptParts(template: string) {
return runPromise((svc) => svc.resolvePromptParts(z.string().parse(template)))
}
export async function cancel(sessionID: SessionID) {
return runPromise((svc) => svc.cancel(SessionID.zod.parse(sessionID)))
}
export const LoopInput = z.object({ export const LoopInput = z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
}) })
export async function loop(input: z.infer<typeof LoopInput>) {
return runPromise((svc) => svc.loop(LoopInput.parse(input)))
}
export const ShellInput = z.object({ export const ShellInput = z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
messageID: MessageID.zod.optional(), messageID: MessageID.zod.optional(),
@@ -1811,10 +1792,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) })
export type ShellInput = z.infer<typeof ShellInput> export type ShellInput = z.infer<typeof ShellInput>
export async function shell(input: ShellInput) {
return runPromise((svc) => svc.shell(ShellInput.parse(input)))
}
export const CommandInput = z.object({ export const CommandInput = z.object({
messageID: MessageID.zod.optional(), messageID: MessageID.zod.optional(),
sessionID: SessionID.zod, sessionID: SessionID.zod,
@@ -1838,10 +1815,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) })
export type CommandInput = z.infer<typeof CommandInput> export type CommandInput = z.infer<typeof CommandInput>
export async function command(input: CommandInput) {
return runPromise((svc) => svc.command(CommandInput.parse(input)))
}
/** @internal Exported for testing */ /** @internal Exported for testing */
export function createStructuredOutputTool(input: { export function createStructuredOutputTool(input: {
schema: Record<string, any> schema: Record<string, any>
+2 -10
View File
@@ -1,8 +1,6 @@
import { makeRuntime } from "@/effect/run-service"
import { Session } from "@/session" import { Session } from "@/session"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { fn } from "@/util/fn"
import { Effect, Layer, Scope, Context } from "effect" import { Effect, Layer, Scope, Context } from "effect"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
@@ -10,7 +8,7 @@ import { ShareNext } from "./share-next"
export namespace SessionShare { export namespace SessionShare {
export interface Interface { 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 share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown>
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown> readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
} }
@@ -40,7 +38,7 @@ export namespace SessionShare {
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) 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) const result = yield* session.create(input)
if (result.parentID) return result if (result.parentID) return result
const conf = yield* cfg.get() const conf = yield* cfg.get()
@@ -58,10 +56,4 @@ export namespace SessionShare {
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input)))
export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID)))
export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID)))
} }
@@ -8,9 +8,19 @@ import { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { $ } from "bun" import { $ } from "bun"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Effect } from "effect"
Log.init({ print: false }) Log.init({ print: false })
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(Project.defaultLayer)),
)
}
function uid() { function uid() {
return SessionID.make(crypto.randomUUID()) return SessionID.make(crypto.randomUUID())
} }
@@ -58,7 +68,7 @@ describe("migrateFromGlobal", () => {
await $`git config user.name "Test"`.cwd(tmp.path).quiet() await $`git config user.name "Test"`.cwd(tmp.path).quiet()
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet() await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet() await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
const { project: pre } = await Project.fromDirectory(tmp.path) const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path))
expect(pre.id).toBe(ProjectID.global) expect(pre.id).toBe(ProjectID.global)
// 2. Seed a session under "global" with matching directory // 2. Seed a session under "global" with matching directory
@@ -68,7 +78,7 @@ describe("migrateFromGlobal", () => {
// 3. Make a commit so the project gets a real ID // 3. Make a commit so the project gets a real ID
await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet() await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
const { project: real } = await Project.fromDirectory(tmp.path) const { project: real } = await run((svc) => svc.fromDirectory(tmp.path))
expect(real.id).not.toBe(ProjectID.global) expect(real.id).not.toBe(ProjectID.global)
// 4. The session should have been migrated to the real project ID // 4. The session should have been migrated to the real project ID
@@ -80,7 +90,7 @@ describe("migrateFromGlobal", () => {
test("migrates global sessions even when project row already exists", async () => { test("migrates global sessions even when project row already exists", async () => {
// 1. Create a repo with a commit — real project ID created immediately // 1. Create a repo with a commit — real project ID created immediately
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session) // 2. Ensure "global" project row exists (as it would from a prior no-git session)
@@ -94,7 +104,7 @@ describe("migrateFromGlobal", () => {
// 4. Call fromDirectory again — project row already exists, // 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug. // so the current code skips migration entirely. This is the bug.
await Project.fromDirectory(tmp.path) await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined() expect(row).toBeDefined()
@@ -103,7 +113,7 @@ describe("migrateFromGlobal", () => {
test("does not claim sessions with empty directory", async () => { test("does not claim sessions with empty directory", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
ensureGlobal() ensureGlobal()
@@ -113,7 +123,7 @@ describe("migrateFromGlobal", () => {
const id = uid() const id = uid()
seed({ id, dir: "", project: ProjectID.global }) seed({ id, dir: "", project: ProjectID.global })
await Project.fromDirectory(tmp.path) await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined() expect(row).toBeDefined()
@@ -122,7 +132,7 @@ describe("migrateFromGlobal", () => {
test("does not steal sessions from unrelated directories", async () => { test("does not steal sessions from unrelated directories", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
ensureGlobal() ensureGlobal()
@@ -131,7 +141,7 @@ describe("migrateFromGlobal", () => {
const id = uid() const id = uid()
seed({ id, dir: "/some/other/dir", project: ProjectID.global }) seed({ id, dir: "/some/other/dir", project: ProjectID.global })
await Project.fromDirectory(tmp.path) await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined() expect(row).toBeDefined()
+83 -69
View File
@@ -8,7 +8,7 @@ import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import { Effect, Layer, Stream } from "effect" import { Effect, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -16,6 +16,15 @@ Log.init({ print: false })
const encoder = new TextEncoder() const encoder = new TextEncoder()
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>, layer = Project.defaultLayer) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(layer)),
)
}
/** /**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands * Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything * matching `failArg` and returns exit code 128, while delegating everything
@@ -64,7 +73,7 @@ describe("Project.fromDirectory", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet() await $`git init`.cwd(tmp.path).quiet()
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
@@ -78,7 +87,7 @@ describe("Project.fromDirectory", () => {
test("should handle git repository with commits", async () => { test("should handle git repository with commits", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
@@ -91,14 +100,14 @@ describe("Project.fromDirectory", () => {
test("returns global for non-git directory", async () => { test("returns global for non-git directory", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
}) })
test("derives stable project ID from root commit", async () => { test("derives stable project ID from root commit", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project: a } = await Project.fromDirectory(tmp.path) const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: b } = await Project.fromDirectory(tmp.path) const { project: b } = await run((svc) => svc.fromDirectory(tmp.path))
expect(b.id).toBe(a.id) expect(b.id).toBe(a.id)
}) })
}) })
@@ -109,7 +118,7 @@ describe("Project.fromDirectory git failure paths", () => {
await $`git init`.cwd(tmp.path).quiet() await $`git init`.cwd(tmp.path).quiet()
// rev-list fails because HEAD doesn't exist yet — this is the natural scenario // rev-list fails because HEAD doesn't exist yet — this is the natural scenario
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.vcs).toBe("git") expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
@@ -119,9 +128,7 @@ describe("Project.fromDirectory git failure paths", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--show-toplevel") const layer = projectLayerWithFailure("--show-toplevel")
const { project, sandbox } = await Effect.runPromise( const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
}) })
@@ -130,9 +137,7 @@ describe("Project.fromDirectory git failure paths", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--git-common-dir") const layer = projectLayerWithFailure("--git-common-dir")
const { project, sandbox } = await Effect.runPromise( const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
}) })
@@ -142,7 +147,7 @@ describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => { test("should set worktree to root when called from root", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project, sandbox } = await Project.fromDirectory(tmp.path) const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
@@ -156,7 +161,7 @@ describe("Project.fromDirectory with worktrees", () => {
try { try {
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
const { project, sandbox } = await Project.fromDirectory(worktreePath) const { project, sandbox } = await run((svc) => svc.fromDirectory(worktreePath))
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath) expect(sandbox).toBe(worktreePath)
@@ -173,13 +178,13 @@ describe("Project.fromDirectory with worktrees", () => {
test("worktree should share project ID with main repo", async () => { test("worktree should share project ID with main repo", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project: main } = await Project.fromDirectory(tmp.path) const { project: main } = await run((svc) => svc.fromDirectory(tmp.path))
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try { try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await Project.fromDirectory(worktreePath) const { project: wt } = await run((svc) => svc.fromDirectory(worktreePath))
expect(wt.id).toBe(main.id) expect(wt.id).toBe(main.id)
@@ -205,8 +210,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git clone --bare ${tmp.path} ${bare}`.quiet() await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet() await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await Project.fromDirectory(tmp.path) const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: b } = await Project.fromDirectory(clone) const { project: b } = await run((svc) => svc.fromDirectory(clone))
expect(b.id).toBe(a.id) expect(b.id).toBe(a.id)
} finally { } finally {
@@ -223,8 +228,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
await Project.fromDirectory(worktree1) await run((svc) => svc.fromDirectory(worktree1))
const { project } = await Project.fromDirectory(worktree2) const { project } = await run((svc) => svc.fromDirectory(worktree2))
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1) expect(project.sandboxes).toContain(worktree1)
@@ -246,12 +251,12 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => { describe("Project.discover", () => {
test("should discover favicon.png in root", async () => { test("should discover favicon.png in root", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData) await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await Project.discover(project) await run((svc) => svc.discover(project))
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@@ -263,11 +268,11 @@ describe("Project.discover", () => {
test("should not discover non-image files", async () => { test("should not discover non-image files", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image") await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
await Project.discover(project) await run((svc) => svc.discover(project))
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@@ -278,12 +283,14 @@ describe("Project.discover", () => {
describe("Project.update", () => { describe("Project.update", () => {
test("should update name", async () => { test("should update name", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({ const updated = await run((svc) =>
projectID: project.id, svc.update({
name: "New Project Name", projectID: project.id,
}) name: "New Project Name",
}),
)
expect(updated.name).toBe("New Project Name") expect(updated.name).toBe("New Project Name")
@@ -293,12 +300,14 @@ describe("Project.update", () => {
test("should update icon url", async () => { test("should update icon url", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({ const updated = await run((svc) =>
projectID: project.id, svc.update({
icon: { url: "https://example.com/icon.png" }, projectID: project.id,
}) icon: { url: "https://example.com/icon.png" },
}),
)
expect(updated.icon?.url).toBe("https://example.com/icon.png") expect(updated.icon?.url).toBe("https://example.com/icon.png")
@@ -308,12 +317,14 @@ describe("Project.update", () => {
test("should update icon color", async () => { test("should update icon color", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({ const updated = await run((svc) =>
projectID: project.id, svc.update({
icon: { color: "#ff0000" }, projectID: project.id,
}) icon: { color: "#ff0000" },
}),
)
expect(updated.icon?.color).toBe("#ff0000") expect(updated.icon?.color).toBe("#ff0000")
@@ -323,12 +334,14 @@ describe("Project.update", () => {
test("should update commands", async () => { test("should update commands", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({ const updated = await run((svc) =>
projectID: project.id, svc.update({
commands: { start: "npm run dev" }, projectID: project.id,
}) commands: { start: "npm run dev" },
}),
)
expect(updated.commands?.start).toBe("npm run dev") expect(updated.commands?.start).toBe("npm run dev")
@@ -338,16 +351,18 @@ describe("Project.update", () => {
test("should throw error when project not found", async () => { test("should throw error when project not found", async () => {
await expect( await expect(
Project.update({ run((svc) =>
projectID: ProjectID.make("nonexistent-project-id"), svc.update({
name: "Should Fail", projectID: ProjectID.make("nonexistent-project-id"),
}), name: "Should Fail",
}),
),
).rejects.toThrow("Project not found: nonexistent-project-id") ).rejects.toThrow("Project not found: nonexistent-project-id")
}) })
test("should emit GlobalBus event on update", async () => { test("should emit GlobalBus event on update", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
let eventPayload: any = null let eventPayload: any = null
const on = (data: any) => { const on = (data: any) => {
@@ -356,10 +371,7 @@ describe("Project.update", () => {
GlobalBus.on("event", on) GlobalBus.on("event", on)
try { try {
await Project.update({ await run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
projectID: project.id,
name: "Updated Name",
})
expect(eventPayload).not.toBeNull() expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated") expect(eventPayload.payload.type).toBe("project.updated")
@@ -371,14 +383,16 @@ describe("Project.update", () => {
test("should update multiple fields at once", async () => { test("should update multiple fields at once", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({ const updated = await run((svc) =>
projectID: project.id, svc.update({
name: "Multi Update", projectID: project.id,
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" }, name: "Multi Update",
commands: { start: "make start" }, icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
}) commands: { start: "make start" },
}),
)
expect(updated.name).toBe("Multi Update") expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico") expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
@@ -390,7 +404,7 @@ describe("Project.update", () => {
describe("Project.list and Project.get", () => { describe("Project.list and Project.get", () => {
test("list returns all projects", async () => { test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const all = Project.list() const all = Project.list()
expect(all.length).toBeGreaterThan(0) expect(all.length).toBeGreaterThan(0)
@@ -399,7 +413,7 @@ describe("Project.list and Project.get", () => {
test("get returns project by id", async () => { test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const found = Project.get(project.id) const found = Project.get(project.id)
expect(found).toBeDefined() expect(found).toBeDefined()
@@ -415,7 +429,7 @@ describe("Project.list and Project.get", () => {
describe("Project.setInitialized", () => { describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => { test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.time.initialized).toBeUndefined() expect(project.time.initialized).toBeUndefined()
@@ -429,15 +443,15 @@ describe("Project.setInitialized", () => {
describe("Project.addSandbox and Project.removeSandbox", () => { describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => { test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-test") const sandboxDir = path.join(tmp.path, "sandbox-test")
await Project.addSandbox(project.id, sandboxDir) await run((svc) => svc.addSandbox(project.id, sandboxDir))
let found = Project.get(project.id) let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir) expect(found?.sandboxes).toContain(sandboxDir)
await Project.removeSandbox(project.id, sandboxDir) await run((svc) => svc.removeSandbox(project.id, sandboxDir))
found = Project.get(project.id) found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir) expect(found?.sandboxes).not.toContain(sandboxDir)
@@ -445,14 +459,14 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox emits GlobalBus event", async () => { test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-event") const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = [] const events: any[] = []
const on = (evt: any) => events.push(evt) const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on) GlobalBus.on("event", on)
await Project.addSandbox(project.id, sandboxDir) await run((svc) => svc.addSandbox(project.id, sandboxDir))
GlobalBus.off("event", on) GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
@@ -1,27 +1,43 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import z from "zod"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project" 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 { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
Log.init({ print: false }) 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 () => { test("lists sessions across projects with project metadata", async () => {
await using first = await tmpdir({ git: true }) await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true }) await using second = await tmpdir({ git: true })
const firstSession = await Instance.provide({ const firstSession = await Instance.provide({
directory: first.path, directory: first.path,
fn: async () => Session.create({ title: "first-session" }), fn: async () => svc.create({ title: "first-session" }),
}) })
const secondSession = await Instance.provide({ const secondSession = await Instance.provide({
directory: second.path, 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) const ids = sessions.map((session) => session.id)
expect(ids).toContain(firstSession.id) expect(ids).toContain(firstSession.id)
@@ -44,20 +60,20 @@ describe("Session.listGlobal", () => {
const archived = await Instance.provide({ const archived = await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => Session.create({ title: "archived-session" }), fn: async () => svc.create({ title: "archived-session" }),
}) })
await Instance.provide({ await Instance.provide({
directory: tmp.path, 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) const ids = sessions.map((session) => session.id)
expect(ids).not.toContain(archived.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) const allIds = allSessions.map((session) => session.id)
expect(allIds).toContain(archived.id) expect(allIds).toContain(archived.id)
@@ -68,19 +84,19 @@ describe("Session.listGlobal", () => {
const first = await Instance.provide({ const first = await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => Session.create({ title: "page-one" }), fn: async () => svc.create({ title: "page-one" }),
}) })
await new Promise((resolve) => setTimeout(resolve, 5)) await new Promise((resolve) => setTimeout(resolve, 5))
const second = await Instance.provide({ const second = await Instance.provide({
directory: tmp.path, 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.length).toBe(1)
expect(page[0].id).toBe(second.id) 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) const ids = next.map((session) => session.id)
expect(ids).toContain(first.id) expect(ids).toContain(first.id)
@@ -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 { Effect } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { Session } from "../../src/session" import { Session as SessionNs } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt" import type { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
Log.init({ print: false }) 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 () => { afterEach(async () => {
mock.restore() mock.restore()
await Instance.disposeAll() await Instance.disposeAll()
}) })
describe("session action routes", () => { describe("session action routes", () => {
test("abort route calls SessionPrompt.cancel", async () => { test("abort route returns success", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
const app = Server.Default().app const app = Server.Default().app
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" }) const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toBe(true) expect(await res.json()).toBe(true)
expect(cancel).toHaveBeenCalledWith(session.id)
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -1,30 +1,42 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance" 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 { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
Log.init({ print: false }) 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 () => { afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
describe("Session.list", () => { describe("session.list", () => {
test("filters by directory", async () => { test("filters by directory", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const first = await Session.create({}) const first = await svc.create({})
await using other = await tmpdir({ git: true }) await using other = await tmpdir({ git: true })
const second = await Instance.provide({ const second = await Instance.provide({
directory: other.path, 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) const ids = sessions.map((s) => s.id)
expect(ids).toContain(first.id) expect(ids).toContain(first.id)
@@ -38,10 +50,10 @@ describe("Session.list", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const root = await Session.create({ title: "root-session" }) const root = await svc.create({ title: "root-session" })
const child = await Session.create({ title: "child-session", parentID: root.id }) 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) const ids = sessions.map((s) => s.id)
expect(ids).toContain(root.id) expect(ids).toContain(root.id)
@@ -55,10 +67,10 @@ describe("Session.list", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({ title: "new-session" }) const session = await svc.create({ title: "new-session" })
const futureStart = Date.now() + 86400000 const futureStart = Date.now() + 86400000
const sessions = [...Session.list({ start: futureStart })] const sessions = [...svc.list({ start: futureStart })]
expect(sessions.length).toBe(0) expect(sessions.length).toBe(0)
}, },
}) })
@@ -69,10 +81,10 @@ describe("Session.list", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await Session.create({ title: "unique-search-term-abc" }) await svc.create({ title: "unique-search-term-abc" })
await Session.create({ title: "other-session-xyz" }) 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) const titles = sessions.map((s) => s.title)
expect(titles).toContain("unique-search-term-abc") expect(titles).toContain("unique-search-term-abc")
@@ -86,11 +98,11 @@ describe("Session.list", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await Session.create({ title: "session-1" }) await svc.create({ title: "session-1" })
await Session.create({ title: "session-2" }) await svc.create({ title: "session-2" })
await Session.create({ title: "session-3" }) await svc.create({ title: "session-3" })
const sessions = [...Session.list({ limit: 2 })] const sessions = [...svc.list({ limit: 2 })]
expect(sessions.length).toBe(2) expect(sessions.length).toBe(2)
}, },
}) })
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { Session } from "../../src/session" import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
@@ -9,6 +10,26 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false }) 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 () => { afterEach(async () => {
await Instance.disposeAll() 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++) { for (let i = 0; i < count; i++) {
const id = MessageID.ascending() const id = MessageID.ascending()
ids.push(id) ids.push(id)
await Session.updateMessage({ await svc.updateMessage({
id, id,
sessionID, sessionID,
role: "user", role: "user",
@@ -40,7 +61,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
tools: {}, tools: {},
mode: "", mode: "",
} as unknown as MessageV2.Info) } as unknown as MessageV2.Info)
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID, sessionID,
messageID: id, messageID: id,
@@ -58,7 +79,7 @@ describe("session messages endpoint", () => {
Instance.provide({ Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 5) const ids = await fill(session.id, 5)
const app = Server.Default().app const app = Server.Default().app
@@ -75,7 +96,7 @@ describe("session messages endpoint", () => {
const bBody = (await b.json()) as MessageV2.WithParts[] const bBody = (await b.json()) as MessageV2.WithParts[]
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) 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({ Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 3) const ids = await fill(session.id, 3)
const app = Server.Default().app const app = Server.Default().app
@@ -96,7 +117,7 @@ describe("session messages endpoint", () => {
const body = (await res.json()) as MessageV2.WithParts[] const body = (await res.json()) as MessageV2.WithParts[]
expect(body.map((item) => item.info.id)).toEqual(ids) 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({ Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const app = Server.Default().app const app = Server.Default().app
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`) 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`) const miss = await app.request(`/session/ses_missing/message?limit=2`)
expect(miss.status).toBe(404) 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({ Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
await fill(session.id, 520) await fill(session.id, 520)
const app = Server.Default().app const app = Server.Default().app
@@ -138,7 +159,7 @@ describe("session messages endpoint", () => {
const body = (await res.json()) as MessageV2.WithParts[] const body = (await res.json()) as MessageV2.WithParts[]
expect(body).toHaveLength(510) expect(body).toHaveLength(510)
await Session.remove(session.id) await svc.remove(session.id)
}, },
}), }),
) )
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test" 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 { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
@@ -7,6 +9,20 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false }) 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 () => { afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
@@ -18,7 +34,7 @@ describe("tui.selectSession endpoint", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// #given // #given
const session = await Session.create({}) const session = await svc.create({})
// #when // #when
const app = Server.Default().app const app = Server.Default().app
@@ -33,7 +49,7 @@ describe("tui.selectSession endpoint", () => {
const body = await response.json() const body = await response.json()
expect(body).toBe(true) expect(body).toBe(true)
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -3,6 +3,7 @@ import { APICallError } from "ai"
import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect" import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import path from "path" import path from "path"
import z from "zod"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
@@ -14,7 +15,7 @@ import { Log } from "../../src/util/log"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" 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 { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session/status"
@@ -29,6 +30,26 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false }) 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( const summary = Layer.succeed(
SessionSummary.Service, SessionSummary.Service,
SessionSummary.Service.of({ SessionSummary.Service.of({
@@ -80,7 +101,7 @@ function createModel(opts: {
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
async function user(sessionID: SessionID, text: string) { async function user(sessionID: SessionID, text: string) {
const msg = await Session.updateMessage({ const msg = await svc.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID, sessionID,
@@ -88,7 +109,7 @@ async function user(sessionID: SessionID, text: string) {
model: ref, model: ref,
time: { created: Date.now() }, time: { created: Date.now() },
}) })
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: msg.id, messageID: msg.id,
sessionID, sessionID,
@@ -119,12 +140,12 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string
time: { created: Date.now() }, time: { created: Date.now() },
finish: "end_turn", finish: "end_turn",
} }
await Session.updateMessage(msg) await svc.updateMessage(msg)
return msg return msg
} }
async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) { async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) {
return Session.updatePart({ return svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID, messageID,
sessionID, sessionID,
@@ -171,7 +192,7 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p
return ManagedRuntime.make( return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer, bus).pipe( Layer.mergeAll(SessionCompaction.layer, bus).pipe(
Layer.provide(provider.layer), Layer.provide(provider.layer),
Layer.provide(Session.defaultLayer), Layer.provide(SessionNs.defaultLayer),
Layer.provide(layer(result)), Layer.provide(layer(result)),
Layer.provide(Agent.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(plugin), Layer.provide(plugin),
@@ -191,9 +212,9 @@ const deps = Layer.mergeAll(
) )
const env = Layer.mergeAll( const env = Layer.mergeAll(
Session.defaultLayer, SessionNs.defaultLayer,
CrossSpawnSpawner.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) const it = testEffect(env)
@@ -227,7 +248,7 @@ function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fa
return ManagedRuntime.make( return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(provider.layer), Layer.provide(provider.layer),
Layer.provide(Session.defaultLayer), Layer.provide(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer), Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer), Layer.provide(layer),
Layer.provide(Permission.defaultLayer), Layer.provide(Permission.defaultLayer),
@@ -467,9 +488,9 @@ describe("session.compaction.create", () => {
provideTmpdirInstance(() => provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const compact = yield* SessionCompaction.Service 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({ yield* compact.create({
sessionID: info.id, sessionID: info.id,
@@ -479,7 +500,7 @@ describe("session.compaction.create", () => {
overflow: true, overflow: true,
}) })
const msgs = yield* session.messages({ sessionID: info.id }) const msgs = yield* ssn.messages({ sessionID: info.id })
expect(msgs).toHaveLength(1) expect(msgs).toHaveLength(1)
expect(msgs[0].info.role).toBe("user") expect(msgs[0].info.role).toBe("user")
expect(msgs[0].parts).toHaveLength(1) expect(msgs[0].parts).toHaveLength(1)
@@ -499,9 +520,9 @@ describe("session.compaction.prune", () => {
provideTmpdirInstance((dir) => provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const compact = yield* SessionCompaction.Service 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({})
const a = yield* session.updateMessage({ const a = yield* ssn.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID: info.id, sessionID: info.id,
@@ -509,7 +530,7 @@ describe("session.compaction.prune", () => {
model: ref, model: ref,
time: { created: Date.now() }, time: { created: Date.now() },
}) })
yield* session.updatePart({ yield* ssn.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: a.id, messageID: a.id,
sessionID: info.id, sessionID: info.id,
@@ -536,8 +557,8 @@ describe("session.compaction.prune", () => {
time: { created: Date.now() }, time: { created: Date.now() },
finish: "end_turn", finish: "end_turn",
} }
yield* session.updateMessage(b) yield* ssn.updateMessage(b)
yield* session.updatePart({ yield* ssn.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: b.id, messageID: b.id,
sessionID: info.id, sessionID: info.id,
@@ -554,7 +575,7 @@ describe("session.compaction.prune", () => {
}, },
}) })
for (const text of ["second", "third"]) { for (const text of ["second", "third"]) {
const msg = yield* session.updateMessage({ const msg = yield* ssn.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID: info.id, sessionID: info.id,
@@ -562,7 +583,7 @@ describe("session.compaction.prune", () => {
model: ref, model: ref,
time: { created: Date.now() }, time: { created: Date.now() },
}) })
yield* session.updatePart({ yield* ssn.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: msg.id, messageID: msg.id,
sessionID: info.id, sessionID: info.id,
@@ -573,7 +594,7 @@ describe("session.compaction.prune", () => {
yield* compact.prune({ sessionID: info.id }) 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") const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool") expect(part?.type).toBe("tool")
expect(part?.state.status).toBe("completed") expect(part?.state.status).toBe("completed")
@@ -589,9 +610,9 @@ describe("session.compaction.prune", () => {
provideTmpdirInstance((dir) => provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const compact = yield* SessionCompaction.Service 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({})
const a = yield* session.updateMessage({ const a = yield* ssn.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID: info.id, sessionID: info.id,
@@ -599,7 +620,7 @@ describe("session.compaction.prune", () => {
model: ref, model: ref,
time: { created: Date.now() }, time: { created: Date.now() },
}) })
yield* session.updatePart({ yield* ssn.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: a.id, messageID: a.id,
sessionID: info.id, sessionID: info.id,
@@ -626,8 +647,8 @@ describe("session.compaction.prune", () => {
time: { created: Date.now() }, time: { created: Date.now() },
finish: "end_turn", finish: "end_turn",
} }
yield* session.updateMessage(b) yield* ssn.updateMessage(b)
yield* session.updatePart({ yield* ssn.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: b.id, messageID: b.id,
sessionID: info.id, sessionID: info.id,
@@ -644,7 +665,7 @@ describe("session.compaction.prune", () => {
}, },
}) })
for (const text of ["second", "third"]) { for (const text of ["second", "third"]) {
const msg = yield* session.updateMessage({ const msg = yield* ssn.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID: info.id, sessionID: info.id,
@@ -652,7 +673,7 @@ describe("session.compaction.prune", () => {
model: ref, model: ref,
time: { created: Date.now() }, time: { created: Date.now() },
}) })
yield* session.updatePart({ yield* ssn.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: msg.id, messageID: msg.id,
sessionID: info.id, sessionID: info.id,
@@ -663,7 +684,7 @@ describe("session.compaction.prune", () => {
yield* compact.prune({ sessionID: info.id }) 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") const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool") expect(part?.type).toBe("tool")
if (part?.type === "tool" && part.state.status === "completed") { if (part?.type === "tool" && part.state.status === "completed") {
@@ -680,12 +701,12 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const msg = await user(session.id, "hello") const msg = await user(session.id, "hello")
const reply = await assistant(session.id, msg.id, tmp.path) const reply = await assistant(session.id, msg.id, tmp.path)
const rt = runtime("continue") const rt = runtime("continue")
try { try {
const msgs = await Session.messages({ sessionID: session.id }) const msgs = await svc.messages({ sessionID: session.id })
await expect( await expect(
rt.runPromise( rt.runPromise(
SessionCompaction.Service.use((svc) => SessionCompaction.Service.use((svc) =>
@@ -710,9 +731,9 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const msg = await user(session.id, "hello") 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() const done = defer()
let seen = false let seen = false
const rt = runtime("continue", Plugin.defaultLayer, wide()) const rt = runtime("continue", Plugin.defaultLayer, wide())
@@ -760,11 +781,11 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const msg = await user(session.id, "hello") const msg = await user(session.id, "hello")
const rt = runtime("compact", Plugin.defaultLayer, wide()) const rt = runtime("compact", Plugin.defaultLayer, wide())
try { try {
const msgs = await Session.messages({ sessionID: session.id }) const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise( const result = await rt.runPromise(
SessionCompaction.Service.use((svc) => SessionCompaction.Service.use((svc) =>
svc.process({ 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, (msg) => msg.info.role === "assistant" && msg.info.summary,
) )
@@ -798,11 +819,11 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const msg = await user(session.id, "hello") const msg = await user(session.id, "hello")
const rt = runtime("continue", Plugin.defaultLayer, wide()) const rt = runtime("continue", Plugin.defaultLayer, wide())
try { try {
const msgs = await Session.messages({ sessionID: session.id }) const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise( const result = await rt.runPromise(
SessionCompaction.Service.use((svc) => SessionCompaction.Service.use((svc) =>
svc.process({ 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) const last = all.at(-1)
expect(result).toBe("continue") expect(result).toBe("continue")
@@ -838,11 +859,11 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const msg = await user(session.id, "hello") const msg = await user(session.id, "hello")
const rt = runtime("continue", autocontinue(false), wide()) const rt = runtime("continue", autocontinue(false), wide())
try { try {
const msgs = await Session.messages({ sessionID: session.id }) const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise( const result = await rt.runPromise(
SessionCompaction.Service.use((svc) => SessionCompaction.Service.use((svc) =>
svc.process({ 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) const last = all.at(-1)
expect(result).toBe("continue") expect(result).toBe("continue")
@@ -881,10 +902,10 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
await user(session.id, "root") await user(session.id, "root")
const replay = await user(session.id, "image") const replay = await user(session.id, "image")
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: replay.id, messageID: replay.id,
sessionID: session.id, sessionID: session.id,
@@ -896,7 +917,7 @@ describe("session.compaction.process", () => {
const msg = await user(session.id, "current") const msg = await user(session.id, "current")
const rt = runtime("continue", Plugin.defaultLayer, wide()) const rt = runtime("continue", Plugin.defaultLayer, wide())
try { try {
const msgs = await Session.messages({ sessionID: session.id }) const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise( const result = await rt.runPromise(
SessionCompaction.Service.use((svc) => SessionCompaction.Service.use((svc) =>
svc.process({ 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(result).toBe("continue")
expect(last?.info.role).toBe("user") expect(last?.info.role).toBe("user")
@@ -929,13 +950,13 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
await user(session.id, "earlier") await user(session.id, "earlier")
const msg = await user(session.id, "current") const msg = await user(session.id, "current")
const rt = runtime("continue", Plugin.defaultLayer, wide()) const rt = runtime("continue", Plugin.defaultLayer, wide())
try { try {
const msgs = await Session.messages({ sessionID: session.id }) const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise( const result = await rt.runPromise(
SessionCompaction.Service.use((svc) => SessionCompaction.Service.use((svc) =>
svc.process({ 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(result).toBe("continue")
expect(last?.info.role).toBe("user") expect(last?.info.role).toBe("user")
@@ -989,9 +1010,9 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const msg = await user(session.id, "hello") 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 abort = new AbortController()
const rt = liveRuntime(stub.layer, wide()) const rt = liveRuntime(stub.layer, wide())
let off: (() => void) | undefined let off: (() => void) | undefined
@@ -1063,9 +1084,9 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const msg = await user(session.id, "hello") 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 abort = new AbortController()
const rt = runtime("continue", plugin(ready), wide()) const rt = runtime("continue", plugin(ready), wide())
let run: Promise<"continue" | "stop"> | undefined let run: Promise<"continue" | "stop"> | undefined
@@ -1100,7 +1121,7 @@ describe("session.compaction.process", () => {
abort.abort() abort.abort()
expect(await run).toBe("stop") 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) expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false)
} finally { } finally {
abort.abort() abort.abort()
@@ -1165,11 +1186,11 @@ describe("session.compaction.process", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const msg = await user(session.id, "hello") const msg = await user(session.id, "hello")
const rt = liveRuntime(stub.layer, wide()) const rt = liveRuntime(stub.layer, wide())
try { try {
const msgs = await Session.messages({ sessionID: session.id }) const msgs = await svc.messages({ sessionID: session.id })
await rt.runPromise( await rt.runPromise(
SessionCompaction.Service.use((svc) => SessionCompaction.Service.use((svc) =>
svc.process({ 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, (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", () => { test("normalizes standard usage to token format", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 1000, inputTokens: 1000,
@@ -1241,7 +1262,7 @@ describe("session.getUsage", () => {
test("extracts cached tokens to cache.read", () => { test("extracts cached tokens to cache.read", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 1000, inputTokens: 1000,
@@ -1265,7 +1286,7 @@ describe("session.getUsage", () => {
test("handles anthropic cache write metadata", () => { test("handles anthropic cache write metadata", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 1000, inputTokens: 1000,
@@ -1294,7 +1315,7 @@ describe("session.getUsage", () => {
test("subtracts cached tokens for anthropic provider", () => { test("subtracts cached tokens for anthropic provider", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers // AI SDK v6 normalizes inputTokens to include cached tokens for all providers
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 1000, inputTokens: 1000,
@@ -1321,7 +1342,7 @@ describe("session.getUsage", () => {
test("separates reasoning tokens from output tokens", () => { test("separates reasoning tokens from output tokens", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 1000, inputTokens: 1000,
@@ -1355,7 +1376,7 @@ describe("session.getUsage", () => {
cache: { read: 0, write: 0 }, cache: { read: 0, write: 0 },
}, },
}) })
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 0, inputTokens: 0,
@@ -1380,7 +1401,7 @@ describe("session.getUsage", () => {
test("handles undefined optional values gracefully", () => { test("handles undefined optional values gracefully", () => {
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 0, inputTokens: 0,
@@ -1416,7 +1437,7 @@ describe("session.getUsage", () => {
cache: { read: 0.3, write: 3.75 }, cache: { read: 0.3, write: 3.75 },
}, },
}) })
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 1_000_000, inputTokens: 1_000_000,
@@ -1457,7 +1478,7 @@ describe("session.getUsage", () => {
}, },
} }
if (npm === "@ai-sdk/amazon-bedrock") { if (npm === "@ai-sdk/amazon-bedrock") {
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage, usage,
metadata: { metadata: {
@@ -1478,7 +1499,7 @@ describe("session.getUsage", () => {
return return
} }
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage, usage,
metadata: { metadata: {
@@ -1499,7 +1520,7 @@ describe("session.getUsage", () => {
test("extracts cache write tokens from vertex metadata key", () => { 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 model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
const result = Session.getUsage({ const result = SessionNs.getUsage({
model, model,
usage: { usage: {
inputTokens: 1000, inputTokens: 1000,
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path" import path from "path"
import { Instance } from "../../src/project/instance" 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 { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
@@ -10,12 +11,32 @@ import { Log } from "../../src/util/log"
const root = path.join(__dirname, "../..") const root = path.join(__dirname, "../..")
Log.init({ print: false }) 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) { async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
const ids = [] as MessageID[] const ids = [] as MessageID[]
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const id = MessageID.ascending() const id = MessageID.ascending()
ids.push(id) ids.push(id)
await Session.updateMessage({ await svc.updateMessage({
id, id,
sessionID, sessionID,
role: "user", role: "user",
@@ -25,7 +46,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
tools: {}, tools: {},
mode: "", mode: "",
} as unknown as MessageV2.Info) } as unknown as MessageV2.Info)
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID, sessionID,
messageID: id, messageID: id,
@@ -38,7 +59,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
async function addUser(sessionID: SessionID, text?: string) { async function addUser(sessionID: SessionID, text?: string) {
const id = MessageID.ascending() const id = MessageID.ascending()
await Session.updateMessage({ await svc.updateMessage({
id, id,
sessionID, sessionID,
role: "user", role: "user",
@@ -49,7 +70,7 @@ async function addUser(sessionID: SessionID, text?: string) {
mode: "", mode: "",
} as unknown as MessageV2.Info) } as unknown as MessageV2.Info)
if (text) { if (text) {
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID, sessionID,
messageID: id, messageID: id,
@@ -66,7 +87,7 @@ async function addAssistant(
opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] }, opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] },
) { ) {
const id = MessageID.ascending() const id = MessageID.ascending()
await Session.updateMessage({ await svc.updateMessage({
id, id,
sessionID, sessionID,
role: "assistant", role: "assistant",
@@ -87,7 +108,7 @@ async function addAssistant(
} }
async function addCompactionPart(sessionID: SessionID, messageID: MessageID) { async function addCompactionPart(sessionID: SessionID, messageID: MessageID) {
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID, sessionID,
messageID, messageID,
@@ -101,14 +122,14 @@ describe("MessageV2.page", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
await fill(session.id, 2) await fill(session.id, 2)
const result = MessageV2.page({ sessionID: session.id, limit: 10 }) const result = MessageV2.page({ sessionID: session.id, limit: 10 })
expect(result).toBeDefined() expect(result).toBeDefined()
expect(result.items).toBeArray() expect(result.items).toBeArray()
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -117,7 +138,7 @@ describe("MessageV2.page", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 6) const ids = await fill(session.id, 6)
const a = MessageV2.page({ sessionID: session.id, limit: 2 }) const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@@ -136,7 +157,7 @@ describe("MessageV2.page", () => {
expect(c.more).toBe(false) expect(c.more).toBe(false)
expect(c.cursor).toBeUndefined() expect(c.cursor).toBeUndefined()
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -145,13 +166,13 @@ describe("MessageV2.page", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 4) const ids = await fill(session.id, 4)
const result = MessageV2.page({ sessionID: session.id, limit: 4 }) const result = MessageV2.page({ sessionID: session.id, limit: 4 })
expect(result.items.map((item) => item.info.id)).toEqual(ids) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const result = MessageV2.page({ sessionID: session.id, limit: 10 }) const result = MessageV2.page({ sessionID: session.id, limit: 10 })
expect(result.items).toEqual([]) expect(result.items).toEqual([])
expect(result.more).toBe(false) expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined() expect(result.cursor).toBeUndefined()
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -186,7 +207,7 @@ describe("MessageV2.page", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 3) const ids = await fill(session.id, 3)
const result = MessageV2.page({ sessionID: session.id, limit: 3 }) const result = MessageV2.page({ sessionID: session.id, limit: 3 })
@@ -194,7 +215,7 @@ describe("MessageV2.page", () => {
expect(result.more).toBe(false) expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined() expect(result.cursor).toBeUndefined()
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -203,7 +224,7 @@ describe("MessageV2.page", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 5) const ids = await fill(session.id, 5)
const result = MessageV2.page({ sessionID: session.id, limit: 1 }) 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.items[0].info.id).toBe(ids[ids.length - 1])
expect(result.more).toBe(true) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const [id] = await fill(session.id, 1) const [id] = await fill(session.id, 1)
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID: session.id, sessionID: session.id,
messageID: id, messageID: id,
@@ -235,7 +256,7 @@ describe("MessageV2.page", () => {
expect(result.items).toHaveLength(1) expect(result.items).toHaveLength(1)
expect(result.items[0].parts).toHaveLength(2) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 4, (i) => 1000.5 + i) const ids = await fill(session.id, 4, (i) => 1000.5 + i)
const a = MessageV2.page({ sessionID: session.id, limit: 2 }) 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(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2))
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 4, () => 1000) const ids = await fill(session.id, 4, () => 1000)
const a = MessageV2.page({ sessionID: session.id, limit: 2 }) 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.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
expect(b.more).toBe(false) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const a = await Session.create({}) const a = await svc.create({})
const b = await Session.create({}) const b = await svc.create({})
await fill(a.id, 3) await fill(a.id, 3)
await fill(b.id, 2) 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(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true)
expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true) expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true)
await Session.remove(a.id) await svc.remove(a.id)
await Session.remove(b.id) await svc.remove(b.id)
}, },
}) })
}) })
@@ -304,7 +325,7 @@ describe("MessageV2.page", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 10) const ids = await fill(session.id, 10)
const result = MessageV2.page({ sessionID: session.id, limit: 100 }) const result = MessageV2.page({ sessionID: session.id, limit: 100 })
@@ -313,7 +334,7 @@ describe("MessageV2.page", () => {
expect(result.more).toBe(false) expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined() expect(result.cursor).toBeUndefined()
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -324,13 +345,13 @@ describe("MessageV2.stream", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 5) const ids = await fill(session.id, 5)
const items = Array.from(MessageV2.stream(session.id)) const items = Array.from(MessageV2.stream(session.id))
expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const items = Array.from(MessageV2.stream(session.id)) const items = Array.from(MessageV2.stream(session.id))
expect(items).toHaveLength(0) expect(items).toHaveLength(0)
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -353,14 +374,14 @@ describe("MessageV2.stream", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 1) const ids = await fill(session.id, 1)
const items = Array.from(MessageV2.stream(session.id)) const items = Array.from(MessageV2.stream(session.id))
expect(items).toHaveLength(1) expect(items).toHaveLength(1)
expect(items[0].info.id).toBe(ids[0]) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
await fill(session.id, 3) await fill(session.id, 3)
const items = Array.from(MessageV2.stream(session.id)) const items = Array.from(MessageV2.stream(session.id))
@@ -378,7 +399,7 @@ describe("MessageV2.stream", () => {
expect(item.parts[0].type).toBe("text") 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 60) const ids = await fill(session.id, 60)
const items = Array.from(MessageV2.stream(session.id)) 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[0].info.id).toBe(ids[ids.length - 1])
expect(items[59].info.id).toBe(ids[0]) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
await fill(session.id, 1) await fill(session.id, 1)
const gen = MessageV2.stream(session.id) const gen = MessageV2.stream(session.id)
@@ -414,7 +435,7 @@ describe("MessageV2.stream", () => {
expect(first).toHaveProperty("done") expect(first).toHaveProperty("done")
expect(first.done).toBe(false) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const [id] = await fill(session.id, 1) const [id] = await fill(session.id, 1)
const result = MessageV2.parts(id) const result = MessageV2.parts(id)
@@ -433,7 +454,7 @@ describe("MessageV2.parts", () => {
expect(result[0].type).toBe("text") expect(result[0].type).toBe("text")
expect((result[0] as MessageV2.TextPart).text).toBe("m0") 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const id = await addUser(session.id) const id = await addUser(session.id)
const result = MessageV2.parts(id) const result = MessageV2.parts(id)
expect(result).toEqual([]) expect(result).toEqual([])
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -457,17 +478,17 @@ describe("MessageV2.parts", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const [id] = await fill(session.id, 1) const [id] = await fill(session.id, 1)
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID: session.id, sessionID: session.id,
messageID: id, messageID: id,
type: "text", type: "text",
text: "second", text: "second",
}) })
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID: session.id, sessionID: session.id,
messageID: id, messageID: id,
@@ -481,7 +502,7 @@ describe("MessageV2.parts", () => {
expect((result[1] as MessageV2.TextPart).text).toBe("second") expect((result[1] as MessageV2.TextPart).text).toBe("second")
expect((result[2] as MessageV2.TextPart).text).toBe("third") 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
await Session.create({}) await svc.create({})
const result = MessageV2.parts(MessageID.ascending()) const result = MessageV2.parts(MessageID.ascending())
expect(result).toEqual([]) expect(result).toEqual([])
}, },
@@ -501,14 +522,14 @@ describe("MessageV2.parts", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const [id] = await fill(session.id, 1) const [id] = await fill(session.id, 1)
const result = MessageV2.parts(id) const result = MessageV2.parts(id)
expect(result[0].sessionID).toBe(session.id) expect(result[0].sessionID).toBe(session.id)
expect(result[0].messageID).toBe(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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const [id] = await fill(session.id, 1) const [id] = await fill(session.id, 1)
const result = MessageV2.get({ sessionID: session.id, messageID: id }) const result = MessageV2.get({ sessionID: session.id, messageID: id })
@@ -529,7 +550,7 @@ describe("MessageV2.get", () => {
expect(result.parts).toHaveLength(1) expect(result.parts).toHaveLength(1)
expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow( expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow(
"NotFoundError", "NotFoundError",
) )
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -553,16 +574,16 @@ describe("MessageV2.get", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const a = await Session.create({}) const a = await svc.create({})
const b = await Session.create({}) const b = await svc.create({})
const [id] = await fill(a.id, 1) const [id] = await fill(a.id, 1)
expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError") expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError")
const result = MessageV2.get({ sessionID: a.id, messageID: id }) const result = MessageV2.get({ sessionID: a.id, messageID: id })
expect(result.info.id).toBe(id) expect(result.info.id).toBe(id)
await Session.remove(a.id) await svc.remove(a.id)
await Session.remove(b.id) await svc.remove(b.id)
}, },
}) })
}) })
@@ -571,10 +592,10 @@ describe("MessageV2.get", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const [id] = await fill(session.id, 1) const [id] = await fill(session.id, 1)
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID: session.id, sessionID: session.id,
messageID: id, messageID: id,
@@ -585,7 +606,7 @@ describe("MessageV2.get", () => {
const result = MessageV2.get({ sessionID: session.id, messageID: id }) const result = MessageV2.get({ sessionID: session.id, messageID: id })
expect(result.parts).toHaveLength(2) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const uid = await addUser(session.id, "hello") const uid = await addUser(session.id, "hello")
const aid = await addAssistant(session.id, uid) const aid = await addAssistant(session.id, uid)
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID: session.id, sessionID: session.id,
messageID: aid, messageID: aid,
@@ -611,7 +632,7 @@ describe("MessageV2.get", () => {
expect(result.parts).toHaveLength(1) expect(result.parts).toHaveLength(1)
expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const id = await addUser(session.id) const id = await addUser(session.id)
const result = MessageV2.get({ sessionID: session.id, messageID: id }) const result = MessageV2.get({ sessionID: session.id, messageID: id })
expect(result.info.id).toBe(id) expect(result.info.id).toBe(id)
expect(result.parts).toEqual([]) expect(result.parts).toEqual([])
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -638,7 +659,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 5) const ids = await fill(session.id, 5)
const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
@@ -646,7 +667,7 @@ describe("MessageV2.filterCompacted", () => {
// reversed from newest-first to chronological // reversed from newest-first to chronological
expect(result.map((item) => item.info.id)).toEqual(ids) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
// Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 // 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 // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break
const u1 = await addUser(session.id, "first question") const u1 = await addUser(session.id, "first question")
const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" }) const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" })
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID: session.id, sessionID: session.id,
messageID: a1, messageID: a1,
@@ -672,7 +693,7 @@ describe("MessageV2.filterCompacted", () => {
const u2 = await addUser(session.id, "new question") const u2 = await addUser(session.id, "new question")
const a2 = await addAssistant(session.id, u2) const a2 = await addAssistant(session.id, u2)
await Session.updatePart({ await svc.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
sessionID: session.id, sessionID: session.id,
messageID: a2, messageID: a2,
@@ -685,7 +706,7 @@ describe("MessageV2.filterCompacted", () => {
expect(result[0].info.id).toBe(u1) expect(result[0].info.id).toBe(u1)
expect(result.length).toBe(4) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const u1 = await addUser(session.id, "hello") const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1) await addCompactionPart(session.id, u1)
@@ -708,7 +729,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result).toHaveLength(2) expect(result).toHaveLength(2)
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -717,7 +738,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const u1 = await addUser(session.id, "hello") const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1) await addCompactionPart(session.id, u1)
@@ -733,7 +754,7 @@ describe("MessageV2.filterCompacted", () => {
// Error assistant doesn't add to completed, so compaction boundary never triggers // Error assistant doesn't add to completed, so compaction boundary never triggers
expect(result).toHaveLength(3) expect(result).toHaveLength(3)
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -742,7 +763,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const u1 = await addUser(session.id, "hello") const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1) await addCompactionPart(session.id, u1)
@@ -754,7 +775,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result).toHaveLength(3) expect(result).toHaveLength(3)
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -808,7 +829,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
await fill(session.id, 3) await fill(session.id, 3)
const paged = MessageV2.page({ sessionID: session.id, limit: 10 }) const paged = MessageV2.page({ sessionID: session.id, limit: 10 })
@@ -818,7 +839,7 @@ describe("MessageV2 consistency", () => {
expect(got.parts).toEqual(item.parts) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const [id] = await fill(session.id, 1) const [id] = await fill(session.id, 1)
const got = MessageV2.get({ sessionID: session.id, messageID: id }) const got = MessageV2.get({ sessionID: session.id, messageID: id })
const standalone = MessageV2.parts(id) const standalone = MessageV2.parts(id)
expect(got.parts).toEqual(standalone) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
await fill(session.id, 7) await fill(session.id, 7)
const streamed = Array.from(MessageV2.stream(session.id)) 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)) 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({ await Instance.provide({
directory: root, directory: root,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await svc.create({})
const ids = await fill(session.id, 4) const ids = await fill(session.id, 4)
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) 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)) expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id))
await Session.remove(session.id) await svc.remove(session.id)
}, },
}) })
}) })
@@ -210,7 +210,7 @@ function makeHttp() {
Layer.provide(SystemPrompt.defaultLayer), Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps), Layer.provideMerge(deps),
), ),
) ).pipe(Layer.provide(summary))
} }
const it = testEffect(makeHttp()) const it = testEffect(makeHttp())
@@ -384,25 +384,23 @@ it.live("loop calls LLM and returns assistant message", () =>
it.live("static loop returns assistant text through local provider", () => it.live("static loop returns assistant text through local provider", () =>
provideTmpdirServer( provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) { Effect.fnUntraced(function* ({ llm }) {
const session = yield* Effect.promise(() => const prompt = yield* SessionPrompt.Service
Session.create({ const sessions = yield* Session.Service
title: "Prompt provider", const session = yield* sessions.create({
permission: [{ permission: "*", pattern: "*", action: "allow" }], title: "Prompt provider",
}), permission: [{ permission: "*", pattern: "*", action: "allow" }],
) })
yield* Effect.promise(() => yield* prompt.prompt({
SessionPrompt.prompt({ sessionID: session.id,
sessionID: session.id, agent: "build",
agent: "build", noReply: true,
noReply: true, parts: [{ type: "text", text: "hello" }],
parts: [{ type: "text", text: "hello" }], })
}),
)
yield* llm.text("world") yield* llm.text("world")
const result = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id })) const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant") expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true) expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
expect(yield* llm.hits).toHaveLength(1) expect(yield* llm.hits).toHaveLength(1)
@@ -415,40 +413,36 @@ it.live("static loop returns assistant text through local provider", () =>
it.live("static loop consumes queued replies across turns", () => it.live("static loop consumes queued replies across turns", () =>
provideTmpdirServer( provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) { Effect.fnUntraced(function* ({ llm }) {
const session = yield* Effect.promise(() => const prompt = yield* SessionPrompt.Service
Session.create({ const sessions = yield* Session.Service
title: "Prompt provider turns", const session = yield* sessions.create({
permission: [{ permission: "*", pattern: "*", action: "allow" }], title: "Prompt provider turns",
}), permission: [{ permission: "*", pattern: "*", action: "allow" }],
) })
yield* Effect.promise(() => yield* prompt.prompt({
SessionPrompt.prompt({ sessionID: session.id,
sessionID: session.id, agent: "build",
agent: "build", noReply: true,
noReply: true, parts: [{ type: "text", text: "hello one" }],
parts: [{ type: "text", text: "hello one" }], })
}),
)
yield* llm.text("world one") yield* llm.text("world one")
const first = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id })) const first = yield* prompt.loop({ sessionID: session.id })
expect(first.info.role).toBe("assistant") expect(first.info.role).toBe("assistant")
expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true) expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
yield* Effect.promise(() => yield* prompt.prompt({
SessionPrompt.prompt({ sessionID: session.id,
sessionID: session.id, agent: "build",
agent: "build", noReply: true,
noReply: true, parts: [{ type: "text", text: "hello two" }],
parts: [{ type: "text", text: "hello two" }], })
}),
)
yield* llm.text("world two") yield* llm.text("world two")
const second = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id })) const second = yield* prompt.loop({ sessionID: session.id })
expect(second.info.role).toBe("assistant") expect(second.info.role).toBe("assistant")
expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true) expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
+267 -199
View File
@@ -2,6 +2,7 @@ import path from "path"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session } from "../../src/session" import { Session } from "../../src/session"
@@ -12,6 +13,12 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false }) Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
function defer<T>() { function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((done) => { const promise = new Promise<T>((done) => {
@@ -104,34 +111,39 @@ describe("session.prompt missing file", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({}) run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const missing = path.join(tmp.path, "does-not-exist.ts") const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({ const msg = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
agent: "build", agent: "build",
noReply: true, noReply: true,
parts: [ parts: [
{ type: "text", text: "please review @does-not-exist.ts" }, { type: "text", text: "please review @does-not-exist.ts" },
{ {
type: "file", type: "file",
mime: "text/plain", mime: "text/plain",
url: `file://${missing}`, url: `file://${missing}`,
filename: "does-not-exist.ts", filename: "does-not-exist.ts",
}, },
], ],
}) })
if (msg.info.role !== "user") throw new Error("expected user message") if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some( const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"), (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
) )
expect(hasFailure).toBe(true) expect(hasFailure).toBe(true)
await Session.remove(session.id) yield* sessions.remove(session.id)
}, }),
),
}) })
}) })
@@ -149,39 +161,44 @@ describe("session.prompt missing file", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({}) run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const missing = path.join(tmp.path, "still-missing.ts") const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({ const msg = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
agent: "build", agent: "build",
noReply: true, noReply: true,
parts: [ parts: [
{ {
type: "file", type: "file",
mime: "text/plain", mime: "text/plain",
url: `file://${missing}`, url: `file://${missing}`,
filename: "still-missing.ts", filename: "still-missing.ts",
}, },
{ type: "text", text: "after-file" }, { type: "text", text: "after-file" },
], ],
}) })
if (msg.info.role !== "user") throw new Error("expected user message") if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({ const stored = MessageV2.get({
sessionID: session.id, sessionID: session.id,
messageID: msg.info.id, messageID: msg.info.id,
}) })
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text) const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true) expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true) expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file") expect(text[2]).toBe("after-file")
await Session.remove(session.id) yield* sessions.remove(session.id)
}, }),
),
}) })
}) })
}) })
@@ -197,31 +214,36 @@ describe("session.prompt special characters", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({}) run(
const template = "Read @file#name.txt" Effect.gen(function* () {
const parts = await SessionPrompt.resolvePromptParts(template) const prompt = yield* SessionPrompt.Service
const fileParts = parts.filter((part) => part.type === "file") const sessions = yield* Session.Service
const session = yield* sessions.create({})
const template = "Read @file#name.txt"
const parts = yield* prompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1) expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt") expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23") expect(fileParts[0].url).toContain("%23")
const decodedPath = fileURLToPath(fileParts[0].url) const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt")) expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({ const message = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
parts, parts,
noReply: true, noReply: true,
}) })
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text") const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content")) const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true) expect(hasContent).toBe(true)
await Session.remove(session.id) yield* sessions.remove(session.id)
}, }),
),
}) })
}) })
}) })
@@ -273,21 +295,26 @@ describe("session.prompt regression", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({ title: "Prompt regression" }) run(
const result = await SessionPrompt.prompt({ Effect.gen(function* () {
sessionID: session.id, const prompt = yield* SessionPrompt.Service
agent: "build", const sessions = yield* Session.Service
parts: [{ type: "text", text: "Where is SessionProcessor?" }], const session = yield* sessions.create({ title: "Prompt regression" })
}) const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
expect(result.info.role).toBe("assistant") expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true) expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
const msgs = await Session.messages({ sessionID: session.id }) const msgs = yield* sessions.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(calls).toBe(1) expect(calls).toBe(1)
}, }),
),
}) })
} finally { } finally {
server.stop(true) server.stop(true)
@@ -342,36 +369,45 @@ describe("session.prompt regression", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({ title: "Prompt cancel regression" }) run(
const run = SessionPrompt.prompt({ Effect.gen(function* () {
sessionID: session.id, const prompt = yield* SessionPrompt.Service
agent: "build", const sessions = yield* Session.Service
parts: [{ type: "text", text: "Cancel me" }], const session = yield* sessions.create({ title: "Prompt cancel regression" })
}) const task = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
}),
)
await ready.promise yield* Effect.promise(() => ready.promise)
await SessionPrompt.cancel(session.id) yield* prompt.cancel(session.id)
const result = await Promise.race([ const result = yield* Effect.promise(() =>
run, Promise.race([
new Promise<never>((_, reject) => task,
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000), new Promise<never>((_, reject) =>
), setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
]) ),
]),
)
expect(result.info.role).toBe("assistant") expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") { if (result.info.role === "assistant") {
expect(result.info.error?.name).toBe("MessageAbortedError") expect(result.info.error?.name).toBe("MessageAbortedError")
} }
const msgs = await Session.messages({ sessionID: session.id }) const msgs = yield* sessions.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant") const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant") expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") { if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError") expect(last.info.error?.name).toBe("MessageAbortedError")
} }
}, }),
),
}) })
} finally { } finally {
server.stop(true) server.stop(true)
@@ -399,45 +435,50 @@ describe("session.prompt agent variant", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({}) run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const other = await SessionPrompt.prompt({ const other = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
agent: "build", agent: "build",
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") }, model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
noReply: true, noReply: true,
parts: [{ type: "text", text: "hello" }], parts: [{ type: "text", text: "hello" }],
}) })
if (other.info.role !== "user") throw new Error("expected user message") if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.model.variant).toBeUndefined() expect(other.info.model.variant).toBeUndefined()
const match = await SessionPrompt.prompt({ const match = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
agent: "build", agent: "build",
noReply: true, noReply: true,
parts: [{ type: "text", text: "hello again" }], parts: [{ type: "text", text: "hello again" }],
}) })
if (match.info.role !== "user") throw new Error("expected user message") if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ expect(match.info.model).toEqual({
providerID: ProviderID.make("openai"), providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5.2"), modelID: ModelID.make("gpt-5.2"),
variant: "xhigh", variant: "xhigh",
}) })
expect(match.info.model.variant).toBe("xhigh") expect(match.info.model.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({ const override = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
agent: "build", agent: "build",
noReply: true, noReply: true,
variant: "high", variant: "high",
parts: [{ type: "text", text: "hello third" }], parts: [{ type: "text", text: "hello third" }],
}) })
if (override.info.role !== "user") throw new Error("expected user message") if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.model.variant).toBe("high") expect(override.info.model.variant).toBe("high")
await Session.remove(session.id) yield* sessions.remove(session.id)
}, }),
),
}) })
} finally { } finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY if (prev === undefined) delete process.env.OPENAI_API_KEY
@@ -451,24 +492,33 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({}) run(
const err = await SessionPrompt.prompt({ Effect.gen(function* () {
sessionID: session.id, const prompt = yield* SessionPrompt.Service
agent: "nonexistent-agent-xyz", const sessions = yield* Session.Service
noReply: true, const session = yield* sessions.create({})
parts: [{ type: "text", text: "hello" }], const err = yield* Effect.promise(() =>
}).then( Effect.runPromise(
() => undefined, prompt.prompt({
(e) => e, sessionID: session.id,
) agent: "nonexistent-agent-xyz",
expect(err).toBeDefined() noReply: true,
expect(err).not.toBeInstanceOf(TypeError) parts: [{ type: "text", text: "hello" }],
expect(NamedError.Unknown.isInstance(err)).toBe(true) }),
if (NamedError.Unknown.isInstance(err)) { ).then(
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"') () => undefined,
} (e) => e,
}, ),
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
}),
),
}) })
}, 30000) }, 30000)
@@ -476,22 +526,31 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({}) run(
const err = await SessionPrompt.prompt({ Effect.gen(function* () {
sessionID: session.id, const prompt = yield* SessionPrompt.Service
agent: "nonexistent-agent-xyz", const sessions = yield* Session.Service
noReply: true, const session = yield* sessions.create({})
parts: [{ type: "text", text: "hello" }], const err = yield* Effect.promise(() =>
}).then( Effect.runPromise(
() => undefined, prompt.prompt({
(e) => e, sessionID: session.id,
) agent: "nonexistent-agent-xyz",
expect(NamedError.Unknown.isInstance(err)).toBe(true) noReply: true,
if (NamedError.Unknown.isInstance(err)) { parts: [{ type: "text", text: "hello" }],
expect(err.data.message).toContain("build") }),
} ).then(
}, () => undefined,
(e) => e,
),
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
}),
),
}) })
}, 30000) }, 30000)
@@ -499,24 +558,33 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: () =>
const session = await Session.create({}) run(
const err = await SessionPrompt.command({ Effect.gen(function* () {
sessionID: session.id, const prompt = yield* SessionPrompt.Service
command: "nonexistent-command-xyz", const sessions = yield* Session.Service
arguments: "", const session = yield* sessions.create({})
}).then( const err = yield* Effect.promise(() =>
() => undefined, Effect.runPromise(
(e) => e, prompt.command({
) sessionID: session.id,
expect(err).toBeDefined() command: "nonexistent-command-xyz",
expect(err).not.toBeInstanceOf(TypeError) arguments: "",
expect(NamedError.Unknown.isInstance(err)).toBe(true) }),
if (NamedError.Unknown.isInstance(err)) { ).then(
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"') () => undefined,
expect(err.data.message).toContain("init") (e) => e,
} ),
}, )
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
}),
),
}) })
}, 30000) }, 30000)
}) })
+46 -30
View File
@@ -1,43 +1,62 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Session } from "../../src/session" import { Session as SessionNs } from "../../src/session"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2" 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" import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..") const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false }) 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", () => { describe("session.created event", () => {
test("should emit session.created event when session is created", async () => { test("should emit session.created event when session is created", async () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
let eventReceived = false 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 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)) await new Promise((resolve) => setTimeout(resolve, 100))
unsub() unsub()
expect(eventReceived).toBe(true) expect(eventReceived).toBe(true)
expect(receivedInfo).toBeDefined() expect(receivedInfo).toBeDefined()
expect(receivedInfo?.id).toBe(session.id) expect(receivedInfo?.id).toBe(info.id)
expect(receivedInfo?.projectID).toBe(session.projectID) expect(receivedInfo?.projectID).toBe(info.projectID)
expect(receivedInfo?.directory).toBe(session.directory) expect(receivedInfo?.directory).toBe(info.directory)
expect(receivedInfo?.title).toBe(session.title) 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 () => { fn: async () => {
const events: string[] = [] const events: string[] = []
const unsubCreated = Bus.subscribe(Session.Event.Created, () => { const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
events.push("created") events.push("created")
}) })
const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => { const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
events.push("updated") events.push("updated")
}) })
const session = await Session.create({}) const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
unsubCreated() unsubCreated()
unsubUpdated() unsubUpdated()
@@ -67,7 +84,7 @@ describe("session.created event", () => {
expect(events).toContain("updated") expect(events).toContain("updated")
expect(events.indexOf("created")).toBeLessThan(events.indexOf("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({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const session = await Session.create({}) const info = await create({})
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
await Session.updateMessage({ await updateMessage({
id: messageID, id: messageID,
sessionID: session.id, sessionID: info.id,
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent: "user", agent: "user",
@@ -110,15 +127,14 @@ describe("step-finish token propagation via Bus event", () => {
const partInput = { const partInput = {
id: PartID.ascending(), id: PartID.ascending(),
messageID, messageID,
sessionID: session.id, sessionID: info.id,
type: "step-finish" as const, type: "step-finish" as const,
reason: "stop", reason: "stop",
cost: 0.005, cost: 0.005,
tokens, tokens,
} }
await Session.updatePart(partInput) await updatePart(partInput)
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
expect(received).toBeDefined() expect(received).toBeDefined()
@@ -134,7 +150,7 @@ describe("step-finish token propagation via Bus event", () => {
expect(received).not.toBe(partInput) expect(received).not.toBe(partInput)
unsub() unsub()
await Session.remove(session.id) await remove(info.id)
}, },
}) })
}, },
@@ -146,17 +162,17 @@ describe("Session", () => {
test("remove works without an instance", async () => { test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const session = await Instance.provide({ const info = await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => Session.create({ title: "remove-without-instance" }), fn: () => create({ title: "remove-without-instance" }),
}) })
await expect(async () => { await expect(async () => {
await Session.remove(session.id) await remove(info.id)
}).not.toThrow() }).not.toThrow()
let missing = false let missing = false
await Session.get(session.id).catch(() => { await get(info.id).catch(() => {
missing = true missing = true
}) })
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
@@ -20,51 +21,63 @@ async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
}) })
} }
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
describe("StructuredOutput Integration", () => { describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)( test.skipIf(!hasApiKey)(
"produces structured output with simple schema", "produces structured output with simple schema",
async () => { async () => {
await withInstance(async () => { await withInstance(() =>
const session = await Session.create({ title: "Structured Output Test" }) run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
const result = await SessionPrompt.prompt({ const result = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
parts: [ parts: [
{ {
type: "text", type: "text",
text: "What is 2 + 2? Provide a simple answer.", text: "What is 2 + 2? Provide a simple answer.",
}, },
], ],
format: { format: {
type: "json_schema", type: "json_schema",
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
answer: { type: "number", description: "The numerical answer" }, answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" }, explanation: { type: "string", description: "Brief explanation" },
},
required: ["answer"],
},
retryCount: 0,
}, },
required: ["answer"], })
},
retryCount: 0,
},
})
// Verify structured output was captured (only on assistant messages) // Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant") expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") { if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined() expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object") expect(typeof result.info.structured).toBe("object")
const output = result.info.structured as any const output = result.info.structured as any
expect(output.answer).toBe(4) expect(output.answer).toBe(4)
// Verify no error was set // Verify no error was set
expect(result.info.error).toBeUndefined() expect(result.info.error).toBeUndefined()
} }
// Clean up // Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize // Note: Not removing session to avoid race with background SessionSummary.summarize
}) }),
),
)
}, },
60000, 60000,
) )
@@ -72,62 +85,68 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)( test.skipIf(!hasApiKey)(
"produces structured output with nested objects", "produces structured output with nested objects",
async () => { async () => {
await withInstance(async () => { await withInstance(() =>
const session = await Session.create({ title: "Nested Schema Test" }) run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
const result = await SessionPrompt.prompt({ const result = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
parts: [ parts: [
{ {
type: "text", type: "text",
text: "Tell me about Anthropic company in a structured format.", text: "Tell me about Anthropic company in a structured format.",
}, },
], ],
format: { format: {
type: "json_schema", type: "json_schema",
schema: { schema: {
type: "object",
properties: {
company: {
type: "object", type: "object",
properties: { properties: {
name: { type: "string" }, company: {
founded: { type: "number" }, type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
},
}, },
required: ["name", "founded"], required: ["company"],
},
products: {
type: "array",
items: { type: "string" },
}, },
retryCount: 0,
}, },
required: ["company"], })
},
retryCount: 0,
},
})
// Verify structured output was captured (only on assistant messages) // Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant") expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") { if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined() expect(result.info.structured).toBeDefined()
const output = result.info.structured as any const output = result.info.structured as any
expect(output.company).toBeDefined() expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic") expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number") expect(typeof output.company.founded).toBe("number")
if (output.products) { if (output.products) {
expect(Array.isArray(output.products)).toBe(true) expect(Array.isArray(output.products)).toBe(true)
} }
// Verify no error was set // Verify no error was set
expect(result.info.error).toBeUndefined() expect(result.info.error).toBeUndefined()
} }
// Clean up // Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize // Note: Not removing session to avoid race with background SessionSummary.summarize
}) }),
),
)
}, },
60000, 60000,
) )
@@ -135,35 +154,41 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)( test.skipIf(!hasApiKey)(
"works with text outputFormat (default)", "works with text outputFormat (default)",
async () => { async () => {
await withInstance(async () => { await withInstance(() =>
const session = await Session.create({ title: "Text Output Test" }) run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
const result = await SessionPrompt.prompt({ const result = yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
parts: [ parts: [
{ {
type: "text", type: "text",
text: "Say hello.", text: "Say hello.",
}, },
], ],
format: { format: {
type: "text", type: "text",
}, },
}) })
// Verify no structured output (text mode) and no error // Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant") expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") { if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined() expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined() expect(result.info.error).toBeUndefined()
} }
// Verify we got a response with parts // Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0) expect(result.parts.length).toBeGreaterThan(0)
// Clean up // Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize // Note: Not removing session to avoid race with background SessionSummary.summarize
}) }),
),
)
}, },
60000, 60000,
) )
@@ -171,47 +196,53 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)( test.skipIf(!hasApiKey)(
"stores outputFormat on user message", "stores outputFormat on user message",
async () => { async () => {
await withInstance(async () => { await withInstance(() =>
const session = await Session.create({ title: "OutputFormat Storage Test" }) run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
await SessionPrompt.prompt({ yield* prompt.prompt({
sessionID: session.id, sessionID: session.id,
parts: [ parts: [
{ {
type: "text", type: "text",
text: "What is 1 + 1?", text: "What is 1 + 1?",
}, },
], ],
format: { format: {
type: "json_schema", type: "json_schema",
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
result: { type: "number" }, result: { type: "number" },
},
required: ["result"],
},
retryCount: 3,
}, },
required: ["result"], })
},
retryCount: 3,
},
})
// Get all messages from session // Get all messages from session
const messages = await Session.messages({ sessionID: session.id }) const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user") const userMessage = messages.find((m) => m.info.role === "user")
// Verify outputFormat was stored on user message // Verify outputFormat was stored on user message
expect(userMessage).toBeDefined() expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") { if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined() expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema") expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") { if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3) expect(userMessage.info.format.retryCount).toBe(3)
} }
} }
// Clean up // Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize // Note: Not removing session to avoid race with background SessionSummary.summarize
}) }),
),
)
}, },
60000, 60000,
) )
+1 -1
View File
@@ -76,7 +76,7 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void;
} }
} }
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts { function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts {
const id = MessageID.ascending() const id = MessageID.ascending()
return { return {
info: { info: {