feat: 品牌替换 + 启动优化 + AGENTS.md 模板定制

- 品牌替换:OpenCode/opencode → AirCoding/aircoding(16+ 文件)
- Logo ASCII art:修复 left/right 行数不匹配导致的启动崩溃
- 启动诊断:添加 OPENCODE_PRINT_TIMING 计时探针
- dev 模式默认 --pure 跳过外部插件加载
- AGENTS.md 模板:追加 AirCoding 多 Agent 专项段落
- architect prompt + plugin:强化 AGENTS.md 产出验证
This commit is contained in:
airlongdian
2026-06-14 09:31:29 +08:00
commit e2fd375a1c
5757 changed files with 1170016 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
import { describe, expect, test } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import path from "path"
import { Effect, FileSystem, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { NodeFileSystem } from "@effect/platform-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "@opencode-ai/core/global"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer))
const configLayer = TestConfig.layer()
const instructionLayer = (global: Partial<Global.Interface>, flags: Partial<RuntimeFlags.Info> = {}) =>
Instruction.layer.pipe(
Layer.provide(configLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Global.layerWith(global)),
Layer.provide(RuntimeFlags.layer(flags)),
)
const provideInstruction =
(global: Partial<Global.Interface>, flags?: Partial<RuntimeFlags.Info>) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(Effect.provide(instructionLayer(global, flags)))
const write = (filepath: string, content: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(filepath), { recursive: true })
yield* fs.writeFileString(filepath, content)
})
const writeFiles = (dir: string, files: Record<string, string>) =>
Effect.all(
Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)),
{ discard: true },
)
const withFiles = <A, E, R>(files: Record<string, string>, self: (dir: string) => Effect.Effect<A, E, R>) =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* writeFiles(dir, files)
return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir }))
}),
)
const tmpWithFiles = (files: Record<string, string>) =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* writeFiles(dir, files)
return dir
})
function loaded(filepath: string): SessionV1.WithParts[] {
const sessionID = SessionID.make("session-loaded-1")
const messageID = MessageID.make("msg_message-loaded-1")
return [
{
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 0 },
agent: "build",
model: {
providerID: ProviderV2.ID.make("anthropic"),
modelID: ModelV2.ID.make("claude-sonnet-4-20250514"),
},
},
parts: [
{
id: PartID.make("prt_part-loaded-1"),
messageID,
sessionID,
type: "tool",
callID: "call-loaded-1",
tool: "read",
state: {
status: "completed",
input: {},
output: "done",
title: "Read",
metadata: { loaded: [filepath] },
time: { start: 0, end: 1 },
},
},
],
},
]
}
describe("Instruction.resolve", () => {
it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () =>
withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const system = yield* svc.systemPaths()
expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true)
const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1"))
expect(results).toEqual([])
}),
),
)
it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const system = yield* svc.systemPaths()
expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false)
const results = yield* svc.resolve(
[],
path.join(dir, "subdir", "nested", "file.ts"),
MessageID.make("msg_message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
}),
),
)
it.live("doesn't reload AGENTS.md when reading it directly", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "AGENTS.md")
const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3"))
expect(results).toEqual([])
}),
),
)
it.live("does not reattach the same nearby instructions twice for one message", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("msg_message-claim-1")
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(dir, "subdir", "AGENTS.md"))
expect(second).toEqual([])
}),
),
)
it.live("clear allows nearby instructions to be attached again for the same message", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("msg_message-claim-2")
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(dir, "subdir", "AGENTS.md"))
}),
),
)
it.live("skips instructions already reported by prior read metadata", () =>
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
Effect.gen(function* () {
const svc = yield* Instruction.Service
const agents = path.join(dir, "subdir", "AGENTS.md")
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("msg_message-claim-3")
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", () => {
it.live("loads both project and global AGENTS.md when both exist", () =>
Effect.gen(function* () {
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Project Instructions" })
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
const rules = yield* svc.system()
expect(rules).toHaveLength(2)
expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`)
expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`)
}).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
}),
)
it.live("skips project and global CLAUDE.md when Claude Code prompt is disabled", () =>
Effect.gen(function* () {
const globalTmp = yield* tmpWithFiles({ ".claude/CLAUDE.md": "# Global Claude" })
const projectTmp = yield* tmpWithFiles({ "CLAUDE.md": "# Project Claude" })
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp, ".claude", "CLAUDE.md"))).toBe(false)
expect(paths.has(path.join(projectTmp, "CLAUDE.md"))).toBe(false)
expect(yield* svc.system()).toEqual([])
}).pipe(
provideInstance(projectTmp),
provideInstruction({ home: globalTmp, config: globalTmp }, { disableClaudeCodePrompt: true }),
)
}),
)
})
describe("Instruction.systemPaths global config", () => {
it.live("uses Global.Service config AGENTS.md", () =>
Effect.gen(function* () {
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
const projectTmp = yield* tmpdirScoped()
yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
}).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
}),
)
})

View File

@@ -0,0 +1,433 @@
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import { describe, expect, test } from "bun:test"
import { tool, type ModelMessage, type JSONValue } from "ai"
import { Effect, Layer, Option, Schema, Stream } from "effect"
import path from "node:path"
import z from "zod"
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { Filesystem } from "@/util/filesystem"
import { LLMEvent, LLMResponse } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import { Env } from "@/env"
import { RuntimeFlags } from "@/effect/runtime-flags"
import type { Agent } from "../../src/agent/agent"
import { LLM } from "../../src/session/llm"
import { MessageID, SessionID } from "../../src/session/schema"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
const zenURL = (connection: string) => `https://console.opencode.ai/proxy/connections/${connection}/v1`
const replayOpenAIOAuth = {
type: "oauth",
refresh: "fixture-refresh-token",
access: "fixture-access-token",
expires: Date.now() + 60 * 60 * 1000,
accountId: "fixture-account",
} satisfies Auth.Info
type RecordedScenario = {
readonly id: string
readonly name: string
readonly providerID: ProviderV2.ID
readonly modelID: string
readonly cassette: string
readonly protocol: string
readonly tags: ReadonlyArray<string>
readonly canRecord: () => boolean
readonly recordAuth?: () => Auth.Info | undefined
readonly replayAuth?: Auth.Info
readonly stableID?: string
readonly config: (model: ModelsDev.Provider["models"][string]) => Partial<ConfigV1.Info>
}
const cloneModel = (model: ModelsDev.Provider["models"][string]) => {
const cloned = structuredClone(model)
const { experimental, ...rest } = cloned
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The config schema accepts the same model shape except object-valued experimental metadata.
if (typeof experimental === "boolean") {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The fixture model already matches config input when experimental is boolean.
return cloned as NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
}
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Dropping non-boolean experimental metadata makes the fixture model match config input.
return rest as NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
}
const envValue = (...names: string[]) => names.map((name) => process.env[name]).find(Boolean)
const decodeAuth = Schema.decodeUnknownOption(Auth.Info)
const recordOpenAIOAuth = (() => {
let loaded = false
let auth: Auth.Info | undefined
return () => {
if (loaded) return auth
loaded = true
auth = decodeRecordOpenAIOAuth()
return auth
}
})()
function decodeRecordOpenAIOAuth() {
const value = process.env.OPENCODE_RECORD_OPENAI_AUTH
if (!value) return undefined
try {
const auth = Option.getOrUndefined(decodeAuth(JSON.parse(value)))
return auth?.type === "oauth" ? auth : undefined
} catch {
return undefined
}
}
const providerConfig = (input: {
readonly providerID: ProviderV2.ID
readonly name: string
readonly env: string[]
readonly npm: string
readonly api: string
readonly model: ModelsDev.Provider["models"][string]
readonly options: Record<string, unknown>
}): Partial<ConfigV1.Info> => ({
enabled_providers: [input.providerID],
provider: {
[input.providerID]: {
name: input.name,
env: input.env,
npm: input.npm,
api: input.api,
models: { [input.model.id]: cloneModel(input.model) },
options: input.options,
},
},
})
const RECORDED_SCENARIOS = [
{
id: "openai-api-key",
name: "OpenAI API key",
providerID: ProviderV2.ID.openai,
modelID: "gpt-4.1-mini",
cassette: "session/native-openai-tool-loop",
protocol: "openai-responses",
tags: ["opencode", "native", "tool-loop"],
canRecord: () => Boolean(envValue("OPENCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY")),
config: (model) =>
providerConfig({
providerID: ProviderV2.ID.openai,
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
model,
options: {
apiKey: envValue("OPENCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY") ?? "fixture-openai-key",
baseURL: "https://api.openai.com/v1",
},
}),
},
{
id: "openai-oauth",
name: "OpenAI OAuth",
providerID: ProviderV2.ID.openai,
modelID: "gpt-5.5",
cassette: "session/native-openai-oauth-tool-loop",
protocol: "openai-responses",
tags: ["opencode", "native", "oauth", "tool-loop"],
canRecord: () => recordOpenAIOAuth() !== undefined,
recordAuth: recordOpenAIOAuth,
replayAuth: replayOpenAIOAuth,
stableID: "openai-oauth",
config: (model) =>
providerConfig({
providerID: ProviderV2.ID.openai,
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
model,
options: { baseURL: "https://api.openai.com/v1" },
}),
},
{
id: "opencode-proxy",
name: "OpenCode proxy",
providerID: ProviderV2.ID.opencode,
modelID: "gpt-5.2-codex",
cassette: "session/native-zen-tool-loop",
protocol: "openai-responses",
tags: ["opencode", "zen", "native", "tool-loop"],
canRecord: () => Boolean(process.env.OPENCODE_RECORD_CONSOLE_TOKEN && process.env.OPENCODE_RECORD_ZEN_ORG_ID),
config: (model) =>
providerConfig({
providerID: ProviderV2.ID.opencode,
name: "OpenCode Zen",
env: ["OPENCODE_CONSOLE_TOKEN"],
npm: "@ai-sdk/openai-compatible",
api: zenURL(process.env.OPENCODE_RECORD_ZEN_CONNECTION ?? "fixture"),
model,
options: {
apiKey: process.env.OPENCODE_RECORD_CONSOLE_TOKEN ?? "fixture-console-token",
headers: { "x-org-id": process.env.OPENCODE_RECORD_ZEN_ORG_ID ?? "fixture-org" },
},
}),
},
{
id: "anthropic-api-key",
name: "Anthropic API key",
providerID: ProviderV2.ID.anthropic,
modelID: "claude-haiku-4-5-20251001",
cassette: "session/native-anthropic-tool-loop",
protocol: "anthropic-messages",
tags: ["opencode", "native", "tool-loop"],
canRecord: () => Boolean(envValue("OPENCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY")),
config: (model) =>
providerConfig({
providerID: ProviderV2.ID.anthropic,
name: "Anthropic",
env: ["ANTHROPIC_API_KEY"],
npm: "@ai-sdk/anthropic",
api: "https://api.anthropic.com/v1",
model,
options: {
apiKey: envValue("OPENCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY") ?? "fixture-anthropic-key",
baseURL: "https://api.anthropic.com/v1",
},
}),
},
] satisfies ReadonlyArray<RecordedScenario>
const shouldRecord = process.env.RECORD === "true"
const selectedScenarios = new Set(
(envValue("OPENCODE_RECORDED_SCENARIO", "RECORDED_PROVIDER") ?? "")
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean),
)
function isSelected(scenario: RecordedScenario) {
if (selectedScenarios.size === 0) return true
return [scenario.id, scenario.name, scenario.providerID, scenario.cassette, ...scenario.tags]
.map((item) => item.toLowerCase())
.some((item) => selectedScenarios.has(item))
}
const canRun = (scenario: RecordedScenario) =>
shouldRecord
? scenario.canRecord()
: HttpRecorderInternal.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR })
const recordError = (scenario: RecordedScenario) =>
scenario.id === "openai-oauth"
? "Set OPENCODE_RECORD_OPENAI_AUTH to an OAuth auth JSON object in the recording environment."
: `Missing recording credentials for ${scenario.name}.`
const redactRecordedBody = (body: string) =>
body
.replace(/wrk_[A-Z0-9]+/g, "wrk_redacted")
.replace(/"safety_identifier"\s*:\s*"user-[^"]+"/g, '"safety_identifier":"user_redacted"')
.replace(/"(access|access_token|refresh|refresh_token|accountId|account_id)"\s*:\s*"[^"]+"/g, '"$1":"redacted"')
function authLayer(scenario: RecordedScenario) {
const replayAuth = shouldRecord ? scenario.recordAuth?.() : scenario.replayAuth
if (!replayAuth) return Auth.defaultLayer
return Layer.mock(Auth.Service)({
get: (providerID) => Effect.succeed(providerID === scenario.providerID ? replayAuth : undefined),
all: () => Effect.succeed({ [scenario.providerID]: replayAuth }),
})
}
async function loadFixture(providerID: string, modelID: string) {
const data = await modelsFixture
const provider = data[providerID]
if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`)
const model = provider.models[modelID]
if (!model) throw new Error(`Missing model in fixture: ${modelID}`)
return model
}
const modelsFixture = Filesystem.readJson<Record<string, ModelsDev.Provider>>(
path.join(import.meta.dir, "../tool/fixtures/models-api.json"),
)
function recordedNativeLLMLayer(scenario: RecordedScenario) {
const auth = authLayer(scenario)
const provider = Provider.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(auth),
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
const metadata = {
provider: scenario.providerID,
protocol: scenario.protocol,
route: scenario.protocol,
tags: scenario.tags,
}
const redact = {
url: (url: string) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"),
body: redactRecordedBody,
}
const recordedHttp = shouldRecord
? HttpRecorderInternal.cassetteLayer(scenario.cassette, {
directory: FIXTURES_DIR,
mode: "record",
metadata,
redactor: HttpRecorderInternal.Redactor.make(redact),
})
: HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact })
const recordedClient = LLMClient.layer.pipe(
Layer.provide(Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer)),
)
return Layer.mergeAll(
provider,
LLM.layer.pipe(
Layer.provide(auth),
Layer.provide(Config.defaultLayer),
Layer.provide(provider),
Layer.provide(Plugin.defaultLayer),
Layer.provide(recordedClient),
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })),
),
)
}
const writeConfig = (directory: string, scenario: RecordedScenario, model: ModelsDev.Provider["models"][string]) =>
Effect.promise(() =>
Bun.write(
path.join(directory, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...scenario.config(model) }),
),
)
const collect = (input: LLM.StreamInput) =>
Effect.gen(function* () {
const llm = yield* LLM.Service
return Array.from(yield* llm.stream(input).pipe(Stream.runCollect))
})
const WEATHER_RESULT = { temperature: 22, condition: "sunny" } as const
const WEATHER_SYSTEM =
"Use the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny."
const WEATHER_USER = "What is the weather in Paris?"
const weatherTool = tool({
description: "Get the current weather for a city.",
inputSchema: z.object({ city: z.string() }),
execute: async () => WEATHER_RESULT,
})
const toolRoundtrip = (
events: ReadonlyArray<LLMEvent>,
call: { readonly id: string; readonly name: string; readonly input: unknown },
result: JSONValue,
): ModelMessage[] => [
{
role: "assistant",
content: [
...events.filter(LLMEvent.is.reasoningEnd).map((part) => ({
type: "reasoning" as const,
text: events
.filter(LLMEvent.is.reasoningDelta)
.filter((event) => event.id === part.id)
.map((event) => event.text)
.join(""),
providerMetadata: part.providerMetadata,
})),
{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input },
],
},
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: call.id, toolName: call.name, output: { type: "json", value: result } },
],
},
]
const driveToolLoop = (scenario: RecordedScenario) =>
Effect.gen(function* () {
const test = yield* TestInstance
const model = yield* Effect.promise(() => loadFixture(scenario.providerID, scenario.modelID))
yield* writeConfig(test.directory, scenario, model)
const stableID = scenario.stableID ?? scenario.providerID
const sessionID = SessionID.make(`session-recorded-${stableID}-loop`)
const modelID = ModelV2.ID.make(model.id)
const agent = {
name: "test",
mode: "primary",
prompt: "Answer using tools when appropriate.",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
temperature: 0,
} satisfies Agent.Info
const provider = yield* Provider.Service
const resolved = yield* provider.getModel(scenario.providerID, modelID)
const userMessage = { role: "user", content: WEATHER_USER } satisfies ModelMessage
const base = {
user: {
id: MessageID.make(`msg_user-recorded-${stableID}-loop`),
sessionID,
role: "user",
time: { created: 0 },
agent: agent.name,
model: { providerID: scenario.providerID, modelID },
} satisfies SessionV1.User,
sessionID,
model: resolved,
agent,
system: [WEATHER_SYSTEM],
tools: { get_weather: weatherTool },
}
const turn1 = yield* collect({ ...base, messages: [userMessage] })
const toolCall = turn1.find(LLMEvent.is.toolCall)
expect(toolCall).toBeDefined()
expect(turn1.find(LLMEvent.is.toolResult)).toBeDefined()
expect(toolCall!.name).toBe("get_weather")
expect(toolCall!.input).toMatchObject({ city: expect.stringMatching(/Paris/i) })
expect(turn1.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
const turn2 = yield* collect({
...base,
messages: [userMessage, ...toolRoundtrip(turn1, toolCall!, WEATHER_RESULT)],
})
expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)
expect(turn2.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(turn2.filter(LLMEvent.is.toolCall)).toHaveLength(0)
})
describe("session.llm native recorded", () => {
for (const scenario of RECORDED_SCENARIOS.filter(isSelected)) {
if (!canRun(scenario)) {
if (shouldRecord && scenario.recordAuth && selectedScenarios.size > 0) {
test(`${scenario.name}: drives a tool loop to a final text answer`, () => {
throw new Error(recordError(scenario))
})
continue
}
test.skip(`${scenario.name}: drives a tool loop to a final text answer`, () => {})
continue
}
const it = testEffect(recordedNativeLLMLayer(scenario))
it.instance(`${scenario.name}: drives a tool loop to a final text answer`, () => driveToolLoop(scenario))
}
})

View File

@@ -0,0 +1,760 @@
import { describe, expect, test } from "bun:test"
import { LLMEvent, ToolFailure } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/llm/route"
import { jsonSchema, tool, type ModelMessage, type Tool } from "ai"
import { Effect, Fiber, Layer, Stream } from "effect"
import { LLMNative } from "@/session/llm/native-request"
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
import type { Provider } from "@/provider/provider"
import { OAUTH_DUMMY_KEY } from "@/auth"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const baseModel: Provider.Model = {
id: ModelV2.ID.make("gpt-5-mini"),
providerID: ProviderV2.ID.make("openai"),
api: {
id: "gpt-5-mini",
url: "https://api.openai.com/v1",
npm: "@ai-sdk/openai",
},
name: "GPT-5 Mini",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: false,
image: true,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128_000,
input: 128_000,
output: 32_000,
},
status: "active",
options: {},
headers: {
"x-model": "model-header",
},
release_date: "2026-01-01",
}
const providerInfo: Provider.Info = {
id: ProviderV2.ID.make("openai"),
name: "OpenAI",
source: "config",
env: ["OPENAI_API_KEY"],
options: { apiKey: "test-openai-key" },
models: {},
}
const it = testEffect(
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
)
function responsesStream(chunks: unknown[]) {
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n") + "\n\n", {
status: 200,
headers: { "Content-Type": "text/event-stream" },
})
}
type NativeRequestInput = Parameters<typeof LLMNative.request>[0]
const sessionText = (text: string) => ({ type: "text" as const, text })
const sessionOpenAIReasoning = (
text: string,
options: {
readonly storedAs: "providerMetadata" | "providerOptions"
readonly itemId: string
readonly encryptedContent: string | null
},
) => {
const metadata = {
openai: { itemId: options.itemId, reasoningEncryptedContent: options.encryptedContent },
}
if (options.storedAs === "providerMetadata")
return Object.assign({ type: "reasoning" as const, text }, { providerMetadata: metadata })
return Object.assign({ type: "reasoning" as const, text }, { providerOptions: metadata })
}
type SessionAssistantPart = ReturnType<typeof sessionText> | ReturnType<typeof sessionOpenAIReasoning>
const storedSession = {
user: (content: string): ModelMessage => ({ role: "user", content }),
assistant: (content: SessionAssistantPart[]): ModelMessage => ({ role: "assistant", content }),
text: sessionText,
openaiReasoning: sessionOpenAIReasoning,
}
const openAIResponses = {
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
type: "reasoning",
id: options.itemId,
encrypted_content: options.encryptedContent,
summary: [{ type: "summary_text", text }],
}),
}
const prepareNativeRequest = (input: NativeRequestInput) => LLMClient.prepare(LLMNative.request(input))
const expectOpenAIResponsesRequest = (input: {
readonly history: NativeRequestInput["messages"]
readonly providerOptions?: NativeRequestInput["providerOptions"]
readonly maxOutputTokens?: NativeRequestInput["maxOutputTokens"]
readonly headers?: NativeRequestInput["headers"]
readonly expectedBody: unknown
}) =>
Effect.gen(function* () {
expect(
yield* prepareNativeRequest({
model: baseModel,
apiKey: "test-openai-key",
messages: input.history,
providerOptions: input.providerOptions,
maxOutputTokens: input.maxOutputTokens,
headers: input.headers,
}),
).toMatchObject({
route: "openai-responses",
protocol: "openai-responses",
body: input.expectedBody,
})
})
describe("session.llm-native.request", () => {
test("maps normalized stream inputs to a native LLM request", () => {
const messages: ModelMessage[] = [
{
role: "system",
content: "system from messages",
},
{
role: "user",
content: [
{ type: "text", text: "hello", providerOptions: { openai: { cacheControl: { type: "ephemeral" } } } },
{ type: "file", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
],
},
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking", providerOptions: { openai: { encryptedContent: "secret" } } },
{ type: "text", text: "I'll run it" },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
input: { command: "ls" },
providerOptions: { openai: { itemId: "item-1" } },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
output: { type: "text", value: "ok" },
providerOptions: { openai: { outputId: "output-1" } },
},
],
},
]
const request = LLMNative.request({
model: baseModel,
system: ["agent system"],
messages,
tools: {
bash: tool({
description: "Run a shell command",
inputSchema: jsonSchema({
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
}),
}),
},
toolChoice: "required",
temperature: 0.2,
topP: 0.9,
topK: 40,
maxOutputTokens: 1024,
providerOptions: { openai: { store: false } },
headers: { "x-request": "request-header" },
})
expect(request.model).toMatchObject({
id: "gpt-5-mini",
provider: "openai",
route: { id: "openai-responses" },
})
expect(request.model.route.endpoint.baseURL).toBe("https://api.openai.com/v1")
expect(request.model.route.defaults.headers).toEqual({
"x-model": "model-header",
"x-request": "request-header",
})
expect(request.model.route.defaults.limits).toMatchObject({
context: 128_000,
output: 32_000,
})
expect(request.system).toEqual([
{ type: "text", text: "agent system" },
{ type: "text", text: "system from messages" },
])
expect(request.generation).toMatchObject({
temperature: 0.2,
topP: 0.9,
topK: 40,
maxTokens: 1024,
})
expect(request.providerOptions).toEqual({ openai: { store: false } })
expect(request.toolChoice).toMatchObject({ type: "required" })
expect(request.tools).toMatchObject([
{
name: "bash",
description: "Run a shell command",
inputSchema: {
type: "object",
properties: {
command: { type: "string" },
},
required: ["command"],
},
},
])
expect(request.messages).toMatchObject([
{
role: "user",
content: [
{ type: "text", text: "hello", providerMetadata: { openai: { cacheControl: { type: "ephemeral" } } } },
{ type: "media", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
],
},
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking", providerMetadata: { openai: { encryptedContent: "secret" } } },
{ type: "text", text: "I'll run it" },
{
type: "tool-call",
id: "call-1",
name: "bash",
input: { command: "ls" },
providerMetadata: { openai: { itemId: "item-1" } },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
id: "call-1",
name: "bash",
result: { type: "text", value: "ok" },
providerMetadata: { openai: { outputId: "output-1" } },
},
],
},
])
})
test("maps stored provider metadata to native content metadata", () => {
const reasoning = Object.assign(
{ type: "reasoning" as const, text: "thinking" },
{
providerMetadata: {
openai: {
itemId: "rs_1",
reasoningEncryptedContent: "encrypted-state",
},
},
},
)
const request = LLMNative.request({
model: baseModel,
messages: [
{
role: "assistant",
content: [reasoning],
},
],
})
expect(request.messages).toMatchObject([
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
],
},
])
})
test("selects native request routes for provider packages", () => {
const openai = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
apiKey: "test-key",
messages: [],
})
expect(openai.route.id).toBe("openai-responses")
expect(openai.route.endpoint.baseURL).toBe("https://api.openai.com/v1")
const anthropic = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } },
apiKey: "test-key",
messages: [],
})
expect(anthropic.route.id).toBe("anthropic-messages")
expect(anthropic.route.endpoint.baseURL).toBe("https://api.anthropic.com/v1")
const google = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } },
apiKey: "test-key",
messages: [],
})
expect(google.route.id).toBe("gemini")
expect(google.route.endpoint.baseURL).toBe("https://generativelanguage.googleapis.com/v1beta")
const compatible = LLMNative.model({
model: {
...baseModel,
providerID: ProviderV2.ID.make("opencode"),
api: { ...baseModel.api, url: "https://ai.example.test/v1", npm: "@ai-sdk/openai-compatible" },
},
apiKey: "test-key",
messages: [],
})
expect(compatible.route.id).toBe("openai-compatible-chat")
expect(compatible.route.endpoint.baseURL).toBe("https://ai.example.test/v1")
const openrouter = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } },
apiKey: "test-key",
messages: [],
})
expect(openrouter.route.id).toBe("openrouter")
expect(openrouter.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
})
test("fails fast for unsupported provider packages", () => {
expect(() =>
LLMNative.request({
model: { ...baseModel, api: { ...baseModel.api, npm: "unknown-provider" } },
messages: [],
}),
).toThrow("Native LLM request adapter does not support provider package unknown-provider")
})
test("only enables native runtime for supported OpenAI API-key models", () => {
expect(LLMNativeRuntime.status({ model: baseModel, provider: providerInfo, auth: undefined })).toMatchObject({
type: "supported",
apiKey: "test-openai-key",
})
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderV2.ID.make("opencode") },
provider: { ...providerInfo, id: ProviderV2.ID.make("opencode") },
auth: undefined,
}),
).toMatchObject({
type: "supported",
apiKey: "test-openai-key",
})
expect(
LLMNativeRuntime.status({
model: {
...baseModel,
providerID: ProviderV2.ID.make("opencode"),
api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" },
},
provider: { ...providerInfo, id: ProviderV2.ID.make("opencode") },
auth: undefined,
}),
).toMatchObject({
type: "supported",
apiKey: "test-openai-key",
})
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderV2.ID.make("google") },
provider: { ...providerInfo, id: ProviderV2.ID.make("google") },
auth: undefined,
}),
).toEqual({ type: "unsupported", reason: "provider is not openai, opencode, or anthropic" })
expect(
LLMNativeRuntime.status({
model: baseModel,
provider: providerInfo,
auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 },
}),
).toEqual({ type: "unsupported", reason: "OAuth auth requires a provider fetch override" })
expect(
LLMNativeRuntime.status({
model: baseModel,
provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: async () => new Response() } },
auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 },
}),
).toMatchObject({ type: "supported", apiKey: OAUTH_DUMMY_KEY })
expect(
LLMNativeRuntime.status({
model: { ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/google" } },
provider: providerInfo,
auth: undefined,
}),
).toEqual({ type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" })
expect(
LLMNativeRuntime.status({
model: baseModel,
provider: { ...providerInfo, options: {} },
auth: undefined,
}),
).toEqual({ type: "unsupported", reason: "API key is not configured" })
})
test("enables native runtime for Anthropic API-key models", () => {
expect(
LLMNativeRuntime.status({
model: {
...baseModel,
providerID: ProviderV2.ID.make("anthropic"),
api: { ...baseModel.api, npm: "@ai-sdk/anthropic", url: "https://api.anthropic.com/v1" },
},
provider: {
...providerInfo,
id: ProviderV2.ID.make("anthropic"),
name: "Anthropic",
env: ["ANTHROPIC_API_KEY"],
options: { apiKey: "test-anthropic-key" },
},
auth: undefined,
}),
).toMatchObject({ type: "supported", apiKey: "test-anthropic-key" })
})
test("prefers console provider api key over stored opencode auth", () => {
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderV2.ID.make("opencode") },
provider: {
...providerInfo,
id: ProviderV2.ID.make("opencode"),
options: { apiKey: "console-token" },
key: "zen-token",
},
auth: { type: "api", key: "zen-token" },
}),
).toMatchObject({
type: "supported",
apiKey: "console-token",
})
expect(
LLMNativeRuntime.status({
model: baseModel,
provider: { ...providerInfo, options: {}, key: "provider-key" },
auth: undefined,
}),
).toMatchObject({
type: "supported",
apiKey: "provider-key",
})
})
it.effect("native tool wrapper converts thrown errors into typed ToolFailure", () =>
Effect.gen(function* () {
const wrapped = LLMNativeRuntime.nativeTools(
{
explode: {
description: "always throws",
inputSchema: jsonSchema({ type: "object" }),
execute: async () => {
throw new Error("boom")
},
} satisfies Tool,
},
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
)
const failure = yield* Effect.flip(wrapped.explode.execute({}, { id: "call-1", name: "explode" }))
expect(failure).toBeInstanceOf(ToolFailure)
expect(failure.message).toBe("boom")
}),
)
it.effect("native tool wrapper raises ToolFailure when the source tool has no execute handler", () =>
Effect.gen(function* () {
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
// The native runtime owns execution, so encountering such a tool here means upstream
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
const wrapped = LLMNativeRuntime.nativeTools(
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } satisfies Tool },
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
)
const failure = yield* Effect.flip(wrapped.incomplete.execute({}, { id: "call-1", name: "incomplete" }))
expect(failure).toBeInstanceOf(ToolFailure)
expect(failure.message).toContain("incomplete")
}),
)
it.effect("emits native tool calls before overlapping local settlements complete", () =>
Effect.gen(function* () {
const observed: string[] = []
const started: string[] = []
let release: (() => void) | undefined
let notifyStarted: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
release = resolve
})
const bothStarted = new Promise<void>((resolve) => {
notifyStarted = resolve
})
const lookup = {
description: "Lookup data",
inputSchema: jsonSchema({ type: "object" }),
execute: async (_args: unknown, options: { toolCallId: string }) => {
started.push(options.toolCallId)
if (started.length === 2) notifyStarted?.()
await gate
return { output: options.toolCallId }
},
} satisfies Tool
const llmClient = {
prepare: () => Effect.die("unused"),
stream: () =>
Stream.fromIterable([
LLMEvent.toolCall({ id: "call-1", name: "lookup", input: {} }),
LLMEvent.toolCall({ id: "call-2", name: "lookup", input: {} }),
LLMEvent.finish({ reason: "tool-calls" }),
]),
generate: () => Effect.die("unused"),
} as LLMClientShape
const native = LLMNativeRuntime.stream({
model: baseModel,
provider: providerInfo,
auth: undefined,
llmClient,
messages: [],
tools: { lookup },
headers: {},
abort: new AbortController().signal,
})
expect(native.type).toBe("supported")
if (native.type === "unsupported") throw new Error(native.reason)
const fiber = yield* native.stream.pipe(
Stream.runForEach((event) => Effect.sync(() => observed.push(event.type))),
Effect.forkScoped,
)
yield* Effect.promise(() => bothStarted)
expect(started).toEqual(["call-1", "call-2"])
expect(observed).toEqual(["tool-call", "tool-call", "finish"])
release?.()
yield* Fiber.join(fiber)
expect(observed).toEqual(["tool-call", "tool-call", "finish", "tool-result", "tool-result"])
}),
)
it.effect("compiles through the native OpenAI Responses route", () =>
expectOpenAIResponsesRequest({
history: [storedSession.user("hello")],
providerOptions: { openai: { store: false, instructions: "You are concise." } },
maxOutputTokens: 512,
headers: { "x-request": "request-header" },
expectedBody: {
model: "gpt-5-mini",
instructions: "You are concise.",
input: [openAIResponses.user("hello")],
max_output_tokens: 512,
store: false,
stream: true,
},
}),
)
it.effect("omits non-persisted OpenAI reasoning ids without encrypted state", () =>
expectOpenAIResponsesRequest({
history: [
storedSession.user("What changed?"),
storedSession.assistant([
storedSession.openaiReasoning("Checked the previous diff.", {
storedAs: "providerOptions",
itemId: "rs_1",
encryptedContent: null,
}),
storedSession.text("The parser changed."),
]),
storedSession.user("Summarize it."),
],
providerOptions: { openai: { store: false } },
expectedBody: {
input: [
openAIResponses.user("What changed?"),
openAIResponses.assistant("The parser changed."),
openAIResponses.user("Summarize it."),
],
store: false,
},
}),
)
it.effect("preserves encrypted OpenAI reasoning state through native request lowering", () =>
expectOpenAIResponsesRequest({
history: [
storedSession.user("What changed?"),
storedSession.assistant([
storedSession.openaiReasoning("Checked the previous diff.", {
storedAs: "providerMetadata",
itemId: "rs_1",
encryptedContent: "encrypted-state",
}),
storedSession.text("The parser changed."),
]),
storedSession.user("Summarize it."),
],
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
expectedBody: {
input: [
openAIResponses.user("What changed?"),
openAIResponses.openaiReasoning("Checked the previous diff.", {
itemId: "rs_1",
encryptedContent: "encrypted-state",
}),
openAIResponses.assistant("The parser changed."),
openAIResponses.user("Summarize it."),
],
include: ["reasoning.encrypted_content"],
store: false,
},
}),
)
it.effect("preserves empty encrypted OpenAI reasoning items before tool output", () =>
expectOpenAIResponsesRequest({
history: [
storedSession.assistant([
storedSession.openaiReasoning("", {
storedAs: "providerMetadata",
itemId: "rs_1",
encryptedContent: "encrypted-state",
}),
]),
],
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
expectedBody: {
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
include: ["reasoning.encrypted_content"],
store: false,
},
}),
)
it.effect("references stored OpenAI reasoning items by id", () =>
expectOpenAIResponsesRequest({
history: [
storedSession.assistant([
storedSession.openaiReasoning("Checked the previous diff.", {
storedAs: "providerMetadata",
itemId: "rs_1",
encryptedContent: null,
}),
]),
],
providerOptions: { openai: { store: true } },
expectedBody: {
input: [{ type: "item_reference", id: "rs_1" }],
store: true,
},
}),
)
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
Effect.gen(function* () {
const captures: Array<{ url: string; body: unknown }> = []
const customFetch = Object.assign(
async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
const request = input instanceof Request ? input : new Request(input, init)
captures.push({ url: request.url, body: await request.clone().json() })
return responsesStream([
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } },
])
},
{ preconnect: () => undefined },
) satisfies typeof fetch
const llmClient = yield* LLMClient.Service
const native = LLMNativeRuntime.stream({
model: baseModel,
provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } },
auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
llmClient,
messages: [{ role: "user", content: "hello" }],
tools: {},
providerOptions: { instructions: "You are concise." },
headers: {},
abort: new AbortController().signal,
})
expect(native.type).toBe("supported")
if (native.type === "unsupported") throw new Error(native.reason)
const events = Array.from(yield* native.stream.pipe(Stream.runCollect))
expect(captures).toHaveLength(1)
expect(captures[0]).toMatchObject({
url: "https://api.openai.com/v1/responses",
body: {
model: "gpt-5-mini",
instructions: "You are concise.",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
},
})
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "text-delta", text: "Hello" }),
expect.objectContaining({ type: "finish" }),
]),
)
}),
)
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,439 @@
import { describe, expect, test } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Layer, Schedule, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderError } from "../../src/provider/error"
import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const providerID = ProviderV2.ID.make("test")
const retryProvider = "test"
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
function apiError(headers?: Record<string, string>): SessionV1.APIError {
return Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "boom",
isRetryable: true,
responseHeaders: headers,
}).toObject(),
)
}
function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
return { name: "", data: { message } }
}
describe("session.retry.delay", () => {
test("caps delay at 30 seconds when headers missing", () => {
const error = apiError()
const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error))
expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
})
test("prefers retry-after-ms when shorter than exponential", () => {
const error = apiError({ "retry-after-ms": "1500" })
expect(SessionRetry.delay(4, error)).toBe(1500)
})
test("uses retry-after seconds when reasonable", () => {
const error = apiError({ "retry-after": "30" })
expect(SessionRetry.delay(3, error)).toBe(30000)
})
test("accepts http-date retry-after values", () => {
const date = new Date(Date.now() + 20000).toUTCString()
const error = apiError({ "retry-after": date })
const d = SessionRetry.delay(1, error)
expect(d).toBeGreaterThanOrEqual(19000)
expect(d).toBeLessThanOrEqual(20000)
})
test("ignores invalid retry hints", () => {
const error = apiError({ "retry-after": "not-a-number" })
expect(SessionRetry.delay(1, error)).toBe(2000)
})
test("ignores malformed date retry hints", () => {
const error = apiError({ "retry-after": "Invalid Date String" })
expect(SessionRetry.delay(1, error)).toBe(2000)
})
test("ignores past date retry hints", () => {
const pastDate = new Date(Date.now() - 5000).toUTCString()
const error = apiError({ "retry-after": pastDate })
expect(SessionRetry.delay(1, error)).toBe(2000)
})
test("uses retry-after values even when exceeding 10 minutes with headers", () => {
const error = apiError({ "retry-after": "50" })
expect(SessionRetry.delay(1, error)).toBe(50000)
const longError = apiError({ "retry-after-ms": "700000" })
expect(SessionRetry.delay(1, longError)).toBe(700000)
})
test("caps oversized header delays to the runtime timer limit", () => {
const error = apiError({ "retry-after-ms": "999999999999" })
expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY)
})
it.instance("policy updates retry status and increments attempts", () =>
Effect.gen(function* () {
const sessionID = SessionID.make("session-retry-test")
const error = apiError({ "retry-after-ms": "0" })
const status = yield* SessionStatus.Service
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
provider: "test",
parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema),
set: (info) =>
status.set(sessionID, {
type: "retry",
attempt: info.attempt,
message: info.message,
next: info.next,
}),
}),
)
yield* step(error)
yield* step(error)
expect(yield* status.get(sessionID)).toMatchObject({
type: "retry",
attempt: 2,
message: "boom",
})
}),
)
})
describe("session.retry.retryable", () => {
test("maps too_many_requests json messages", () => {
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
})
test("maps overloaded provider codes", () => {
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
})
test("does not retry unknown json messages", () => {
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("does not throw on numeric error codes", () => {
const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
const result = SessionRetry.retryable(error, retryProvider)
expect(result).toBeUndefined()
})
test("returns undefined for non-json message", () => {
const error = wrap("not-json")
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries plain text rate limit errors from Alibaba", () => {
const msg =
"Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
const error = wrap(msg)
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("retries plain text rate limit errors", () => {
const msg = "Rate limit exceeded, please try again later"
const error = wrap(msg)
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("retries too many requests in plain text", () => {
const msg = "Too many requests, please slow down"
const error = wrap(msg)
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("retries transport timeout errors", () => {
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
expect(SessionV1.APIError.isInstance(request)).toBe(true)
expect(SessionRetry.retryable(request, retryProvider)).toEqual({
message: "Provider response headers timed out after 10000ms",
})
})
test("retries websocket stream transport errors", () => {
const request = MessageV2.fromError(
new ProviderError.ResponseStreamError("WebSocket closed before response.completed (code 1006: Connection ended)"),
{ providerID },
)
expect(SessionV1.APIError.isInstance(request)).toBe(true)
expect(SessionRetry.retryable(request, retryProvider)).toEqual({
message: "WebSocket closed before response.completed (code 1006: Connection ended)",
})
})
test("does not retry context overflow errors", () => {
const error = new SessionV1.ContextOverflowError({
message: "Input exceeds context window of this model",
responseBody: '{"error":{"code":"context_length_exceeded"}}',
}).toObject()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries 500 errors even when isRetryable is false", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Internal server error",
isRetryable: false,
statusCode: 500,
responseBody: '{"type":"api_error","message":"Internal server error"}',
}).toObject(),
)
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Internal server error" })
})
test("retries 502 bad gateway errors", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Bad gateway",
isRetryable: false,
statusCode: 502,
}).toObject(),
)
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Bad gateway" })
})
test("retries 503 service unavailable errors", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Service unavailable",
isRetryable: false,
statusCode: 503,
}).toObject(),
)
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Service unavailable" })
})
test("does not retry 4xx errors when isRetryable is false", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Bad request",
isRetryable: false,
statusCode: 400,
}).toObject(),
)
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries ZlibError decompression failures", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Response decompression failed",
isRetryable: true,
metadata: { code: "ZlibError" },
}).toObject(),
)
const retryable = SessionRetry.retryable(error, retryProvider)
expect(retryable).toBeDefined()
expect(retryable).toEqual({ message: "Response decompression failed" })
})
test("maps free limits to Go upsell action", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Free usage exceeded",
isRetryable: true,
statusCode: 429,
responseBody: JSON.stringify({
type: "error",
error: { type: "FreeUsageLimitError", message: "Free usage exceeded" },
}),
}).toObject(),
)
expect(SessionRetry.retryable(error, "opencode")).toEqual({
message: SessionRetry.GO_UPSELL_MESSAGE,
action: {
reason: "free_tier_limit",
provider: "opencode",
title: "Free limit reached",
message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
label: "subscribe",
link: SessionRetry.GO_UPSELL_URL,
},
})
})
test("maps Go subscription limits to workspace PAYG upsell", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Subscription quota exceeded. You can continue using free models.",
isRetryable: true,
statusCode: 429,
responseHeaders: {
"retry-after": "19380",
},
responseBody: JSON.stringify({
type: "error",
error: {
type: "GoUsageLimitError",
message: "Subscription quota exceeded. You can continue using free models.",
},
metadata: {
workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
limitName: "5 hour",
},
}),
}).toObject(),
)
expect(SessionRetry.retryable(error, "opencode-go")).toEqual({
message:
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
action: {
reason: "account_rate_limit",
provider: "opencode-go",
title: "Go limit reached",
message:
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance",
label: "open settings",
link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
},
})
})
test("maps Go subscription limits without limit metadata", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Subscription quota exceeded. You can continue using free models.",
isRetryable: true,
statusCode: 429,
responseHeaders: {
"retry-after": "900",
},
responseBody: JSON.stringify({
type: "error",
error: {
type: "GoUsageLimitError",
message: "Subscription quota exceeded. You can continue using free models.",
},
metadata: {
workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
},
}),
}).toObject(),
)
expect(SessionRetry.retryable(error, "opencode-go")?.action?.message).toBe(
"Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance",
)
})
})
describe("session.message-v2.fromError", () => {
test.concurrent(
"converts ECONNRESET socket errors to retryable APIError",
async () => {
using server = Bun.serve({
port: 0,
idleTimeout: 8,
async fetch(_req) {
return new Response(
new ReadableStream({
async pull(controller) {
controller.enqueue("Hello,")
await sleep(10000)
controller.enqueue(" World!")
controller.close()
},
}),
{ headers: { "Content-Type": "text/plain" } },
)
},
})
const error = await fetch(new URL("/", server.url.origin))
.then((res) => res.text())
.catch((e) => e)
const result = MessageV2.fromError(error, { providerID })
expect(SessionV1.APIError.isInstance(result)).toBe(true)
if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
expect(result.data.message).toBe("Connection reset by server")
expect(result.data.metadata?.code).toBe("ECONNRESET")
expect(result.data.metadata?.message).toInclude("socket connection")
},
15_000,
)
test("ECONNRESET socket error is retryable", () => {
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Connection reset by server",
isRetryable: true,
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
}).toObject(),
)
const retryable = SessionRetry.retryable(error, retryProvider)
expect(retryable).toBeDefined()
expect(retryable).toEqual({ message: "Connection reset by server" })
})
test("marks OpenAI 404 status codes as retryable", () => {
const error = new APICallError({
message: "boom",
url: "https://api.openai.com/v1/chat/completions",
requestBodyValues: {},
statusCode: 404,
responseHeaders: { "content-type": "application/json" },
responseBody: '{"error":"boom"}',
isRetryable: false,
})
const result = MessageV2.fromError(error, { providerID: ProviderV2.ID.make("openai") })
if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
})
test("converts OpenAI server_error stream chunks to retryable APIError", () => {
const result = MessageV2.fromError(
{
message: JSON.stringify({
type: "error",
sequence_number: 2,
error: {
type: "server_error",
code: "server_error",
message: "An error occurred while processing your request.",
param: null,
},
}),
},
{ providerID: ProviderV2.ID.make("openai") },
)
expect(SessionV1.APIError.isInstance(result)).toBe(true)
if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
expect(SessionRetry.retryable(result, retryProvider)).toEqual({
message: "An error occurred while processing your request.",
})
})
})

View File

@@ -0,0 +1,639 @@
import { describe, expect } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import { SessionRevert } from "../../src/session/revert"
import { MessageV2 } from "../../src/session/message-v2"
import { Snapshot } from "../../src/snapshot"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const env = Layer.mergeAll(
Session.defaultLayer,
SessionRevert.defaultLayer,
Snapshot.defaultLayer,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") {
const session = yield* Session.Service
return yield* session.updateMessage({
id: MessageID.ascending(),
role: "user" as const,
sessionID,
agent,
model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4") },
time: { created: Date.now() },
})
})
const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) {
const session = yield* Session.Service
return yield* session.updateMessage({
id: MessageID.ascending(),
role: "assistant" as const,
sessionID,
mode: "default",
agent: "default",
path: { cwd: dir, root: dir },
cost: 0,
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ModelV2.ID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID,
time: { created: Date.now() },
finish: "end_turn",
})
})
const text = Effect.fn("test.text")(function* (sessionID: SessionID, messageID: MessageID, content: string) {
const session = yield* Session.Service
return yield* session.updatePart({
id: PartID.ascending(),
messageID,
sessionID,
type: "text" as const,
text: content,
})
})
const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: MessageID) {
const session = yield* Session.Service
return yield* session.updatePart({
id: PartID.ascending(),
messageID,
sessionID,
type: "tool" as const,
tool: "bash",
callID: "call-1",
state: {
status: "completed" as const,
input: {},
output: "done",
title: "",
metadata: {},
time: { start: 0, end: 1 },
},
})
})
const read = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text))
const tokens = {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
}
describe("revert + compact workflow", () => {
it.live(
"should properly handle compact command after revert",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sessionID = info.id
const userMsg1 = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderV2.ID.make("openai"),
modelID: ModelV2.ID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: userMsg1.id,
sessionID,
type: "text",
text: "Hello, please help me",
})
const assistantMsg1: SessionV1.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: dir,
root: dir,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelV2.ID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg1.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
yield* session.updateMessage(assistantMsg1)
yield* session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg1.id,
sessionID,
type: "text",
text: "Sure, I'll help you!",
})
const userMsg2 = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderV2.ID.make("openai"),
modelID: ModelV2.ID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: userMsg2.id,
sessionID,
type: "text",
text: "What's the capital of France?",
})
const assistantMsg2: SessionV1.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: dir,
root: dir,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelV2.ID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg2.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
yield* session.updateMessage(assistantMsg2)
yield* session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg2.id,
sessionID,
type: "text",
text: "The capital of France is Paris.",
})
let messages = yield* session.messages({ sessionID })
expect(messages.length).toBe(4)
const messageIds = messages.map((m) => m.info.id)
expect(messageIds).toContain(userMsg1.id)
expect(messageIds).toContain(userMsg2.id)
expect(messageIds).toContain(assistantMsg1.id)
expect(messageIds).toContain(assistantMsg2.id)
yield* revert.revert({
sessionID,
messageID: userMsg2.id,
})
let sessionInfo = yield* session.get(sessionID)
expect(sessionInfo.revert).toBeDefined()
expect(sessionInfo.revert?.messageID).toBeDefined()
messages = yield* session.messages({ sessionID })
expect(messages.length).toBe(4)
yield* revert.cleanup(sessionInfo)
messages = yield* session.messages({ sessionID })
const remainingIds = messages.map((m) => m.info.id)
expect(messages.length).toBeLessThan(4)
expect(remainingIds).not.toContain(userMsg2.id)
expect(remainingIds).not.toContain(assistantMsg2.id)
sessionInfo = yield* session.get(sessionID)
expect(sessionInfo.revert).toBeUndefined()
yield* session.remove(sessionID)
}),
{ git: true },
),
)
it.live(
"should properly clean up revert state before creating compaction message",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sessionID = info.id
const userMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "default",
model: {
providerID: ProviderV2.ID.make("openai"),
modelID: ModelV2.ID.make("gpt-4"),
},
time: {
created: Date.now(),
},
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID,
type: "text",
text: "Hello",
})
const assistantMsg: SessionV1.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "default",
agent: "default",
path: {
cwd: dir,
root: dir,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelV2.ID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg.id,
time: {
created: Date.now(),
},
finish: "end_turn",
}
yield* session.updateMessage(assistantMsg)
yield* session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg.id,
sessionID,
type: "text",
text: "Hi there!",
})
yield* revert.revert({
sessionID,
messageID: userMsg.id,
})
let sessionInfo = yield* session.get(sessionID)
expect(sessionInfo.revert).toBeDefined()
yield* revert.cleanup(sessionInfo)
sessionInfo = yield* session.get(sessionID)
expect(sessionInfo.revert).toBeUndefined()
const messages = yield* session.messages({ sessionID })
expect(messages.length).toBe(0)
yield* session.remove(sessionID)
}),
{ git: true },
),
)
it.live(
"cleanup with partID removes parts from the revert point onward",
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sid = info.id
const u1 = yield* user(sid)
const p1 = yield* text(sid, u1.id, "first part")
const p2 = yield* tool(sid, u1.id)
yield* text(sid, u1.id, "third part")
yield* session.setRevert({
sessionID: sid,
revert: { messageID: u1.id, partID: p2.id },
summary: { additions: 0, deletions: 0, files: 0 },
})
const state = yield* session.get(sid)
yield* revert.cleanup(state)
const msgs = yield* session.messages({ sessionID: sid })
expect(msgs.length).toBe(1)
expect(msgs[0].parts.length).toBe(1)
expect(msgs[0].parts[0].id).toBe(p1.id)
const cleared = yield* session.get(sid)
expect(cleared.revert).toBeUndefined()
}),
{ git: true },
),
)
it.live(
"cleanup removes messages after revert point but keeps earlier ones",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sid = info.id
const u1 = yield* user(sid)
yield* text(sid, u1.id, "hello")
const a1 = yield* assistant(sid, u1.id, dir)
yield* text(sid, a1.id, "hi back")
const u2 = yield* user(sid)
yield* text(sid, u2.id, "second question")
const a2 = yield* assistant(sid, u2.id, dir)
yield* text(sid, a2.id, "second answer")
yield* session.setRevert({
sessionID: sid,
revert: { messageID: u2.id },
summary: { additions: 0, deletions: 0, files: 0 },
})
const state = yield* session.get(sid)
yield* revert.cleanup(state)
const msgs = yield* session.messages({ sessionID: sid })
const ids = msgs.map((m) => m.info.id)
expect(ids).toContain(u1.id)
expect(ids).toContain(a1.id)
expect(ids).not.toContain(u2.id)
expect(ids).not.toContain(a2.id)
}),
{ git: true },
),
)
it.live(
"cleanup is a no-op when session has no revert state",
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const info = yield* session.create({})
const sid = info.id
const u1 = yield* user(sid)
yield* text(sid, u1.id, "hello")
const state = yield* session.get(sid)
expect(state.revert).toBeUndefined()
yield* revert.cleanup(state)
const msgs = yield* session.messages({ sessionID: sid })
expect(msgs.length).toBe(1)
}),
{ git: true },
),
)
it.live(
"restore messages in sequential order",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const snapshot = yield* Snapshot.Service
yield* write(path.join(dir, "a.txt"), "a0")
yield* write(path.join(dir, "b.txt"), "b0")
yield* write(path.join(dir, "c.txt"), "c0")
const info = yield* session.create({})
const sid = info.id
const turn = Effect.fn("test.turn")(function* (file: string, next: string) {
const u = yield* user(sid)
yield* text(sid, u.id, `${file}:${next}`)
const a = yield* assistant(sid, u.id, dir)
const before = yield* snapshot.track()
if (!before) throw new Error("expected snapshot")
yield* write(path.join(dir, file), next)
const after = yield* snapshot.track()
if (!after) throw new Error("expected snapshot")
const patch = yield* snapshot.patch(before)
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-start",
snapshot: before,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-finish",
reason: "stop",
snapshot: after,
cost: 0,
tokens,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "patch",
hash: patch.hash,
files: patch.files,
})
return u.id
})
const first = yield* turn("a.txt", "a1")
const second = yield* turn("b.txt", "b2")
const third = yield* turn("c.txt", "c3")
yield* revert.revert({
sessionID: sid,
messageID: first,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(first)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
yield* revert.revert({
sessionID: sid,
messageID: second,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(second)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
yield* revert.revert({
sessionID: sid,
messageID: third,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(third)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
yield* revert.unrevert({
sessionID: sid,
})
expect((yield* session.get(sid)).revert).toBeUndefined()
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
expect(yield* read(path.join(dir, "c.txt"))).toBe("c3")
}),
{ git: true },
),
)
it.live(
"restore same file in sequential order",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const snapshot = yield* Snapshot.Service
yield* write(path.join(dir, "a.txt"), "a0")
const info = yield* session.create({})
const sid = info.id
const turn = Effect.fn("test.turnSame")(function* (next: string) {
const u = yield* user(sid)
yield* text(sid, u.id, `a.txt:${next}`)
const a = yield* assistant(sid, u.id, dir)
const before = yield* snapshot.track()
if (!before) throw new Error("expected snapshot")
yield* write(path.join(dir, "a.txt"), next)
const after = yield* snapshot.track()
if (!after) throw new Error("expected snapshot")
const patch = yield* snapshot.patch(before)
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-start",
snapshot: before,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "step-finish",
reason: "stop",
snapshot: after,
cost: 0,
tokens,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: sid,
type: "patch",
hash: patch.hash,
files: patch.files,
})
return u.id
})
const first = yield* turn("a1")
const second = yield* turn("a2")
const third = yield* turn("a3")
expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
yield* revert.revert({
sessionID: sid,
messageID: first,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(first)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
yield* revert.revert({
sessionID: sid,
messageID: second,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(second)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
yield* revert.revert({
sessionID: sid,
messageID: third,
})
expect((yield* session.get(sid)).revert?.messageID).toBe(third)
expect(yield* read(path.join(dir, "a.txt"))).toBe("a2")
yield* revert.unrevert({
sessionID: sid,
})
expect((yield* session.get(sid)).revert).toBeUndefined()
expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
}),
{ git: true },
),
)
})

View File

@@ -0,0 +1,313 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Session } from "@/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { Todo } from "../../src/session/todo"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
// Covers the session-domain Effect Schema migration. For each migrated
// schema we assert:
// 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input.
// 2. Clearly-invalid input is rejected.
// Representative valid IDs — the branded schemas require the right prefix
// (see src/id/id.ts).
const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K")
const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L")
const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M")
const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N")
const projectID = ProjectV2.ID.make("proj-alpha")
const workspaceID = Schema.decodeUnknownSync(WorkspaceV2.ID)("wrk-primary")
function decodeUnknown<S extends Schema.Top>(schema: S) {
const decode = Schema.decodeUnknownSync(schema as any)
return (input: unknown): Schema.Schema.Type<S> => decode(input) as Schema.Schema.Type<S>
}
describe("Session.Info", () => {
const decode = decodeUnknown(Session.Info)
test("accepts minimal session", () => {
const input = {
id: sessionID,
slug: "hello",
projectID,
directory: "/tmp/proj",
title: "First session",
version: "0.1.0",
time: { created: 1, updated: 2 },
}
expect(decode(input)).toEqual(input)
})
test("round-trips every optional field", () => {
const input = {
id: sessionID,
slug: "fullshape",
projectID,
workspaceID,
directory: "/tmp/proj",
path: "packages/opencode",
parentID: sessionIDChild,
summary: {
additions: 10,
deletions: 5,
files: 2,
diffs: [{ additions: 1, deletions: 0, file: "a.ts", patch: "--- a/a.ts" }],
},
share: { url: "https://share.example.com/s/1" },
title: "Full session",
version: "1.0.0",
metadata: { source: "test" },
time: { created: 100, updated: 200, compacting: 150, archived: 300 },
permission: [{ action: "allow" as const, pattern: "*", permission: "read" }],
revert: {
messageID,
partID,
snapshot: "snap-1",
diff: "diff-1",
},
}
expect(decode(input)).toEqual(input)
})
test("accepts migrated summary diffs without file details", () => {
const input = {
id: sessionID,
slug: "legacy-diff",
projectID,
directory: "/tmp/proj",
title: "Legacy diff",
version: "0.1.0",
summary: {
additions: 1,
deletions: 0,
files: 1,
diffs: [{ additions: 1, deletions: 0 }],
},
time: { created: 1, updated: 2 },
}
expect(decode(input)).toEqual(input)
})
test("rejects unbranded session id", () => {
const bad = { id: "not-a-session-id" } as unknown
expect(() => decode(bad)).toThrow()
})
test("rejects missing required fields", () => {
const bad = { id: sessionID } as unknown
expect(() => decode(bad)).toThrow()
})
})
describe("Session.ProjectInfo", () => {
const decode = decodeUnknown(Session.ProjectInfo)
test("accepts with and without optional name", () => {
const noName = { id: projectID, worktree: "/tmp/wt" }
const withName = { ...noName, name: "alpha" }
expect(decode(noName)).toEqual(noName)
expect(decode(withName)).toEqual(withName)
})
})
describe("Session.GlobalInfo", () => {
const decode = decodeUnknown(Session.GlobalInfo)
test("accepts null project", () => {
const input = {
id: sessionID,
slug: "global",
projectID,
directory: "/tmp/proj",
title: "global",
version: "0",
time: { created: 0, updated: 0 },
project: null,
}
expect(decode(input)).toEqual(input)
})
test("accepts populated project", () => {
const input = {
id: sessionID,
slug: "global",
projectID,
directory: "/tmp/proj",
title: "global",
version: "0",
time: { created: 0, updated: 0 },
project: { id: projectID, worktree: "/tmp/wt", name: "alpha" },
}
expect(decode(input)).toEqual(input)
})
})
describe("Session input schemas", () => {
test("CreateInput accepts undefined and populated forms", () => {
const decode = decodeUnknown(Session.CreateInput)
expect(decode(undefined)).toBeUndefined()
const populated = {
parentID: sessionID,
title: "child",
metadata: { source: "test" },
permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }],
workspaceID,
}
expect(decode(populated)).toEqual(populated)
})
test("ForkInput round-trips", () => {
const decode = decodeUnknown(Session.ForkInput)
const input = { sessionID, messageID }
expect(decode(input)).toEqual(input)
// messageID is optional
const bare = { sessionID }
expect(decode(bare)).toEqual(bare)
})
test("SetTitleInput rejects missing title", () => {
expect(() => decodeUnknown(Session.SetTitleInput)({ sessionID })).toThrow()
})
test("SetArchivedInput accepts both with and without time", () => {
const decode = decodeUnknown(Session.SetArchivedInput)
expect(decode({ sessionID })).toEqual({ sessionID })
expect(decode({ sessionID, time: 123 })).toEqual({ sessionID, time: 123 })
})
test("SetPermissionInput requires a ruleset", () => {
const decode = decodeUnknown(Session.SetPermissionInput)
const input = { sessionID, permission: [{ action: "deny" as const, pattern: "*", permission: "write" }] }
expect(decode(input)).toEqual(input)
expect(() => decode({ sessionID })).toThrow()
})
test("MessagesInput accepts optional limit", () => {
const decode = decodeUnknown(Session.MessagesInput)
expect(decode({ sessionID })).toEqual({ sessionID })
expect(decode({ sessionID, limit: 50 })).toEqual({ sessionID, limit: 50 })
})
})
describe("SessionRevert.RevertInput", () => {
const decode = decodeUnknown(SessionRevert.RevertInput)
test("messageID is required, partID is optional", () => {
const withPart = { sessionID, messageID, partID }
expect(decode(withPart)).toEqual(withPart)
const noPart = { sessionID, messageID }
expect(decode(noPart)).toEqual(noPart)
expect(() => decode({ sessionID })).toThrow()
})
})
describe("SessionSummary.DiffInput", () => {
const decode = decodeUnknown(SessionSummary.DiffInput)
test("messageID optional", () => {
expect(decode({ sessionID })).toEqual({ sessionID })
expect(decode({ sessionID, messageID })).toEqual({ sessionID, messageID })
})
})
describe("SessionStatus.Info", () => {
const decode = decodeUnknown(SessionStatus.Info)
test("idle / busy discriminators", () => {
expect(decode({ type: "idle" })).toEqual({ type: "idle" })
expect(decode({ type: "busy" })).toEqual({ type: "busy" })
})
test("retry carries attempt/message/action/next", () => {
const input = {
type: "retry" as const,
attempt: 1,
message: "transient",
action: {
reason: "free_tier_limit",
provider: "opencode",
title: "Free limit reached",
message: "Subscribe to OpenCode Go.",
label: "subscribe",
link: "https://opencode.ai/go",
},
next: 500,
}
expect(decode(input)).toEqual(input)
})
test("rejects unknown type", () => {
expect(() => decode({ type: "bogus" })).toThrow()
})
})
describe("Todo.Info", () => {
const decode = decodeUnknown(Todo.Info)
test("three-field round-trip", () => {
const input = { content: "do a thing", status: "pending", priority: "high" }
expect(decode(input)).toEqual(input)
})
})
describe("SessionPrompt input schemas", () => {
test("LoopInput is just sessionID", () => {
const decode = decodeUnknown(SessionPrompt.LoopInput)
expect(decode({ sessionID })).toEqual({ sessionID })
})
test("ShellInput requires agent + command", () => {
const decode = decodeUnknown(SessionPrompt.ShellInput)
const expected = { sessionID, agent: "build", command: "echo hi" }
const input: unknown = expected
expect(decode(input)).toEqual(expected)
expect(() => decode({ sessionID })).toThrow()
})
test("PromptInput accepts a text part and a file part", () => {
const decode = decodeUnknown(SessionPrompt.PromptInput)
const expected = {
sessionID,
parts: [
{ type: "text" as const, text: "hello" },
{ type: "file" as const, mime: "image/png", url: "data:image/png;base64,AAAA" },
],
}
const input: unknown = expected
const decoded = decode(input)
expect(decoded.parts).toHaveLength(2)
expect(decoded.parts[0]).toMatchObject({ type: "text", text: "hello" })
expect(decoded.parts[1]).toMatchObject({ type: "file", mime: "image/png" })
})
test("PromptInput rejects unknown part type", () => {
const decode = decodeUnknown(SessionPrompt.PromptInput)
const bad = {
sessionID,
parts: [{ type: "nonsense", payload: 42 }],
}
expect(() => decode(bad)).toThrow()
})
test("CommandInput round-trips core fields", () => {
const decode = decodeUnknown(SessionPrompt.CommandInput)
const expected = {
sessionID,
arguments: "--flag",
command: "deploy",
}
const input: unknown = expected
expect(decode(input)).toEqual(expected)
})
})

View File

@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ProjectV2 } from "@opencode-ai/core/project"
import { MessageID, SessionID } from "../../src/session/schema"
import { Session } from "../../src/session/session"
const info = {
id: SessionID.descending(),
slug: "test-session",
projectID: ProjectV2.ID.global,
workspaceID: undefined,
directory: "/tmp/opencode",
parentID: undefined,
summary: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
share: undefined,
title: "Test session",
version: "1.0.0",
time: {
created: 1,
updated: 2,
compacting: undefined,
archived: undefined,
},
permission: undefined,
revert: undefined,
} satisfies Session.Info
describe("Session schema", () => {
test("encodes undefined optional session fields as omitted keys", () => {
const encoded = Schema.encodeUnknownSync(Session.Info)(info) as Record<string, unknown>
for (const key of ["workspaceID", "parentID", "summary", "share", "permission", "revert"]) {
expect(Object.hasOwn(encoded, key)).toBe(false)
}
expect(Object.hasOwn(encoded.time as Record<string, unknown>, "compacting")).toBe(false)
expect(Object.hasOwn(encoded.time as Record<string, unknown>, "archived")).toBe(false)
expect(JSON.stringify(encoded)).not.toContain("parentID")
})
test("encodes undefined optional global session project fields as omitted keys", () => {
const encoded = Schema.encodeUnknownSync(Session.GlobalInfo)({
...info,
project: {
id: ProjectV2.ID.global,
name: undefined,
worktree: "/tmp/opencode",
},
}) as Record<string, unknown>
expect(Object.hasOwn(encoded, "parentID")).toBe(false)
expect(Object.hasOwn(encoded.project as Record<string, unknown>, "name")).toBe(false)
})
test("encodes nested undefined optional session fields as omitted keys", () => {
const encoded = Schema.encodeUnknownSync(Session.Info)({
...info,
summary: {
additions: 1,
deletions: 2,
files: 3,
diffs: undefined,
},
revert: {
messageID: MessageID.ascending(),
partID: undefined,
snapshot: undefined,
diff: undefined,
},
}) as Record<string, unknown>
expect(Object.hasOwn(encoded.summary as Record<string, unknown>, "diffs")).toBe(false)
for (const key of ["partID", "snapshot", "diff"]) {
expect(Object.hasOwn(encoded.revert as Record<string, unknown>, key)).toBe(false)
}
})
})

View File

@@ -0,0 +1,248 @@
import { describe, expect } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Deferred, Effect, Exit, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { GlobalBus } from "@/bus/global"
const it = testEffect(
Layer.mergeAll(
SessionNs.layer.pipe(
Layer.provide(Storage.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provideMerge(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
),
CrossSpawnSpawner.defaultLayer,
testInstanceStoreLayer,
),
)
const awaitDeferred = <T>(deferred: Deferred.Deferred<T>, message: string) =>
Effect.race(
Deferred.await(deferred),
Effect.sleep("2 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error(message)))),
)
const remove = (id: SessionID) => SessionNs.use.remove(id)
describe("session.created event", () => {
it.instance("should emit session.created event when session is created", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const events = yield* EventV2Bridge.Service
const received = yield* Deferred.make<SessionNs.Info>()
const unsub = yield* events.listen((event) => {
if (event.type === SessionNs.Event.Created.type)
Deferred.doneUnsafe(
received,
Effect.succeed((event.data as typeof SessionNs.Event.Created.data.Type).info as SessionNs.Info),
)
return Effect.void
})
yield* Effect.addFinalizer(() => unsub)
const info = yield* session.create({})
const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created")
expect(receivedInfo.id).toBe(info.id)
expect(receivedInfo.projectID).toBe(info.projectID)
expect(receivedInfo.directory).toBe(info.directory)
expect(receivedInfo.path).toBe(info.path)
expect(receivedInfo.title).toBe(info.title)
yield* session.remove(info.id)
}),
)
it.instance("session.created event should be emitted before session.updated", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const source = yield* EventV2Bridge.Service
const events: string[] = []
const received = yield* Deferred.make<string[]>()
const push = (event: string) => {
events.push(event)
if (events.includes("created") && events.includes("updated")) {
Deferred.doneUnsafe(received, Effect.succeed(events))
}
}
const unsubscribe = yield* source.listen((event) => {
if (event.type === SessionNs.Event.Created.type) push("created")
if (event.type === SessionNs.Event.Updated.type) push("updated")
return Effect.void
})
yield* Effect.addFinalizer(() => unsubscribe)
const info = yield* session.create({})
yield* session.setTitle({ sessionID: info.id, title: "updated" })
const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events")
expect(receivedEvents).toContain("created")
expect(receivedEvents).toContain("updated")
expect(receivedEvents.indexOf("created")).toBeLessThan(receivedEvents.indexOf("updated"))
yield* session.remove(info.id)
}),
)
it.instance("emits legacy global sync payload", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const received = yield* Deferred.make<{ syncEvent: EventV2.SerializedEvent }>()
const listener = (event: { payload: { type?: string; syncEvent?: EventV2.SerializedEvent } }) => {
if (event.payload.type === "sync" && event.payload.syncEvent)
Deferred.doneUnsafe(received, Effect.succeed({ syncEvent: event.payload.syncEvent }))
}
GlobalBus.on("event", listener)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener)))
const info = yield* session.create({})
const event = yield* awaitDeferred(received, "timed out waiting for legacy global sync event")
expect(event.syncEvent).toMatchObject({
type: EventV2.versionedType(SessionNs.Event.Created.type, 1),
seq: 0,
aggregateID: info.id,
data: { sessionID: info.id },
})
yield* session.remove(info.id)
}),
)
})
describe("step-finish token propagation via event", () => {
it.instance(
"non-zero tokens propagate through PartUpdated event",
() =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const events = yield* EventV2Bridge.Service
const info = yield* session.create({})
const messageID = MessageID.ascending()
yield* session.updateMessage({
id: messageID,
sessionID: info.id,
role: "user",
time: { created: Date.now() },
agent: "user",
model: { providerID: "test", modelID: "test" },
tools: {},
mode: "",
} as unknown as SessionV1.Info)
// Event subscribers receive readonly Schema.Type payloads; `SessionV1.Part`
// is the mutable domain type. Cast bridges the two — safe because the
// test only reads the value afterwards.
const received = yield* Deferred.make<SessionV1.Part>()
const unsub = yield* events.listen((event) => {
if (event.type === MessageV2.Event.PartUpdated.type)
Deferred.doneUnsafe(
received,
Effect.succeed((event.data as typeof MessageV2.Event.PartUpdated.data.Type).part as SessionV1.Part),
)
return Effect.void
})
yield* Effect.addFinalizer(() => unsub)
const tokens = {
total: 1500,
input: 500,
output: 800,
reasoning: 200,
cache: { read: 100, write: 50 },
}
const partInput = {
id: PartID.ascending(),
messageID,
sessionID: info.id,
type: "step-finish" as const,
reason: "stop",
cost: 0.005,
tokens,
}
yield* session.updatePart(partInput)
const receivedPart = yield* awaitDeferred(received, "timed out waiting for message.part.updated")
expect(receivedPart.type).toBe("step-finish")
const finish = receivedPart as SessionV1.StepFinishPart
expect(finish.tokens.input).toBe(500)
expect(finish.tokens.output).toBe(800)
expect(finish.tokens.reasoning).toBe(200)
expect(finish.tokens.total).toBe(1500)
expect(finish.tokens.cache.read).toBe(100)
expect(finish.tokens.cache.write).toBe(50)
expect(finish.cost).toBe(0.005)
expect(receivedPart).not.toBe(partInput)
yield* session.remove(info.id)
}),
{ timeout: 30000 },
)
})
describe("Session", () => {
it.live("remove works without an instance", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const dir = yield* tmpdirScoped({ git: true })
const info = yield* provideInstance(dir)(session.create({ title: "remove-without-instance" }))
const removeExit = yield* remove(info.id).pipe(Effect.exit)
expect(Exit.isSuccess(removeExit)).toBe(true)
const getExit = yield* session.get(info.id).pipe(Effect.exit)
expect(Exit.isFailure(getExit)).toBe(true)
}),
)
it.instance("persists metadata and copies it on fork by default", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const meta = { source: "sdk", trace: { id: "abc" } }
const created = yield* Effect.acquireRelease(session.create({ title: "with-meta", metadata: meta }), (info) =>
session.remove(info.id).pipe(Effect.ignore),
)
const saved = yield* session.get(created.id)
const fork = yield* Effect.acquireRelease(session.fork({ sessionID: created.id }), (info) =>
session.remove(info.id).pipe(Effect.ignore),
)
expect(saved.metadata).toEqual(meta)
expect(fork.metadata).toEqual(meta)
expect(fork.metadata).not.toBe(meta)
}),
)
it.instance("omits metadata when not provided", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const created = yield* Effect.acquireRelease(session.create({ title: "empty-meta" }), (info) =>
session.remove(info.id).pipe(Effect.ignore),
)
const saved = yield* session.get(created.id)
expect(created.metadata).toBeUndefined()
expect(saved.metadata).toBeUndefined()
}),
)
})

View File

@@ -0,0 +1,190 @@
/**
* Reproducer for snapshot race condition with instant tool execution.
*
* When the mock LLM returns a tool call response instantly, the AI SDK
* processes the tool call and executes the tool (e.g. apply_patch) before
* the processor's start-step handler can capture a pre-tool snapshot.
* Both the "before" and "after" snapshots end up with the same git tree
* hash, so computeDiff returns empty and the session summary shows 0 files.
*
* This is a real bug: the snapshot system assumes it can capture state
* before tools run by hooking into start-step, but the AI SDK executes
* tools internally during multi-step processing before emitting events.
*/
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import fs from "fs/promises"
import path from "path"
import { Session } from "@/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionSummary } from "../../src/session/summary"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestLLMServer } from "../lib/llm-server"
import { LSP } from "@/lsp/lsp"
import { MCP } from "../../src/mcp"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { RuntimeFlags } from "@/effect/runtime-flags"
const mcp = Layer.succeed(
MCP.Service,
MCP.Service.of({
status: () => Effect.succeed({}),
clients: () => Effect.succeed({}),
tools: () => Effect.succeed({}),
prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}),
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
connect: () => Effect.void,
disconnect: () => Effect.void,
getPrompt: () => Effect.succeed(undefined),
readResource: () => Effect.succeed(undefined),
startAuth: () => Effect.die("unexpected MCP auth"),
authenticate: () => Effect.die("unexpected MCP auth"),
finishAuth: () => Effect.die("unexpected MCP auth"),
removeAuth: () => Effect.void,
supportsOAuth: () => Effect.succeed(false),
hasStoredTokens: () => Effect.succeed(false),
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
}),
)
const lsp = Layer.succeed(
LSP.Service,
LSP.Service.of({
init: () => Effect.void,
status: () => Effect.succeed([]),
hasClients: () => Effect.succeed(false),
touchFile: () => Effect.void,
diagnostics: () => Effect.succeed({}),
hover: () => Effect.succeed(undefined),
definition: () => Effect.succeed([]),
references: () => Effect.succeed([]),
implementation: () => Effect.succeed([]),
documentSymbol: () => Effect.succeed([]),
workspaceSymbol: () => Effect.succeed([]),
prepareCallHierarchy: () => Effect.succeed([]),
incomingCalls: () => Effect.succeed([]),
outgoingCalls: () => Effect.succeed([]),
}),
)
const root = LayerNode.group([
SessionPrompt.node,
Session.node,
SessionProjector.node,
SessionSummary.node,
Database.node,
CrossSpawnSpawner.node,
LayerNode.make(TestLLMServer.layer, []),
])
const it = testEffect(
LayerNode.buildLayer(root, {
replacements: [
LayerNode.replace(MCP.node, mcp),
LayerNode.replace(LSP.node, lsp),
LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })),
],
}),
)
const providerCfg = (url: string) => ({
provider: {
test: {
name: "Test",
id: "test",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
"test-model": {
id: "test-model",
name: "Test Model",
attachment: false,
reasoning: false,
temperature: false,
tool_call: true,
release_date: "2025-01-01",
limit: { context: 100000, output: 10000 },
cost: { input: 0, output: 0 },
options: {},
},
},
options: {
apiKey: "test-key",
baseURL: url,
},
},
},
})
it.live("tool execution produces non-empty session diff (snapshot race)", () =>
provideTmpdirServer(
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",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
// Use bash tool (always registered) to create a file
const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}`
yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", {
command,
description: "create test file",
})
yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done")
// Seed user message
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "create the file" }],
})
// Run the agent loop
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
// Verify the file was created
const filePath = path.join(dir, "race-test.txt")
const fileExists = yield* Effect.promise(() =>
fs
.access(filePath)
.then(() => true)
.catch(() => false),
)
expect(fileExists).toBe(true)
// Verify the tool call completed (in the first assistant message)
const allMsgs = yield* MessageV2.filterCompactedEffect(session.id)
const user = allMsgs.find(
(msg): msg is SessionV1.WithParts & { info: SessionV1.User } => msg.info.role === "user",
)
const tool = allMsgs
.flatMap((m) => m.parts)
.find((p): p is SessionV1.ToolPart => p.type === "tool" && p.tool === "bash")
expect(tool?.state.status).toBe("completed")
if (!user) throw new Error("Expected user message")
// Poll for the turn diff — summarize() is fire-and-forget.
let diff: Array<{ file?: string }> = []
for (let i = 0; i < 50; i++) {
diff = yield* summary.diff({ sessionID: session.id, messageID: user.info.id })
if (diff.length > 0) break
yield* Effect.sleep("100 millis")
}
expect(diff.length).toBeGreaterThan(0)
}),
{ git: true, config: providerCfg },
),
)

View File

@@ -0,0 +1,235 @@
import { describe, expect, test } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { testEffect } from "../lib/effect"
// Skip tests if no API key is available
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
const it = testEffect(
Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)),
)
const live = hasApiKey ? it.instance : it.instance.skip
describe("StructuredOutput Integration", () => {
live(
"produces structured output with simple schema",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
},
required: ["answer"],
},
retryCount: 0,
},
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
const output = result.info.structured as any
expect(output.answer).toBe(4)
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
live(
"produces structured output with nested objects",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
company: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
},
},
required: ["company"],
},
retryCount: 0,
},
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
live(
"works with text outputFormat (default)",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
live(
"stores outputFormat on user message",
() =>
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
},
required: ["result"],
},
retryCount: 3,
},
})
// Get all messages from session
const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
{ git: true },
60000,
)
test("unit test: StructuredOutputError is properly structured", () => {
const error = new SessionV1.StructuredOutputError({
message: "Failed to produce valid structured output after 3 attempts",
retries: 3,
})
expect(error.name).toBe("StructuredOutputError")
expect(error.data.message).toContain("3 attempts")
expect(error.data.retries).toBe(3)
const obj = error.toObject()
expect(obj.name).toBe("StructuredOutputError")
expect(obj.data.retries).toBe(3)
})
})

View File

@@ -0,0 +1,387 @@
import { describe, expect, test } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Exit, Schema } from "effect"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionID, MessageID } from "../../src/session/schema"
const decodeFormat = Schema.decodeUnknownExit(SessionV1.Format)
const decodeUser = Schema.decodeUnknownExit(SessionV1.User)
const decodeAssistant = Schema.decodeUnknownExit(SessionV1.Assistant)
describe("structured-output.OutputFormat", () => {
test("parses text format", () => {
const result = decodeFormat({ type: "text" })
expect(Exit.isSuccess(result)).toBe(true)
if (Exit.isSuccess(result)) {
expect(result.value.type).toBe("text")
}
})
test("parses json_schema format with defaults", () => {
const result = decodeFormat({
type: "json_schema",
schema: { type: "object", properties: { name: { type: "string" } } },
})
expect(Exit.isSuccess(result)).toBe(true)
if (Exit.isSuccess(result)) {
expect(result.value.type).toBe("json_schema")
if (result.value.type === "json_schema") {
expect(result.value.retryCount).toBe(2) // default value
}
}
})
test("parses json_schema format with custom retryCount", () => {
const result = decodeFormat({
type: "json_schema",
schema: { type: "object" },
retryCount: 5,
})
expect(Exit.isSuccess(result)).toBe(true)
if (Exit.isSuccess(result) && result.value.type === "json_schema") {
expect(result.value.retryCount).toBe(5)
}
})
test("rejects invalid type", () => {
const result = decodeFormat({ type: "invalid" })
expect(Exit.isFailure(result)).toBe(true)
})
test("rejects json_schema without schema", () => {
const result = decodeFormat({ type: "json_schema" })
expect(Exit.isFailure(result)).toBe(true)
})
test("rejects negative retryCount", () => {
const result = decodeFormat({
type: "json_schema",
schema: { type: "object" },
retryCount: -1,
})
expect(Exit.isFailure(result)).toBe(true)
})
})
describe("structured-output.StructuredOutputError", () => {
test("creates error with message and retries", () => {
const error = new SessionV1.StructuredOutputError({
message: "Failed to validate",
retries: 3,
})
expect(error.name).toBe("StructuredOutputError")
expect(error.data.message).toBe("Failed to validate")
expect(error.data.retries).toBe(3)
})
test("converts to object correctly", () => {
const error = new SessionV1.StructuredOutputError({
message: "Test error",
retries: 2,
})
const obj = error.toObject()
expect(obj.name).toBe("StructuredOutputError")
expect(obj.data.message).toBe("Test error")
expect(obj.data.retries).toBe(2)
})
test("isInstance correctly identifies error", () => {
const error = new SessionV1.StructuredOutputError({
message: "Test",
retries: 1,
})
expect(SessionV1.StructuredOutputError.isInstance(error)).toBe(true)
expect(SessionV1.StructuredOutputError.isInstance({ name: "other" })).toBe(false)
})
})
describe("structured-output.UserMessage", () => {
test("user message accepts outputFormat", () => {
const result = decodeUser({
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "user",
time: { created: Date.now() },
agent: "default",
model: { providerID: "anthropic", modelID: "claude-3" },
outputFormat: {
type: "json_schema",
schema: { type: "object" },
},
})
expect(Exit.isSuccess(result)).toBe(true)
})
test("user message works without outputFormat (optional)", () => {
const result = decodeUser({
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "user",
time: { created: Date.now() },
agent: "default",
model: { providerID: "anthropic", modelID: "claude-3" },
})
expect(Exit.isSuccess(result)).toBe(true)
})
})
describe("structured-output.AssistantMessage", () => {
const baseAssistantMessage = {
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "assistant" as const,
parentID: MessageID.ascending(),
modelID: "claude-3",
providerID: "anthropic",
mode: "default",
agent: "default",
path: { cwd: "/test", root: "/test" },
cost: 0.001,
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: Date.now() },
}
test("assistant message accepts structured", () => {
const result = decodeAssistant({
...baseAssistantMessage,
structured: { company: "Anthropic", founded: 2021 },
})
expect(Exit.isSuccess(result)).toBe(true)
if (Exit.isSuccess(result)) {
expect(result.value.structured).toEqual({ company: "Anthropic", founded: 2021 })
}
})
test("assistant message works without structured_output (optional)", () => {
const result = decodeAssistant(baseAssistantMessage)
expect(Exit.isSuccess(result)).toBe(true)
})
})
describe("structured-output.createStructuredOutputTool", () => {
test("creates tool with description", () => {
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object" },
onSuccess: () => {},
})
expect(tool.description).toContain("structured format")
})
test("creates tool with schema as inputSchema", () => {
const schema = {
type: "object",
properties: {
company: { type: "string" },
founded: { type: "number" },
},
required: ["company"],
}
const tool = SessionPrompt.createStructuredOutputTool({
schema,
onSuccess: () => {},
})
// AI SDK wraps schema in { jsonSchema: {...} }
expect(tool.inputSchema).toBeDefined()
const inputSchema = tool.inputSchema as any
expect(inputSchema.jsonSchema?.properties?.company).toBeDefined()
expect(inputSchema.jsonSchema?.properties?.founded).toBeDefined()
})
test("strips $schema property from inputSchema", () => {
const schema = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: { name: { type: "string" } },
}
const tool = SessionPrompt.createStructuredOutputTool({
schema,
onSuccess: () => {},
})
// AI SDK wraps schema in { jsonSchema: {...} }
const inputSchema = tool.inputSchema as any
expect(inputSchema.jsonSchema?.$schema).toBeUndefined()
})
test("execute calls onSuccess with valid args", async () => {
let capturedOutput: unknown
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object", properties: { name: { type: "string" } } },
onSuccess: (output) => {
capturedOutput = output
},
})
expect(tool.execute).toBeDefined()
const testArgs = { name: "Test Company" }
const result = await tool.execute!(testArgs, {
toolCallId: "test-call-id",
messages: [],
abortSignal: undefined as any,
})
expect(capturedOutput).toEqual(testArgs)
expect(result.output).toBe("Structured output captured successfully.")
expect(result.metadata.valid).toBe(true)
})
test("AI SDK validates schema before execute - missing required field", async () => {
// Note: The AI SDK validates the input against the schema BEFORE calling execute()
// So invalid inputs never reach the tool's execute function
// This test documents the expected schema behavior
const tool = SessionPrompt.createStructuredOutputTool({
schema: {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name", "age"],
},
onSuccess: () => {},
})
// The schema requires both 'name' and 'age'
expect(tool.inputSchema).toBeDefined()
const inputSchema = tool.inputSchema as any
expect(inputSchema.jsonSchema?.required).toContain("name")
expect(inputSchema.jsonSchema?.required).toContain("age")
})
test("AI SDK validates schema types before execute - wrong type", async () => {
// Note: The AI SDK validates the input against the schema BEFORE calling execute()
// So invalid inputs never reach the tool's execute function
// This test documents the expected schema behavior
const tool = SessionPrompt.createStructuredOutputTool({
schema: {
type: "object",
properties: {
count: { type: "number" },
},
required: ["count"],
},
onSuccess: () => {},
})
// The schema defines 'count' as a number
expect(tool.inputSchema).toBeDefined()
const inputSchema = tool.inputSchema as any
expect(inputSchema.jsonSchema?.properties?.count?.type).toBe("number")
})
test("execute handles nested objects", async () => {
let capturedOutput: unknown
const tool = SessionPrompt.createStructuredOutputTool({
schema: {
type: "object",
properties: {
user: {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string" },
},
required: ["name"],
},
},
required: ["user"],
},
onSuccess: (output) => {
capturedOutput = output
},
})
// Valid nested object - AI SDK validates before calling execute()
const validResult = await tool.execute!(
{ user: { name: "John", email: "john@test.com" } },
{
toolCallId: "test-call-id",
messages: [],
abortSignal: undefined as any,
},
)
expect(capturedOutput).toEqual({ user: { name: "John", email: "john@test.com" } })
expect(validResult.metadata.valid).toBe(true)
// Verify schema has correct nested structure
const inputSchema = tool.inputSchema as any
expect(inputSchema.jsonSchema?.properties?.user?.type).toBe("object")
expect(inputSchema.jsonSchema?.properties?.user?.properties?.name?.type).toBe("string")
expect(inputSchema.jsonSchema?.properties?.user?.required).toContain("name")
})
test("execute handles arrays", async () => {
let capturedOutput: unknown
const tool = SessionPrompt.createStructuredOutputTool({
schema: {
type: "object",
properties: {
tags: {
type: "array",
items: { type: "string" },
},
},
required: ["tags"],
},
onSuccess: (output) => {
capturedOutput = output
},
})
// Valid array - AI SDK validates before calling execute()
const validResult = await tool.execute!(
{ tags: ["a", "b", "c"] },
{
toolCallId: "test-call-id",
messages: [],
abortSignal: undefined as any,
},
)
expect(capturedOutput).toEqual({ tags: ["a", "b", "c"] })
expect(validResult.metadata.valid).toBe(true)
// Verify schema has correct array structure
const inputSchema = tool.inputSchema as any
expect(inputSchema.jsonSchema?.properties?.tags?.type).toBe("array")
expect(inputSchema.jsonSchema?.properties?.tags?.items?.type).toBe("string")
})
test("toModelOutput returns text value", async () => {
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object" },
onSuccess: () => {},
})
expect(tool.toModelOutput).toBeDefined()
const modelOutput = await Promise.resolve(
tool.toModelOutput!({
toolCallId: "test-call-id",
input: {},
output: {
output: "Test output",
},
}),
)
expect(modelOutput.type).toBe("text")
if (modelOutput.type !== "text") throw new Error("expected text model output")
expect(modelOutput.value).toBe("Test output")
})
// Note: Retry behavior is handled by the AI SDK and the prompt loop, not the tool itself
// The tool simply calls onSuccess when execute() is called with valid args
// See prompt.ts loop() for actual retry logic
})

View File

@@ -0,0 +1,86 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import type { Agent } from "../../src/agent/agent"
import { NamedError } from "@opencode-ai/core/util/error"
import { Skill } from "../../src/skill"
import { Permission } from "../../src/permission"
import { SystemPrompt } from "../../src/session/system"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { testEffect } from "../lib/effect"
const skills: Skill.Info[] = [
{
name: "zeta-skill",
description: "Zeta skill.",
location: "/tmp/zeta-skill/SKILL.md",
content: "# zeta-skill",
},
{
name: "alpha-skill",
description: "Alpha skill.",
location: "/tmp/alpha-skill/SKILL.md",
content: "# alpha-skill",
},
{
name: "middle-skill",
description: "Middle skill.",
location: "/tmp/middle-skill/SKILL.md",
content: "# middle-skill",
},
{
name: "manual-skill",
location: "/tmp/manual-skill/SKILL.md",
content: "# manual-skill",
},
]
const build: Agent.Info = {
name: "build",
mode: "primary",
permission: Permission.fromConfig({ "*": "allow" }),
options: {},
}
const it = testEffect(
SystemPrompt.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(
Layer.succeed(
Skill.Service,
Skill.Service.of({
get: (name) => Effect.succeed(skills.find((skill) => skill.name === name)),
require: (name) => {
const info = skills.find((skill) => skill.name === name)
if (info) return Effect.succeed(info)
return Effect.fail(new Skill.NotFoundError({ name, available: skills.map((skill) => skill.name) }))
},
all: () => Effect.succeed(skills),
dirs: () => Effect.succeed([]),
available: () => Effect.succeed(skills),
}),
),
),
),
)
describe("session.system", () => {
it.effect("skills output is sorted by name and stable across calls", () =>
Effect.gen(function* () {
const prompt = yield* SystemPrompt.Service
const first = yield* prompt.skills(build)
const second = yield* prompt.skills(build)
const output = first ?? (yield* Effect.fail(new NamedError.Unknown({ message: "missing skills output" })))
expect(first).toBe(second)
const alpha = output.indexOf("<name>alpha-skill</name>")
const middle = output.indexOf("<name>middle-skill</name>")
const zeta = output.indexOf("<name>zeta-skill</name>")
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)
expect(zeta).toBeGreaterThan(middle)
expect(output).not.toContain("manual-skill")
}),
)
})