merge dev

This commit is contained in:
Shoubhit Dash 2026-04-16 17:30:14 +05:30
commit 2e18a603f0
992 changed files with 59696 additions and 106347 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2"
@ -8,6 +9,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "../../src/global"
import { tmpdir } from "../fixture/fixture"
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
function loaded(filepath: string): MessageV2.WithParts[] {
const sessionID = SessionID.make("session-loaded-1")
const messageID = MessageID.make("message-loaded-1")
@ -57,17 +61,22 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
const results = await Instruction.resolve(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
},
const results = yield* svc.resolve(
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([])
}),
),
),
})
})
@ -80,18 +89,23 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
const results = await Instruction.resolve(
[],
path.join(tmp.path, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
},
const results = yield* svc.resolve(
[],
path.join(tmp.path, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}),
),
),
})
})
@ -104,14 +118,19 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = await Instruction.systemPaths()
expect(system.has(filepath)).toBe(false)
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = await Instruction.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
},
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
expect(results).toEqual([])
}),
),
),
})
})
@ -124,17 +143,22 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
const first = await Instruction.resolve([], filepath, id)
const second = await Instruction.resolve([], filepath, id)
const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
expect(second).toEqual([])
},
expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
expect(second).toEqual([])
}),
),
),
})
})
@ -147,18 +171,23 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
const first = await Instruction.resolve([], filepath, id)
await Instruction.clear(id)
const second = await Instruction.resolve([], filepath, id)
const first = yield* svc.resolve([], filepath, id)
yield* svc.clear(id)
const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
},
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
}),
),
),
})
})
@ -171,21 +200,78 @@ describe("Instruction.resolve", () => {
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
const results = await Instruction.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
},
const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
}),
),
),
})
})
test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
})
describe("Instruction.system", () => {
test("loads both project and global AGENTS.md when both exist", async () => {
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
delete process.env["OPENCODE_CONFIG_DIR"]
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions")
},
})
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
const rules = yield* svc.system()
expect(rules).toHaveLength(2)
expect(rules).toContain(
`Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`,
)
expect(rules).toContain(
`Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`,
)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
}
}
})
})
describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
let originalConfigDir: string | undefined
@ -221,11 +307,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
@ -248,11 +339,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
@ -274,10 +370,15 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await Instruction.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig

View file

@ -1,20 +1,36 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { tool, type ModelMessage } from "ai"
import { Cause, Exit, Stream } from "effect"
import { Cause, Effect, Exit, Stream } from "effect"
import z from "zod"
import { makeRuntime } from "../../src/effect/run-service"
import { LLM } from "../../src/session/llm"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { ProviderTransform } from "../../src/provider/transform"
import { ModelsDev } from "../../src/provider/models"
import { Provider } from "../../src/provider"
import { ProviderTransform } from "../../src/provider"
import { ModelsDev } from "../../src/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem"
import { Filesystem } from "../../src/util"
import { tmpdir } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent"
import type { MessageV2 } from "../../src/session/message-v2"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
async function getModel(providerID: ProviderID, modelID: ModelID) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.getModel(providerID, modelID)
}),
)
}
const llm = makeRuntime(LLM.Service, LLM.defaultLayer)
async function drain(input: LLM.StreamInput) {
return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain))
}
describe("session.llm.hasToolCalls", () => {
test("returns false for empty messages array", () => {
@ -213,7 +229,7 @@ beforeEach(() => {
})
afterAll(() => {
state.server?.stop()
void state.server?.stop()
})
function createChatStream(text: string) {
@ -325,7 +341,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-1")
const agent = {
name: "test",
@ -345,20 +361,16 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const body = capture.body
const headers = capture.headers
@ -383,80 +395,6 @@ describe("session.llm.stream", () => {
})
})
test("raw stream abort signal cancels provider response body promptly", async () => {
const server = state.server
if (!server) throw new Error("Server not initialized")
const providerID = "alibaba"
const modelID = "qwen-plus"
const fixture = await loadFixture(providerID, modelID)
const model = fixture.model
const pending = waitStreamingRequest("/chat/completions")
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: [providerID],
provider: {
[providerID]: {
options: {
apiKey: "test-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-raw-abort")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-raw-abort"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
} satisfies MessageV2.User
const ctrl = new AbortController()
const result = await LLM.stream({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: ctrl.signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
const iter = result.fullStream[Symbol.asyncIterator]()
await pending.request
await iter.next()
ctrl.abort()
await Promise.race([pending.responseCanceled, timeout(500)])
await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined)
await iter.return?.()
},
})
})
test("service stream cancellation cancels provider response body promptly", async () => {
const server = state.server
if (!server) throw new Error("Server not initialized")
@ -490,7 +428,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-service-abort")
const agent = {
name: "test",
@ -508,8 +446,7 @@ describe("session.llm.stream", () => {
} satisfies MessageV2.User
const ctrl = new AbortController()
const { runPromiseExit } = makeRuntime(LLM.Service, LLM.defaultLayer)
const run = runPromiseExit(
const run = llm.runPromiseExit(
(svc) =>
svc
.stream({
@ -581,7 +518,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-tools")
const agent = {
name: "test",
@ -600,14 +537,13 @@ describe("session.llm.stream", () => {
tools: { question: true },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
permission: [{ permission: "question", pattern: "*", action: "allow" }],
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {
question: tool({
@ -618,9 +554,6 @@ describe("session.llm.stream", () => {
},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
@ -699,7 +632,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-2")
const agent = {
name: "test",
@ -718,20 +651,16 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const body = capture.body
@ -819,7 +748,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-data-url")
const agent = {
name: "test",
@ -837,13 +766,12 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [
{
role: "user",
@ -861,9 +789,6 @@ describe("session.llm.stream", () => {
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
},
@ -942,7 +867,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-3")
const agent = {
name: "test",
@ -962,20 +887,16 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const body = capture.body
@ -988,6 +909,269 @@ describe("session.llm.stream", () => {
})
})
test("sends anthropic tool_use blocks with tool_result immediately after them", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const source = await loadFixture("anthropic", "claude-opus-4-6")
const model = source.model
const chunks = [
{
type: "message_start",
message: {
id: "msg-tool-order",
model: model.id,
usage: {
input_tokens: 3,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
},
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "ok" },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
usage: {
input_tokens: 3,
output_tokens: 2,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
},
{ type: "message_stop" },
]
const request = waitRequest("/messages", createEventResponse(chunks))
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic"],
provider: {
anthropic: {
name: "Anthropic",
env: ["ANTHROPIC_API_KEY"],
npm: "@ai-sdk/anthropic",
api: "https://api.anthropic.com/v1",
models: {
[model.id]: model,
},
options: {
apiKey: "test-anthropic-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-anthropic-tools")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-anthropic-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id, variant: "max" },
} satisfies MessageV2.User
const input = [
{
info: {
id: "msg_user",
sessionID,
role: "user",
time: { created: 1 },
agent: "gentleman",
model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
},
parts: [
{
id: "p_user",
sessionID,
messageID: "msg_user",
type: "text",
text: "Can you check whether there are any PDF files in my home directory?",
},
],
},
{
info: {
id: "msg_call",
sessionID,
parentID: "msg_user",
role: "assistant",
mode: "gentleman",
agent: "gentleman",
variant: "max",
path: { cwd: "/root", root: "/" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: "claude-opus-4-6",
providerID: "anthropic",
time: { created: 2, completed: 3 },
finish: "tool-calls",
},
parts: [
{
id: "p_step",
sessionID,
messageID: "msg_call",
type: "step-start",
},
{
id: "p_read",
sessionID,
messageID: "msg_call",
type: "tool",
tool: "read",
callID: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
state: {
status: "completed",
input: { filePath: "/root" },
output: "<path>/root</path>",
metadata: {},
title: "root",
time: { start: 10, end: 11 },
},
},
{
id: "p_glob",
sessionID,
messageID: "msg_call",
type: "tool",
tool: "glob",
callID: "toolu_01APxrADs7VozN8uWzw9WwHr",
state: {
status: "completed",
input: { pattern: "**/*.pdf", path: "/root" },
output: "No files found",
metadata: {},
title: "root",
time: { start: 12, end: 13 },
},
},
{
id: "p_text",
sessionID,
messageID: "msg_call",
type: "text",
text: "I checked your home directory and looked for PDF files.",
time: { start: 14, end: 15 },
},
],
},
] as any[]
await drain({
user,
sessionID,
model: resolved,
agent,
system: [],
messages: await MessageV2.toModelMessages(input as any, resolved),
tools: {
read: tool({
description: "Stub read tool",
inputSchema: z.object({
filePath: z.string(),
}),
execute: async () => ({ output: "stub" }),
}),
glob: tool({
description: "Stub glob tool",
inputSchema: z.object({
pattern: z.string(),
path: z.string().optional(),
}),
execute: async () => ({ output: "stub" }),
}),
},
})
const capture = await request
const body = capture.body
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
expect(body.messages).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "Can you check whether there are any PDF files in my home directory?" }],
},
{
role: "assistant",
content: [
{
type: "text",
text: "I checked your home directory and looked for PDF files.",
},
{
type: "tool_use",
id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
name: "read",
input: { filePath: "/root" },
},
{
type: "tool_use",
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
name: "glob",
input: { pattern: "**/*.pdf", path: "/root" },
cache_control: {
type: "ephemeral",
},
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
content: "<path>/root</path>",
},
{
type: "tool_result",
tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
content: "No files found",
cache_control: {
type: "ephemeral",
},
},
],
},
])
},
})
})
test("sends Google API payload for Gemini models", async () => {
const server = state.server
if (!server) {
@ -997,7 +1181,6 @@ describe("session.llm.stream", () => {
const providerID = "google"
const modelID = "gemini-2.5-flash"
const fixture = await loadFixture(providerID, modelID)
const provider = fixture.provider
const model = fixture.model
const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent`
@ -1043,7 +1226,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-4")
const agent = {
name: "test",
@ -1063,20 +1246,16 @@ describe("session.llm.stream", () => {
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
} satisfies MessageV2.User
const stream = await LLM.stream({
await drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const body = capture.body
const config = body.generationConfig as

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { APICallError } from "ai"
import { MessageV2 } from "../../src/session/message-v2"
import type { Provider } from "../../src/provider/provider"
import type { Provider } from "../../src/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { Question } from "../../src/question"

View file

@ -1,21 +1,42 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Log } from "../../src/util/log"
import { Log } from "../../src/util"
const root = path.join(__dirname, "../..")
Log.init({ print: false })
void Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
const ids = [] as MessageID[]
for (let i = 0; i < count; i++) {
const id = MessageID.ascending()
ids.push(id)
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "user",
@ -25,7 +46,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
tools: {},
mode: "",
} as unknown as MessageV2.Info)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
@ -38,7 +59,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
async function addUser(sessionID: SessionID, text?: string) {
const id = MessageID.ascending()
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "user",
@ -49,7 +70,7 @@ async function addUser(sessionID: SessionID, text?: string) {
mode: "",
} as unknown as MessageV2.Info)
if (text) {
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
@ -66,7 +87,7 @@ async function addAssistant(
opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] },
) {
const id = MessageID.ascending()
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "assistant",
@ -87,7 +108,7 @@ async function addAssistant(
}
async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tailStartID?: MessageID) {
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID,
@ -102,14 +123,14 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 2)
const result = MessageV2.page({ sessionID: session.id, limit: 10 })
expect(result).toBeDefined()
expect(result.items).toBeArray()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -118,7 +139,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 6)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@ -137,7 +158,7 @@ describe("MessageV2.page", () => {
expect(c.more).toBe(false)
expect(c.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -146,13 +167,13 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4)
const result = MessageV2.page({ sessionID: session.id, limit: 4 })
expect(result.items.map((item) => item.info.id)).toEqual(ids)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -161,14 +182,14 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const result = MessageV2.page({ sessionID: session.id, limit: 10 })
expect(result.items).toEqual([])
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -187,7 +208,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 3)
const result = MessageV2.page({ sessionID: session.id, limit: 3 })
@ -195,7 +216,7 @@ describe("MessageV2.page", () => {
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -204,7 +225,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const result = MessageV2.page({ sessionID: session.id, limit: 1 })
@ -212,7 +233,7 @@ describe("MessageV2.page", () => {
expect(result.items[0].info.id).toBe(ids[ids.length - 1])
expect(result.more).toBe(true)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -221,10 +242,10 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@ -236,7 +257,7 @@ describe("MessageV2.page", () => {
expect(result.items).toHaveLength(1)
expect(result.items[0].parts).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -245,7 +266,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4, (i) => 1000.5 + i)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@ -254,7 +275,7 @@ describe("MessageV2.page", () => {
expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2))
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -263,7 +284,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4, () => 1000)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@ -274,7 +295,7 @@ describe("MessageV2.page", () => {
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
expect(b.more).toBe(false)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -283,8 +304,8 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const a = await Session.create({})
const b = await Session.create({})
const a = await svc.create({})
const b = await svc.create({})
await fill(a.id, 3)
await fill(b.id, 2)
@ -295,8 +316,8 @@ describe("MessageV2.page", () => {
expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true)
expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true)
await Session.remove(a.id)
await Session.remove(b.id)
await svc.remove(a.id)
await svc.remove(b.id)
},
})
})
@ -305,7 +326,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 10)
const result = MessageV2.page({ sessionID: session.id, limit: 100 })
@ -314,7 +335,7 @@ describe("MessageV2.page", () => {
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -325,13 +346,13 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const items = Array.from(MessageV2.stream(session.id))
expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse())
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -340,12 +361,12 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const items = Array.from(MessageV2.stream(session.id))
expect(items).toHaveLength(0)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -354,14 +375,14 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 1)
const items = Array.from(MessageV2.stream(session.id))
expect(items).toHaveLength(1)
expect(items[0].info.id).toBe(ids[0])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -370,7 +391,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 3)
const items = Array.from(MessageV2.stream(session.id))
@ -379,7 +400,7 @@ describe("MessageV2.stream", () => {
expect(item.parts[0].type).toBe("text")
}
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -388,7 +409,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 60)
const items = Array.from(MessageV2.stream(session.id))
@ -396,7 +417,7 @@ describe("MessageV2.stream", () => {
expect(items[0].info.id).toBe(ids[ids.length - 1])
expect(items[59].info.id).toBe(ids[0])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -405,7 +426,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 1)
const gen = MessageV2.stream(session.id)
@ -415,7 +436,7 @@ describe("MessageV2.stream", () => {
expect(first).toHaveProperty("done")
expect(first.done).toBe(false)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -426,7 +447,7 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.parts(id)
@ -434,7 +455,7 @@ describe("MessageV2.parts", () => {
expect(result[0].type).toBe("text")
expect((result[0] as MessageV2.TextPart).text).toBe("m0")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -443,13 +464,13 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const id = await addUser(session.id)
const result = MessageV2.parts(id)
expect(result).toEqual([])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -458,17 +479,17 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
type: "text",
text: "second",
})
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@ -482,7 +503,7 @@ describe("MessageV2.parts", () => {
expect((result[1] as MessageV2.TextPart).text).toBe("second")
expect((result[2] as MessageV2.TextPart).text).toBe("third")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -491,7 +512,7 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
await Session.create({})
await svc.create({})
const result = MessageV2.parts(MessageID.ascending())
expect(result).toEqual([])
},
@ -502,14 +523,14 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.parts(id)
expect(result[0].sessionID).toBe(session.id)
expect(result[0].messageID).toBe(id)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -520,7 +541,7 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.get({ sessionID: session.id, messageID: id })
@ -530,7 +551,7 @@ describe("MessageV2.get", () => {
expect(result.parts).toHaveLength(1)
expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -539,13 +560,13 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow(
"NotFoundError",
)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -554,16 +575,16 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const a = await Session.create({})
const b = await Session.create({})
const a = await svc.create({})
const b = await svc.create({})
const [id] = await fill(a.id, 1)
expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError")
const result = MessageV2.get({ sessionID: a.id, messageID: id })
expect(result.info.id).toBe(id)
await Session.remove(a.id)
await Session.remove(b.id)
await svc.remove(a.id)
await svc.remove(b.id)
},
})
})
@ -572,10 +593,10 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@ -586,7 +607,7 @@ describe("MessageV2.get", () => {
const result = MessageV2.get({ sessionID: session.id, messageID: id })
expect(result.parts).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -595,11 +616,11 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const uid = await addUser(session.id, "hello")
const aid = await addAssistant(session.id, uid)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: aid,
@ -612,7 +633,7 @@ describe("MessageV2.get", () => {
expect(result.parts).toHaveLength(1)
expect((result.parts[0] as MessageV2.TextPart).text).toBe("response")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -621,14 +642,14 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const id = await addUser(session.id)
const result = MessageV2.get({ sessionID: session.id, messageID: id })
expect(result.info.id).toBe(id)
expect(result.parts).toEqual([])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -639,7 +660,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
@ -647,7 +668,7 @@ describe("MessageV2.filterCompacted", () => {
// reversed from newest-first to chronological
expect(result.map((item) => item.info.id)).toEqual(ids)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -656,13 +677,13 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
// Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2
// Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break
const u1 = await addUser(session.id, "first question")
const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a1,
@ -673,7 +694,7 @@ describe("MessageV2.filterCompacted", () => {
const u2 = await addUser(session.id, "new question")
const a2 = await addAssistant(session.id, u2)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a2,
@ -686,7 +707,7 @@ describe("MessageV2.filterCompacted", () => {
expect(result[0].info.id).toBe(u1)
expect(result.length).toBe(4)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -700,16 +721,16 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
const u2 = await addUser(session.id, "world")
await addUser(session.id, "world")
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -718,7 +739,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
@ -728,13 +749,13 @@ describe("MessageV2.filterCompacted", () => {
isRetryable: true,
}).toObject() as MessageV2.Assistant["error"]
await addAssistant(session.id, u1, { summary: true, finish: "end_turn", error })
const u2 = await addUser(session.id, "retry")
await addUser(session.id, "retry")
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
// Error assistant doesn't add to completed, so compaction boundary never triggers
expect(result).toHaveLength(3)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -743,19 +764,19 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
// summary=true but no finish
await addAssistant(session.id, u1, { summary: true })
const u2 = await addUser(session.id, "next")
await addUser(session.id, "next")
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result).toHaveLength(3)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -764,11 +785,11 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "first")
const a1 = await addAssistant(session.id, u1, { finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a1,
@ -778,7 +799,7 @@ describe("MessageV2.filterCompacted", () => {
const u2 = await addUser(session.id, "second")
const a2 = await addAssistant(session.id, u2, { finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a2,
@ -789,7 +810,7 @@ describe("MessageV2.filterCompacted", () => {
const c1 = await addUser(session.id)
await addCompactionPart(session.id, c1, u2)
const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: s1,
@ -799,7 +820,7 @@ describe("MessageV2.filterCompacted", () => {
const u3 = await addUser(session.id, "third")
const a3 = await addAssistant(session.id, u3, { finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a3,
@ -811,7 +832,7 @@ describe("MessageV2.filterCompacted", () => {
expect(result.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -820,11 +841,11 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "first")
const a1 = await addAssistant(session.id, u1, { finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a1,
@ -834,7 +855,7 @@ describe("MessageV2.filterCompacted", () => {
const u2 = await addUser(session.id, "second")
const a2 = await addAssistant(session.id, u2, { finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a2,
@ -845,7 +866,7 @@ describe("MessageV2.filterCompacted", () => {
const c1 = await addUser(session.id)
await addCompactionPart(session.id, c1, u2)
const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: s1,
@ -855,7 +876,7 @@ describe("MessageV2.filterCompacted", () => {
const u3 = await addUser(session.id, "third")
const a3 = await addAssistant(session.id, u3, { finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a3,
@ -866,7 +887,7 @@ describe("MessageV2.filterCompacted", () => {
const c2 = await addUser(session.id)
await addCompactionPart(session.id, c2, u3)
const s2 = await addAssistant(session.id, c2, { summary: true, finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: s2,
@ -876,7 +897,7 @@ describe("MessageV2.filterCompacted", () => {
const u4 = await addUser(session.id, "fourth")
const a4 = await addAssistant(session.id, u4, { finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a4,
@ -888,7 +909,7 @@ describe("MessageV2.filterCompacted", () => {
expect(result.map((item) => item.info.id)).toEqual([u3, a3, c2, s2, u4, a4])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -942,7 +963,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 3)
const paged = MessageV2.page({ sessionID: session.id, limit: 10 })
@ -952,7 +973,7 @@ describe("MessageV2 consistency", () => {
expect(got.parts).toEqual(item.parts)
}
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -961,14 +982,14 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const got = MessageV2.get({ sessionID: session.id, messageID: id })
const standalone = MessageV2.parts(id)
expect(got.parts).toEqual(standalone)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -977,7 +998,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 7)
const streamed = Array.from(MessageV2.stream(session.id))
@ -995,7 +1016,7 @@ describe("MessageV2 consistency", () => {
expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@ -1004,15 +1025,15 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const ids = await fill(session.id, 4)
const session = await svc.create({})
await fill(session.id, 4)
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
const all = Array.from(MessageV2.stream(session.id)).reverse()
expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})

View file

@ -5,10 +5,10 @@ import path from "path"
import type { Agent } from "../../src/agent/agent"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { Config } from "../../src/config"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Provider } from "../../src/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm"
@ -16,14 +16,24 @@ import { MessageV2 } from "../../src/session/message-v2"
import { SessionProcessor } from "../../src/session/processor"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { Snapshot } from "../../src/snapshot"
import { Log } from "../../src/util/log"
import { Log } from "../../src/util"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { raw, reply, TestLLMServer } from "../lib/llm-server"
Log.init({ print: false })
void Log.init({ print: false })
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
const ref = {
providerID: ProviderID.make("test"),
@ -156,7 +166,10 @@ const deps = Layer.mergeAll(
Provider.defaultLayer,
status,
).pipe(Layer.provideMerge(infra))
const env = Layer.mergeAll(TestLLMServer.layer, SessionProcessor.layer.pipe(Layer.provideMerge(deps)))
const env = Layer.mergeAll(
TestLLMServer.layer,
SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)),
)
const it = testEffect(env)

View file

@ -1,27 +1,28 @@
import { NodeFileSystem } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
import z from "zod"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "../../src/config/config"
import { Config } from "../../src/config"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import type { Provider } from "../../src/provider/provider"
import { Provider as ProviderSvc } from "../../src/provider"
import { Env } from "../../src/env"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { AppFileSystem } from "../../src/filesystem"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { SessionCompaction } from "../../src/session/compaction"
import { SessionSummary } from "../../src/session/summary"
import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
import { SessionPrompt } from "../../src/session/prompt"
@ -30,17 +31,29 @@ import { SessionRunState } from "../../src/session/run-state"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
import { ToolRegistry } from "../../src/tool"
import { Truncate } from "../../src/tool"
import { Log } from "../../src/util"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
Log.init({ print: false })
void Log.init({ print: false })
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
const ref = {
providerID: ProviderID.make("test"),
@ -141,7 +154,7 @@ const filetime = Layer.succeed(
read: () => Effect.void,
get: () => Effect.succeed(undefined),
assert: () => Effect.void,
withLock: (_filepath, fn) => Effect.promise(fn),
withLock: (_filepath, fn) => fn(),
}),
)
@ -153,6 +166,7 @@ function makeHttp() {
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,
@ -169,33 +183,39 @@ function makeHttp() {
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
)
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
return Layer.mergeAll(
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(run),
Layer.provideMerge(compact),
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
),
)
).pipe(Layer.provide(summary))
}
const it = testEffect(makeHttp())
const unix = process.platform !== "win32" ? it.live : it.live.skip
// Config that registers a custom "test" provider with a "test-model" model
// so Provider.getModel("test", "test-model") succeeds inside the loop.
// so provider model lookup succeeds inside the loop.
const cfg = {
provider: {
test: {
@ -362,25 +382,23 @@ it.live("loop calls LLM and returns assistant message", () =>
it.live("static loop returns assistant text through local provider", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.text("world")
const result = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
expect(yield* llm.hits).toHaveLength(1)
@ -393,40 +411,36 @@ it.live("static loop returns assistant text through local provider", () =>
it.live("static loop consumes queued replies across turns", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
})
yield* llm.text("world one")
const first = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const first = yield* prompt.loop({ sessionID: session.id })
expect(first.info.role).toBe("assistant")
expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
})
yield* llm.text("world two")
const second = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const second = yield* prompt.loop({ sessionID: session.id })
expect(second.info.role).toBe("assistant")
expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
@ -467,6 +481,48 @@ it.live("loop continues when finish is tool-calls", () =>
),
)
it.live("glob tool keeps instance context during prompt runs", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Glob context",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
const file = path.join(dir, "probe.txt")
yield* Effect.promise(() => Bun.write(file, "probe"))
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "find text files" }],
})
yield* llm.tool("glob", { pattern: "**/*.txt" })
yield* llm.text("done")
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
const tool = msgs
.flatMap((msg) => msg.parts)
.find(
(part): part is CompletedToolPart =>
part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
)
if (!tool) return
expect(tool.state.output).toContain(file)
expect(tool.state.output).not.toContain("No context found for instance")
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
}),
{ git: true, config: providerCfg },
),
)
it.live("loop continues when finish is stop but assistant has tool parts", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
@ -728,19 +784,12 @@ it.live(
const registry = yield* ToolRegistry.Service
const { task } = yield* registry.named()
const original = task.execute
task.execute = async (_args, ctx) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return {
title: "",
metadata: {
sessionId: SessionID.make("task"),
model: ref,
},
output: "",
}
}
task.execute = (_args, ctx) =>
Effect.callback<never>((_resume) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
return Effect.sync(() => aborted.resolve())
})
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
const { prompt, chat } = yield* boot()
@ -806,7 +855,7 @@ it.live(
it.live("concurrent loop callers get same result", () =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
yield* seed(chat.id, { finish: "stop" })
@ -947,7 +996,7 @@ it.live(
it.live("assertNotBusy succeeds when idle", () =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const run = yield* SessionRunState.Service
const sessions = yield* Session.Service
@ -992,7 +1041,7 @@ it.live(
unix("shell captures stdout and stderr in completed tool output", () =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
const result = yield* prompt.shell({
@ -1067,7 +1116,7 @@ unix("shell lists files from the project directory", () =>
unix("shell captures stderr from a failing command", () =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
const result = yield* prompt.shell({
@ -1093,7 +1142,7 @@ unix(
() =>
withSh(() =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const { prompt, chat } = yield* boot()
@ -1205,7 +1254,7 @@ unix(
() =>
withSh(() =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const { prompt, run, chat } = yield* boot()
@ -1242,7 +1291,7 @@ unix(
() =>
withSh(() =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const { prompt, chat } = yield* boot()
@ -1311,8 +1360,8 @@ unix(
expect(tool.state.metadata.truncated).toBe(true)
expect(typeof tool.state.metadata.outputPath).toBe("string")
expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.")
expect(tool.state.output).toContain("Full output saved to:")
expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./)
expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
expect(tool.state.output).not.toContain("Tool execution aborted")
}),
{ git: true, config: providerCfg },
@ -1324,7 +1373,7 @@ unix(
"cancel interrupts loop queued behind shell",
() =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const { prompt, chat } = yield* boot()
@ -1353,7 +1402,7 @@ unix(
() =>
withSh(() =>
provideTmpdirInstance(
(dir) =>
(_dir) =>
Effect.gen(function* () {
const { prompt, chat } = yield* boot()
@ -1386,11 +1435,10 @@ function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
const ready = defer<void>()
const aborted = defer<void>()
const original = tool.execute
tool.execute = async (_args: any, ctx: any) => {
tool.execute = (_args: any, ctx: any) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return { title: "", metadata: {}, output: "" }
return Effect.callback<never>(() => {})
}
const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
return { ready, aborted, restore }

View file

@ -1,16 +1,23 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { NamedError } from "@opencode-ai/util/error"
import { NamedError } from "@opencode-ai/shared/util/error"
import { fileURLToPath } from "url"
import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
import { Log } from "../../src/util"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
void Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
@ -53,12 +60,11 @@ function chat(text: string) {
function hanging(ready: () => void) {
const encoder = new TextEncoder()
let timer: ReturnType<typeof setTimeout> | undefined
const first =
`data: ${JSON.stringify({
id: "chatcmpl-1",
object: "chat.completion.chunk",
choices: [{ delta: { role: "assistant" } }],
})}` + "\n\n"
const first = `data: ${JSON.stringify({
id: "chatcmpl-1",
object: "chat.completion.chunk",
choices: [{ delta: { role: "assistant" } }],
})}\n\n`
const rest =
[
`data: ${JSON.stringify({
@ -104,34 +110,39 @@ describe("session.prompt missing file", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
@ -149,39 +160,44 @@ describe("session.prompt missing file", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
const stored = MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
})
@ -197,31 +213,36 @@ describe("session.prompt special characters", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const template = "Read @file#name.txt"
const parts = yield* prompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
const message = yield* prompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
})
@ -273,24 +294,29 @@ describe("session.prompt regression", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Prompt regression" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt regression" })
const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
const msgs = await Session.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(calls).toBe(1)
},
const msgs = yield* sessions.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(calls).toBe(1)
}),
),
})
} finally {
server.stop(true)
void server.stop(true)
}
})
@ -342,39 +368,48 @@ describe("session.prompt regression", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Prompt cancel regression" })
const run = SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt cancel regression" })
const task = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
}),
)
await ready.promise
await SessionPrompt.cancel(session.id)
yield* Effect.promise(() => ready.promise)
yield* prompt.cancel(session.id)
const result = await Promise.race([
run,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
),
])
const result = yield* Effect.promise(() =>
Promise.race([
task,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
),
]),
)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.error?.name).toBe("MessageAbortedError")
}
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.error?.name).toBe("MessageAbortedError")
}
const msgs = await Session.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError")
}
},
const msgs = yield* sessions.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError")
}
}),
),
})
} finally {
server.stop(true)
void server.stop(true)
}
})
})
@ -399,45 +434,50 @@ describe("session.prompt agent variant", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.model.variant).toBeUndefined()
const other = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.model.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5.2"),
variant: "xhigh",
})
expect(match.info.model.variant).toBe("xhigh")
const match = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5.2"),
variant: "xhigh",
})
expect(match.info.model.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.model.variant).toBe("high")
const override = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.model.variant).toBe("high")
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
@ -451,24 +491,33 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
).then(
() => undefined,
(e) => e,
),
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
}),
),
})
}, 30000)
@ -476,22 +525,31 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
).then(
() => undefined,
(e) => e,
),
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
}),
),
})
}, 30000)
@ -499,24 +557,33 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}),
).then(
() => undefined,
(e) => e,
),
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
}),
),
})
}, 30000)
})

View file

@ -1,11 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/util/error"
import type { NamedError } from "@opencode-ai/shared/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Schedule } from "effect"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Instance } from "../../src/project/instance"
@ -94,12 +95,16 @@ describe("session.retry.delay", () => {
parse: (err) => err as MessageV2.APIError,
set: (info) =>
Effect.promise(() =>
SessionStatus.set(sessionID, {
type: "retry",
attempt: info.attempt,
message: info.message,
next: info.next,
}),
AppRuntime.runPromise(
SessionStatus.Service.use((svc) =>
svc.set(sessionID, {
type: "retry",
attempt: info.attempt,
message: info.message,
next: info.next,
}),
),
),
),
}),
)
@ -108,7 +113,7 @@ describe("session.retry.delay", () => {
}),
)
expect(await SessionStatus.get(sessionID)).toMatchObject({
expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({
type: "retry",
attempt: 2,
message: "boom",
@ -173,6 +178,47 @@ describe("session.retry.retryable", () => {
expect(SessionRetry.retryable(error)).toBeUndefined()
})
test("retries 500 errors even when isRetryable is false", () => {
const error = new MessageV2.APIError({
message: "Internal server error",
isRetryable: false,
statusCode: 500,
responseBody: '{"type":"api_error","message":"Internal server error"}',
}).toObject() as MessageV2.APIError
expect(SessionRetry.retryable(error)).toBe("Internal server error")
})
test("retries 502 bad gateway errors", () => {
const error = new MessageV2.APIError({
message: "Bad gateway",
isRetryable: false,
statusCode: 502,
}).toObject() as MessageV2.APIError
expect(SessionRetry.retryable(error)).toBe("Bad gateway")
})
test("retries 503 service unavailable errors", () => {
const error = new MessageV2.APIError({
message: "Service unavailable",
isRetryable: false,
statusCode: 503,
}).toObject() as MessageV2.APIError
expect(SessionRetry.retryable(error)).toBe("Service unavailable")
})
test("does not retry 4xx errors when isRetryable is false", () => {
const error = new MessageV2.APIError({
message: "Bad request",
isRetryable: false,
statusCode: 400,
}).toObject() as MessageV2.APIError
expect(SessionRetry.retryable(error)).toBeUndefined()
})
test("retries ZlibError decompression failures", () => {
const error = new MessageV2.APIError({
message: "Response decompression failed",
@ -193,7 +239,7 @@ describe("session.message-v2.fromError", () => {
using server = Bun.serve({
port: 0,
idleTimeout: 8,
async fetch(req) {
async fetch(_req) {
return new Response(
new ReadableStream({
async pull(controller) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,624 @@
import { describe, expect, test } from "bun:test"
import * as DateTime from "effect/DateTime"
import * as FastCheck from "effect/testing/FastCheck"
import { SessionEntry } from "../../src/v2/session-entry"
import { SessionEvent } from "../../src/v2/session-event"
const time = (n: number) => DateTime.makeUnsafe(n)
const word = FastCheck.string({ minLength: 1, maxLength: 8 })
const text = FastCheck.string({ maxLength: 16 })
const texts = FastCheck.array(text, { maxLength: 8 })
const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
const files = FastCheck.array(
word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
{ maxLength: 2 },
)
function maybe<A>(arb: FastCheck.Arbitrary<A>) {
return FastCheck.oneof(FastCheck.constant(undefined), arb)
}
function assistant() {
return new SessionEntry.Assistant({
id: SessionEvent.ID.create(),
type: "assistant",
time: { created: time(0) },
content: [],
})
}
function history() {
const state: SessionEntry.History = {
entries: [],
pending: [],
}
return state
}
function active() {
const state: SessionEntry.History = {
entries: [assistant()],
pending: [],
}
return state
}
function run(events: SessionEvent.Event[], state = history()) {
return events.reduce<SessionEntry.History>((state, event) => SessionEntry.step(state, event), state)
}
function last(state: SessionEntry.History) {
const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
expect(entry?.type).toBe("assistant")
return entry?.type === "assistant" ? entry : undefined
}
function texts_of(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
}
function reasons(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
}
function tools(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
}
function tool(state: SessionEntry.History, callID: string) {
return tools(state).find((x) => x.callID === callID)
}
describe("session-entry step", () => {
describe("seeded pending assistant", () => {
test("stores prompts in entries when no assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(history(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("user")
if (next.entries[0]?.type !== "user") return
expect(next.entries[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("stores prompts in pending when an assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(next.pending).toHaveLength(1)
expect(next.pending[0]?.type).toBe("user")
if (next.pending[0]?.type !== "user") return
expect(next.pending[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("accumulates text deltas on the latest text part", () => {
FastCheck.assert(
FastCheck.property(texts, (parts) => {
const next = parts.reduce(
(state, part, i) =>
SessionEntry.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })),
SessionEntry.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
)
expect(texts_of(next)).toEqual([
{
type: "text",
text: parts.join(""),
},
])
}),
{ numRuns: 100 },
)
})
test("routes later text deltas to the latest text segment", () => {
FastCheck.assert(
FastCheck.property(texts, texts, (a, b) => {
const next = run(
[
SessionEvent.Text.Started.create({ timestamp: time(1) }),
...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
],
active(),
)
expect(texts_of(next)).toEqual([
{ type: "text", text: a.join("") },
{ type: "text", text: b.join("") },
])
}),
{ numRuns: 50 },
)
})
test("reasoning.ended replaces buffered reasoning text", () => {
FastCheck.assert(
FastCheck.property(texts, text, (parts, end) => {
const next = run(
[
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
],
active(),
)
expect(reasons(next)).toEqual([
{
type: "reasoning",
text: end,
},
])
}),
{ numRuns: 100 },
)
})
test("tool.success completes the latest running tool", () => {
FastCheck.assert(
FastCheck.property(
word,
word,
dict,
maybe(text),
maybe(dict),
maybe(files),
texts,
(callID, title, input, output, metadata, attachments, parts) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
...parts.map((x, i) =>
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(parts.length + 2),
}),
SessionEvent.Tool.Success.create({
callID,
title,
output,
metadata,
attachments,
provider: { executed: true },
timestamp: time(parts.length + 3),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state.status).toBe("completed")
if (match?.state.status !== "completed") return
expect(match.time.ran).toEqual(time(parts.length + 2))
expect(match.state.input).toEqual(input)
expect(match.state.output).toBe(output ?? "")
expect(match.state.title).toBe(title)
expect(match.state.metadata).toEqual(metadata ?? {})
expect(match.state.attachments).toEqual(attachments ?? [])
},
),
{ numRuns: 50 },
)
})
test("tool.error completes the latest running tool with an error", () => {
FastCheck.assert(
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(2),
}),
SessionEvent.Tool.Error.create({
callID,
error,
metadata,
provider: { executed: true },
timestamp: time(3),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state.status).toBe("error")
if (match?.state.status !== "error") return
expect(match.time.ran).toEqual(time(2))
expect(match.state.input).toEqual(input)
expect(match.state.error).toBe(error)
expect(match.state.metadata).toEqual(metadata ?? {})
}),
{ numRuns: 50 },
)
})
test("tool.success is ignored before tool.called promotes the tool to running", () => {
FastCheck.assert(
FastCheck.property(word, word, (callID, title) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Success.create({
callID,
title,
provider: { executed: true },
timestamp: time(2),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state).toEqual({
status: "pending",
input: "",
})
}),
{ numRuns: 50 },
)
})
test("step.ended copies completion fields onto the pending assistant", () => {
FastCheck.assert(
FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
const event = SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(n),
})
const next = SessionEntry.step(active(), event)
const entry = last(next)
expect(entry).toBeDefined()
if (!entry) return
expect(entry.time.completed).toEqual(event.timestamp)
expect(entry.cost).toBe(event.cost)
expect(entry.tokens).toEqual(event.tokens)
}),
{ numRuns: 50 },
)
})
})
describe("known reducer gaps", () => {
test("prompt appends immutably when no assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const old = history()
const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(old).not.toBe(next)
expect(old.entries).toHaveLength(0)
expect(next.entries).toHaveLength(1)
}),
{ numRuns: 50 },
)
})
test("prompt appends immutably when an assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const old = active()
const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(old).not.toBe(next)
expect(old.pending).toHaveLength(0)
expect(next.pending).toHaveLength(1)
}),
{ numRuns: 50 },
)
})
test("step.started creates an assistant consumed by follow-up events", () => {
FastCheck.assert(
FastCheck.property(texts, (parts) => {
const next = run([
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Text.Started.create({ timestamp: time(2) }),
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(parts.length + 3),
}),
])
const entry = last(next)
expect(entry).toBeDefined()
if (!entry) return
expect(entry.content).toEqual([
{
type: "text",
text: parts.join(""),
},
])
expect(entry.time.completed).toEqual(time(parts.length + 3))
}),
{ numRuns: 100 },
)
})
test("replays prompt -> step -> text -> step.ended", () => {
FastCheck.assert(
FastCheck.property(word, texts, (body, parts) => {
const next = run([
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Text.Started.create({ timestamp: time(2) }),
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(parts.length + 3),
}),
])
expect(next.entries).toHaveLength(2)
expect(next.entries[0]?.type).toBe("user")
expect(next.entries[1]?.type).toBe("assistant")
if (next.entries[1]?.type !== "assistant") return
expect(next.entries[1].content).toEqual([
{
type: "text",
text: parts.join(""),
},
])
expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
}),
{ numRuns: 50 },
)
})
test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
FastCheck.assert(
FastCheck.property(
word,
texts,
text,
dict,
word,
maybe(text),
maybe(dict),
maybe(files),
(body, reason, end, input, title, output, metadata, attachments) => {
const callID = "call"
const next = run([
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(reason.length + 5),
}),
SessionEvent.Tool.Success.create({
callID,
title,
output,
metadata,
attachments,
provider: { executed: true },
timestamp: time(reason.length + 6),
}),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(reason.length + 7),
}),
])
expect(next.entries.at(-1)?.type).toBe("assistant")
const entry = next.entries.at(-1)
if (entry?.type !== "assistant") return
expect(entry.content).toHaveLength(2)
expect(entry.content[0]).toEqual({
type: "reasoning",
text: end,
})
expect(entry.content[1]?.type).toBe("tool")
if (entry.content[1]?.type !== "tool") return
expect(entry.content[1].state.status).toBe("completed")
expect(entry.time.completed).toEqual(time(reason.length + 7))
},
),
{ numRuns: 50 },
)
})
test("starting a new step completes the old assistant and appends a new active assistant", () => {
const next = run(
[
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
],
active(),
)
expect(next.entries).toHaveLength(2)
expect(next.entries[0]?.type).toBe("assistant")
expect(next.entries[1]?.type).toBe("assistant")
if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
expect(next.entries[0].time.completed).toEqual(time(1))
expect(next.entries[1].time.created).toEqual(time(1))
expect(next.entries[1].time.completed).toBeUndefined()
})
test("handles sequential tools independently", () => {
FastCheck.assert(
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
input: a,
provider: { executed: true },
timestamp: time(2),
}),
SessionEvent.Tool.Success.create({
callID: "a",
title,
output: "done",
provider: { executed: true },
timestamp: time(3),
}),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "bash",
input: b,
provider: { executed: true },
timestamp: time(5),
}),
SessionEvent.Tool.Error.create({
callID: "b",
error,
provider: { executed: true },
timestamp: time(6),
}),
],
active(),
)
const first = tool(next, "a")
const second = tool(next, "b")
expect(first?.state.status).toBe("completed")
if (first?.state.status !== "completed") return
expect(first.state.input).toEqual(a)
expect(first.state.output).toBe("done")
expect(first.state.title).toBe(title)
expect(second?.state.status).toBe("error")
if (second?.state.status !== "error") return
expect(second.state.input).toEqual(b)
expect(second.state.error).toBe(error)
}),
{ numRuns: 50 },
)
})
test.failing("records synthetic events", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("synthetic")
if (next.entries[0]?.type !== "synthetic") return
expect(next.entries[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test.failing("records compaction events", () => {
FastCheck.assert(
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
const next = SessionEntry.step(
history(),
SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
)
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("compaction")
if (next.entries[0]?.type !== "compaction") return
expect(next.entries[0].auto).toBe(auto)
expect(next.entries[0].overflow).toBe(overflow)
}),
{ numRuns: 50 },
)
})
})
})

View file

@ -1,14 +1,36 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { Bus } from "../../src/bus"
import { Log } from "../../src/util/log"
import { Log } from "../../src/util"
import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID } from "../../src/session/schema"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
void Log.init({ print: false })
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
function remove(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
}
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
}
describe("session.created event", () => {
test("should emit session.created event when session is created", async () => {
@ -16,27 +38,25 @@ describe("session.created event", () => {
directory: projectRoot,
fn: async () => {
let eventReceived = false
let receivedInfo: Session.Info | undefined
let receivedInfo: SessionNs.Info | undefined
const unsub = Bus.subscribe(Session.Event.Created, (event) => {
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
eventReceived = true
receivedInfo = event.properties.info as Session.Info
receivedInfo = event.properties.info as SessionNs.Info
})
const session = await Session.create({})
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsub()
expect(eventReceived).toBe(true)
expect(receivedInfo).toBeDefined()
expect(receivedInfo?.id).toBe(session.id)
expect(receivedInfo?.projectID).toBe(session.projectID)
expect(receivedInfo?.directory).toBe(session.directory)
expect(receivedInfo?.title).toBe(session.title)
expect(receivedInfo?.id).toBe(info.id)
expect(receivedInfo?.projectID).toBe(info.projectID)
expect(receivedInfo?.directory).toBe(info.directory)
expect(receivedInfo?.title).toBe(info.title)
await Session.remove(session.id)
await remove(info.id)
},
})
})
@ -47,18 +67,16 @@ describe("session.created event", () => {
fn: async () => {
const events: string[] = []
const unsubCreated = Bus.subscribe(Session.Event.Created, () => {
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
events.push("created")
})
const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => {
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
events.push("updated")
})
const session = await Session.create({})
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsubCreated()
unsubUpdated()
@ -66,7 +84,7 @@ describe("session.created event", () => {
expect(events).toContain("updated")
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
await Session.remove(session.id)
await remove(info.id)
},
})
})
@ -79,12 +97,12 @@ describe("step-finish token propagation via Bus event", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const session = await Session.create({})
const info = await create({})
const messageID = MessageID.ascending()
await Session.updateMessage({
await updateMessage({
id: messageID,
sessionID: session.id,
sessionID: info.id,
role: "user",
time: { created: Date.now() },
agent: "user",
@ -109,15 +127,14 @@ describe("step-finish token propagation via Bus event", () => {
const partInput = {
id: PartID.ascending(),
messageID,
sessionID: session.id,
sessionID: info.id,
type: "step-finish" as const,
reason: "stop",
cost: 0.005,
tokens,
}
await Session.updatePart(partInput)
await updatePart(partInput)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(received).toBeDefined()
@ -133,10 +150,32 @@ describe("step-finish token propagation via Bus event", () => {
expect(received).not.toBe(partInput)
unsub()
await Session.remove(session.id)
await remove(info.id)
},
})
},
{ timeout: 30000 },
)
})
describe("Session", () => {
test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true })
const info = await Instance.provide({
directory: tmp.path,
fn: () => create({ title: "remove-without-instance" }),
})
await expect(async () => {
await remove(info.id)
}).not.toThrow()
let missing = false
await get(info.id).catch(() => {
missing = true
})
expect(missing).toBe(true)
})
})

View file

@ -12,7 +12,8 @@
* tools internally during multi-step processing before emitting events.
*/
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import fs from "fs/promises"
import path from "path"
import { Session } from "../../src/session"
@ -21,40 +22,42 @@ import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
import { SessionSummary } from "../../src/session/summary"
import { MessageV2 } from "../../src/session/message-v2"
import { Log } from "../../src/util/log"
import { Log } from "../../src/util"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestLLMServer } from "../lib/llm-server"
// Same layer setup as prompt-effect.test.ts
import { NodeFileSystem } from "@effect/platform-node"
import { Layer } from "effect"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "../../src/config/config"
import { Config } from "../../src/config"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Provider as ProviderSvc } from "../../src/provider"
import { Env } from "../../src/env"
import { Question } from "../../src/question"
import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Todo } from "../../src/session/todo"
import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
import { SessionRunState } from "../../src/session/run-state"
import { SessionStatus } from "../../src/session/status"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { AppFileSystem } from "../../src/filesystem"
import { ToolRegistry } from "../../src/tool"
import { Truncate } from "../../src/tool"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
Log.init({ print: false })
void Log.init({ print: false })
const mcp = Layer.succeed(
MCP.Service,
@ -105,7 +108,7 @@ const filetime = Layer.succeed(
read: () => Effect.void,
get: () => Effect.succeed(undefined),
assert: () => Effect.void,
withLock: (_filepath, fn) => Effect.promise(fn),
withLock: (_filepath, fn) => fn(),
}),
)
@ -118,6 +121,7 @@ function makeHttp() {
Session.defaultLayer,
Snapshot.defaultLayer,
LLM.defaultLayer,
Env.defaultLayer,
AgentSvc.defaultLayer,
Command.defaultLayer,
Permission.defaultLayer,
@ -134,23 +138,30 @@ function makeHttp() {
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
)
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
return Layer.mergeAll(
TestLLMServer.layer,
SessionSummary.defaultLayer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provideMerge(run),
Layer.provideMerge(compact),
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
),
)
@ -192,6 +203,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
Effect.fnUntraced(function* ({ dir, llm }) {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const summary = yield* SessionSummary.Service
const session = yield* sessions.create({
title: "snapshot race test",
@ -236,9 +248,9 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
expect(tool?.state.status).toBe("completed")
// Poll for diff — summarize() is fire-and-forget
let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
let diff: Array<{ file: string }> = []
for (let i = 0; i < 50; i++) {
diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
diff = yield* summary.diff({ sessionID: session.id })
if (diff.length > 0) break
yield* Effect.sleep("100 millis")
}

View file

@ -1,13 +1,14 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
import { Log } from "../../src/util"
import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
void Log.init({ print: false })
// Skip tests if no API key is available
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
@ -20,51 +21,63 @@ async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
})
}
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"produces structured output with simple schema",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Structured Output Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
},
required: ["answer"],
},
retryCount: 0,
},
required: ["answer"],
},
retryCount: 0,
},
})
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
const output = result.info.structured as any
expect(output.answer).toBe(4)
const output = result.info.structured as any
expect(output.answer).toBe(4)
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@ -72,62 +85,68 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"produces structured output with nested objects",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Nested Schema Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
company: {
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
company: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
},
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
required: ["company"],
},
retryCount: 0,
},
required: ["company"],
},
retryCount: 0,
},
})
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@ -135,35 +154,41 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"works with text outputFormat (default)",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Text Output Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@ -171,47 +196,53 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"stores outputFormat on user message",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "OutputFormat Storage Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
},
required: ["result"],
},
retryCount: 3,
},
required: ["result"],
},
retryCount: 3,
},
})
})
// Get all messages from session
const messages = await Session.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Get all messages from session
const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)

View file

@ -1,9 +1,14 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { SystemPrompt } from "../../src/session/system"
import { tmpdir } from "../fixture/fixture"
import { provideInstance, tmpdir } from "../fixture/fixture"
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
describe("session.system", () => {
test("skills output is sorted by name and stable across calls", async () => {
@ -37,9 +42,14 @@ description: ${description}
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const first = await SystemPrompt.skills(build!)
const second = await SystemPrompt.skills(build!)
const build = await load(tmp.path, (svc) => svc.get("build"))
const runSkills = Effect.gen(function* () {
const svc = yield* SystemPrompt.Service
return yield* svc.skills(build!)
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
const first = await Effect.runPromise(runSkills)
const second = await Effect.runPromise(runSkills)
expect(first).toBe(second)