feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
1834
packages/opencode/test/session/compaction.test.ts
Normal file
1834
packages/opencode/test/session/compaction.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
256
packages/opencode/test/session/instruction.test.ts
Normal file
256
packages/opencode/test/session/instruction.test.ts
Normal 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 }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
433
packages/opencode/test/session/llm-native-recorded.test.ts
Normal file
433
packages/opencode/test/session/llm-native-recorded.test.ts
Normal 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))
|
||||
}
|
||||
})
|
||||
760
packages/opencode/test/session/llm-native.test.ts
Normal file
760
packages/opencode/test/session/llm-native.test.ts
Normal 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" }),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
1932
packages/opencode/test/session/llm.test.ts
Normal file
1932
packages/opencode/test/session/llm.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1661
packages/opencode/test/session/message-v2.test.ts
Normal file
1661
packages/opencode/test/session/message-v2.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1056
packages/opencode/test/session/messages-pagination.test.ts
Normal file
1056
packages/opencode/test/session/messages-pagination.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1076
packages/opencode/test/session/processor-effect.test.ts
Normal file
1076
packages/opencode/test/session/processor-effect.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2326
packages/opencode/test/session/prompt.test.ts
Normal file
2326
packages/opencode/test/session/prompt.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
439
packages/opencode/test/session/retry.test.ts
Normal file
439
packages/opencode/test/session/retry.test.ts
Normal 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.",
|
||||
})
|
||||
})
|
||||
})
|
||||
639
packages/opencode/test/session/revert-compact.test.ts
Normal file
639
packages/opencode/test/session/revert-compact.test.ts
Normal 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 },
|
||||
),
|
||||
)
|
||||
})
|
||||
313
packages/opencode/test/session/schema-decoding.test.ts
Normal file
313
packages/opencode/test/session/schema-decoding.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
78
packages/opencode/test/session/session-schema.test.ts
Normal file
78
packages/opencode/test/session/session-schema.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
248
packages/opencode/test/session/session.test.ts
Normal file
248
packages/opencode/test/session/session.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
190
packages/opencode/test/session/snapshot-tool-race.test.ts
Normal file
190
packages/opencode/test/session/snapshot-tool-race.test.ts
Normal 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 },
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
387
packages/opencode/test/session/structured-output.test.ts
Normal file
387
packages/opencode/test/session/structured-output.test.ts
Normal 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
|
||||
})
|
||||
86
packages/opencode/test/session/system.test.ts
Normal file
86
packages/opencode/test/session/system.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user