refactor(session): remove prompt async facade exports
This commit is contained in:
parent
2728f6413e
commit
2fc5b00537
8 changed files with 605 additions and 546 deletions
|
|
@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
type GitHubAuthor = {
|
type GitHubAuthor = {
|
||||||
login: string
|
login: string
|
||||||
|
|
@ -937,96 +938,86 @@ export const GithubRunCommand = cmd({
|
||||||
async function chat(message: string, files: PromptFiles = []) {
|
async function chat(message: string, files: PromptFiles = []) {
|
||||||
console.log("Sending message to opencode...")
|
console.log("Sending message to opencode...")
|
||||||
|
|
||||||
const result = await SessionPrompt.prompt({
|
return AppRuntime.runPromise(
|
||||||
sessionID: session.id,
|
Effect.gen(function* () {
|
||||||
messageID: MessageID.ascending(),
|
const prompt = yield* SessionPrompt.Service
|
||||||
variant,
|
const result = yield* prompt.prompt({
|
||||||
model: {
|
sessionID: session.id,
|
||||||
providerID,
|
messageID: MessageID.ascending(),
|
||||||
modelID,
|
variant,
|
||||||
},
|
model: {
|
||||||
// agent is omitted - server will use default_agent from config or fall back to "build"
|
providerID,
|
||||||
parts: [
|
modelID,
|
||||||
{
|
|
||||||
id: PartID.ascending(),
|
|
||||||
type: "text",
|
|
||||||
text: message,
|
|
||||||
},
|
|
||||||
...files.flatMap((f) => [
|
|
||||||
{
|
|
||||||
id: PartID.ascending(),
|
|
||||||
type: "file" as const,
|
|
||||||
mime: f.mime,
|
|
||||||
url: `data:${f.mime};base64,${f.content}`,
|
|
||||||
filename: f.filename,
|
|
||||||
source: {
|
|
||||||
type: "file" as const,
|
|
||||||
text: {
|
|
||||||
value: f.replacement,
|
|
||||||
start: f.start,
|
|
||||||
end: f.end,
|
|
||||||
},
|
|
||||||
path: f.filename,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]),
|
// agent is omitted - server will use default_agent from config or fall back to "build"
|
||||||
],
|
parts: [
|
||||||
})
|
{
|
||||||
|
id: PartID.ascending(),
|
||||||
|
type: "text",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
...files.flatMap((f) => [
|
||||||
|
{
|
||||||
|
id: PartID.ascending(),
|
||||||
|
type: "file" as const,
|
||||||
|
mime: f.mime,
|
||||||
|
url: `data:${f.mime};base64,${f.content}`,
|
||||||
|
filename: f.filename,
|
||||||
|
source: {
|
||||||
|
type: "file" as const,
|
||||||
|
text: {
|
||||||
|
value: f.replacement,
|
||||||
|
start: f.start,
|
||||||
|
end: f.end,
|
||||||
|
},
|
||||||
|
path: f.filename,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
// result should always be assistant just satisfying type checker
|
if (result.info.role === "assistant" && result.info.error) {
|
||||||
if (result.info.role === "assistant" && result.info.error) {
|
const err = result.info.error
|
||||||
const err = result.info.error
|
console.error("Agent error:", err)
|
||||||
console.error("Agent error:", err)
|
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
|
||||||
|
throw new Error(`${err.name}: ${err.data?.message || ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (err.name === "ContextOverflowError") {
|
const text = extractResponseText(result.parts)
|
||||||
throw new Error(formatPromptTooLargeError(files))
|
if (text) return text
|
||||||
}
|
|
||||||
|
|
||||||
const errorMsg = err.data?.message || ""
|
console.log("Requesting summary from agent...")
|
||||||
throw new Error(`${err.name}: ${errorMsg}`)
|
const summary = yield* prompt.prompt({
|
||||||
}
|
sessionID: session.id,
|
||||||
|
messageID: MessageID.ascending(),
|
||||||
|
variant,
|
||||||
|
model: {
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
},
|
||||||
|
tools: { "*": false },
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: PartID.ascending(),
|
||||||
|
type: "text",
|
||||||
|
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
const text = extractResponseText(result.parts)
|
if (summary.info.role === "assistant" && summary.info.error) {
|
||||||
if (text) return text
|
const err = summary.info.error
|
||||||
|
console.error("Summary agent error:", err)
|
||||||
|
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
|
||||||
|
throw new Error(`${err.name}: ${err.data?.message || ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
// No text part (tool-only or reasoning-only) - ask agent to summarize
|
const summaryText = extractResponseText(summary.parts)
|
||||||
console.log("Requesting summary from agent...")
|
if (!summaryText) throw new Error("Failed to get summary from agent")
|
||||||
const summary = await SessionPrompt.prompt({
|
return summaryText
|
||||||
sessionID: session.id,
|
}),
|
||||||
messageID: MessageID.ascending(),
|
)
|
||||||
variant,
|
|
||||||
model: {
|
|
||||||
providerID,
|
|
||||||
modelID,
|
|
||||||
},
|
|
||||||
tools: { "*": false }, // Disable all tools to force text response
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: PartID.ascending(),
|
|
||||||
type: "text",
|
|
||||||
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (summary.info.role === "assistant" && summary.info.error) {
|
|
||||||
const err = summary.info.error
|
|
||||||
console.error("Summary agent error:", err)
|
|
||||||
|
|
||||||
if (err.name === "ContextOverflowError") {
|
|
||||||
throw new Error(formatPromptTooLargeError(files))
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMsg = err.data?.message || ""
|
|
||||||
throw new Error(`${err.name}: ${errorMsg}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const summaryText = extractResponseText(summary.parts)
|
|
||||||
if (!summaryText) {
|
|
||||||
throw new Error("Failed to get summary from agent")
|
|
||||||
}
|
|
||||||
|
|
||||||
return summaryText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOidcToken() {
|
async function getOidcToken() {
|
||||||
|
|
|
||||||
|
|
@ -341,13 +341,17 @@ export const SessionRoutes = lazy(() =>
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await SessionPrompt.command({
|
await AppRuntime.runPromise(
|
||||||
sessionID,
|
SessionPrompt.Service.use((svc) =>
|
||||||
messageID: body.messageID,
|
svc.command({
|
||||||
model: body.providerID + "/" + body.modelID,
|
sessionID,
|
||||||
command: Command.Default.INIT,
|
messageID: body.messageID,
|
||||||
arguments: "",
|
model: body.providerID + "/" + body.modelID,
|
||||||
})
|
command: Command.Default.INIT,
|
||||||
|
arguments: "",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -407,7 +411,7 @@ export const SessionRoutes = lazy(() =>
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
await SessionPrompt.cancel(c.req.valid("param").sessionID)
|
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -875,7 +879,9 @@ export const SessionRoutes = lazy(() =>
|
||||||
return stream(c, async (stream) => {
|
return stream(c, async (stream) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
const msg = await SessionPrompt.prompt({ ...body, sessionID })
|
const msg = await AppRuntime.runPromise(
|
||||||
|
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
|
||||||
|
)
|
||||||
stream.write(JSON.stringify(msg))
|
stream.write(JSON.stringify(msg))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -904,7 +910,7 @@ export const SessionRoutes = lazy(() =>
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
|
AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => {
|
||||||
log.error("prompt_async failed", { sessionID, error: err })
|
log.error("prompt_async failed", { sessionID, error: err })
|
||||||
Bus.publish(Session.Event.Error, {
|
Bus.publish(Session.Event.Error, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -948,7 +954,7 @@ export const SessionRoutes = lazy(() =>
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
const msg = await SessionPrompt.command({ ...body, sessionID })
|
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
|
||||||
return c.json(msg)
|
return c.json(msg)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -980,7 +986,7 @@ export const SessionRoutes = lazy(() =>
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
const msg = await SessionPrompt.shell({ ...body, sessionID })
|
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
|
||||||
return c.json(msg)
|
return c.json(msg)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import { Session } from "../../src/session"
|
import { Session } from "../../src/session"
|
||||||
import { SessionPrompt } from "../../src/session/prompt"
|
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
|
@ -15,20 +13,18 @@ afterEach(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("session action routes", () => {
|
describe("session action routes", () => {
|
||||||
test("abort route calls SessionPrompt.cancel", async () => {
|
test("abort route returns success", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
|
|
||||||
const app = Server.Default().app
|
const app = Server.Default().app
|
||||||
|
|
||||||
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
|
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toBe(true)
|
expect(await res.json()).toBe(true)
|
||||||
expect(cancel).toHaveBeenCalledWith(session.id)
|
|
||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue