Compare commits

..

8 Commits

Author SHA1 Message Date
Kit Langton 16d8545e72 test: migrate config agent fixtures 2026-05-18 14:31:46 -04:00
Kit Langton 4828443997 test: migrate config template fixtures 2026-05-18 14:31:43 -04:00
Kit Langton c8c01f5275 test: migrate simple config fixtures 2026-05-18 14:31:37 -04:00
James Long ff9d7cab5c fix(core): fix file references in workspaces (#28209) 2026-05-18 18:23:35 +00:00
Kit Langton 88681d389b Migrate provider lookup tests to instance fixtures
Migrate provider env precedence, model lookup, and default model tests to Effect-aware instance fixtures while keeping behavior unchanged.
2026-05-18 18:01:53 +00:00
Kit Langton ae2ecd1ed3 Migrate custom provider tests to instance fixtures
Migrate the next custom provider/model config tests to Effect-aware instance fixtures while keeping timing neutral and behavior unchanged.
2026-05-18 17:49:26 +00:00
Kit Langton 159d271e1e refactor(sync): publish via EffectBridge.fork for codebase consistency (#28187) 2026-05-18 13:30:41 -04:00
Kit Langton 762850dfe5 Migrate provider config tests to instance fixtures
Convert the first provider env/config/filtering tests to Effect-aware instance fixtures while keeping behavior unchanged and documenting neutral timing.
2026-05-18 13:22:59 -04:00
7 changed files with 510 additions and 670 deletions
@@ -1,12 +1,14 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const project = useProject()
const [store] = createStore({
filter: "",
@@ -17,6 +19,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
async () => {
const result = await sdk.client.find.files({
query: store.filter,
workspace: project.workspace.current(),
})
if (result.error) return []
const sliced = (result.data ?? []).slice(0, 5)
@@ -6,6 +6,7 @@ import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useEditorContext } from "@tui/context/editor"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
@@ -85,6 +86,7 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const project = useProject()
const command = useCommandPalette()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
@@ -382,6 +384,7 @@ export function Autocomplete(props: {
// Get files from SDK
const result = await sdk.client.find.files({
query: baseQuery,
workspace: project.workspace.current(),
})
const options: AutocompleteOption[] = []
+39 -53
View File
@@ -7,9 +7,7 @@ import { eq } from "drizzle-orm"
import { GlobalBus } from "@/bus/global"
import { Bus as ProjectBus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import type { InstanceContext } from "@/project/instance-context"
import { EventSequenceTable, EventTable } from "./event.sql"
import type { WorkspaceID } from "@/control-plane/schema"
import { EventID } from "./schema"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import type { DeepMutable } from "@opencode-ai/core/schema"
@@ -17,7 +15,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { attachWith } from "@/effect/run-service"
import { EffectBridge } from "@/effect/bridge"
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
// when writing to the database. Bus payloads (`Properties`) stay readonly —
@@ -51,10 +49,6 @@ export type SerializedEvent<Def extends Definition = Definition> = Event<Def> &
type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
type PublishContext = {
instance?: InstanceContext
workspace?: WorkspaceID
}
export interface Interface {
readonly run: <Def extends Definition>(
@@ -107,16 +101,14 @@ export const layer = Layer.effect(Service)(
}
const publish = !!options?.publish
const context = publish
? {
instance: yield* InstanceState.context,
workspace: yield* InstanceState.workspaceID,
}
: undefined
// Bridge captures handler-fiber refs (InstanceRef/WorkspaceRef) and the
// full Effect context, so the forked publish + GlobalBus emit run with
// the right state without a per-call attachWith.
const bridge = yield* EffectBridge.make()
process(def, event, {
bus,
bridge,
publish,
context,
ownerID: options?.ownerID,
experimentalWorkspaces: flags.experimentalWorkspaces,
})
@@ -154,12 +146,7 @@ export const layer = Layer.effect(Service)(
}
const { publish = true } = options || {}
const context = publish
? {
instance: yield* InstanceState.context,
workspace: yield* InstanceState.workspaceID,
}
: undefined
const bridge = yield* EffectBridge.make()
// Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing
@@ -175,7 +162,7 @@ export const layer = Layer.effect(Service)(
const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data }
process(def, event, { bus, publish, context, experimentalWorkspaces: flags.experimentalWorkspaces })
process(def, event, { bus, bridge, publish, experimentalWorkspaces: flags.experimentalWorkspaces })
},
{
behavior: "immediate",
@@ -308,8 +295,8 @@ function process<Def extends Definition>(
event: Event<Def>,
options: {
bus: ProjectBus.Interface
bridge: EffectBridge.Shape
publish: boolean
context?: PublishContext
ownerID?: string
experimentalWorkspaces: boolean
},
@@ -351,37 +338,36 @@ function process<Def extends Definition>(
}
Database.effect(() => {
if (options?.publish) {
if (!options.context?.instance) {
throw new Error("SyncEvent.process: publish requires instance context")
}
const result = convertEvent(def.type, event.data)
const publish = (data: unknown) =>
Effect.runPromise(
attachWith(options.bus.publish(def, data as Properties<Def>, { id: event.id }), {
instance: options.context?.instance,
workspace: options.context?.workspace,
}),
)
if (result instanceof Promise) {
void result.then(publish)
} else {
void publish(result)
}
GlobalBus.emit("event", {
directory: options.context.instance.directory,
project: options.context.instance.project.id,
workspace: options.context.workspace,
payload: {
type: "sync",
syncEvent: {
type: versionedType(def.type, def.version),
...event,
},
},
})
if (!options.publish) return
const result = convertEvent(def.type, event.data)
// The bridge was built inside the caller's fiber so it already carries
// InstanceRef/WorkspaceRef and the full Effect context. Both the bus
// publish and the GlobalBus emit run inside the forked Effect so they
// share the same instance/workspace lookup.
const publish = (data: unknown) =>
options.bridge.fork(
Effect.gen(function* () {
yield* options.bus.publish(def, data as Properties<Def>, { id: event.id })
const instance = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: instance.directory,
project: instance.project.id,
workspace,
payload: {
type: "sync",
syncEvent: {
type: versionedType(def.type, def.version),
...event,
},
},
})
}),
)
if (result instanceof Promise) {
void result.then(publish)
} else {
publish(result)
}
})
})
+270 -388
View File
@@ -1,5 +1,5 @@
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { Effect, Exit, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "@/config/config"
import { ConfigManaged } from "@/config/managed"
@@ -13,7 +13,7 @@ import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Env } from "../../src/env"
import { provideTestInstance, provideTmpdirInstance, withTestInstance } from "../fixture/fixture"
import { provideTestInstance, provideTmpdirInstance, TestInstance, withTestInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -94,14 +94,6 @@ const listDirs = (ctx: InstanceContext) =>
Effect.provide(layer),
),
)
const ready = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -123,6 +115,25 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
}
const writeConfigEffect = (dir: string, config: object, name = "opencode.json") =>
Effect.promise(() => writeConfig(dir, config, name))
function withProcessEnv<A, E, R>(key: string, value: string, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const original = process.env[key]
process.env[key] = value
return original
}),
() => effect,
(original) =>
Effect.sync(() => {
if (original !== undefined) process.env[key] = original
else delete process.env[key]
}),
)
}
async function check(map: (dir: string) => string) {
if (process.platform !== "win32") return
await using globalTmp = await tmpdir()
@@ -210,43 +221,24 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
}
})
test("loads JSON config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
model: "test/model",
username: "testuser",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
})
})
it.instance(
"loads JSON config file",
Effect.gen(function* () {
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
}),
{ config: { model: "test/model", username: "testuser" } },
)
test("loads shell config field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
shell: "bash",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.shell).toBe("bash")
},
})
})
it.instance(
"loads shell config field",
Effect.gen(function* () {
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.shell).toBe("bash")
}),
{ config: { shell: "bash" } },
)
test("updates config and preserves empty shell sentinel", async () => {
await using tmp = await tmpdir({
@@ -330,41 +322,23 @@ test("updates global config and omits empty shell key in jsonc", async () => {
}
})
test("loads formatter boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
formatter: true,
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.formatter).toBe(true)
},
})
})
it.instance(
"loads formatter boolean config",
Effect.gen(function* () {
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.formatter).toBe(true)
}),
{ config: { formatter: true } },
)
test("loads lsp boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
lsp: true,
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.lsp).toBe(true)
},
})
})
it.instance(
"loads lsp boolean config",
Effect.gen(function* () {
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.lsp).toBe(true)
}),
{ config: { lsp: true } },
)
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
@@ -405,124 +379,118 @@ test("ignores legacy tui keys in opencode config", async () => {
})
})
test("loads JSONC config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.jsonc"),
it.instance("loads JSONC config file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Filesystem.write(
path.join(test.directory, "opencode.jsonc"),
`{
// This is a comment
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"username": "testuser"
}`,
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
})
})
),
)
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
}),
)
test("jsonc overrides json in the same directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(
dir,
{
$schema: "https://opencode.ai/config.json",
model: "base",
username: "base",
},
"opencode.jsonc",
)
await writeConfig(dir, {
it.instance("jsonc overrides json in the same directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(
test.directory,
{
$schema: "https://opencode.ai/config.json",
model: "override",
model: "base",
username: "base",
},
"opencode.jsonc",
)
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
model: "override",
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.model).toBe("base")
expect(config.username).toBe("base")
}),
)
it.instance("handles environment variable substitution", () =>
withProcessEnv(
"TEST_VAR",
"test-user",
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
username: "{env:TEST_VAR}",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("base")
expect(config.username).toBe("base")
},
})
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.username).toBe("test-user")
}),
),
)
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test-user"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{env:TEST_VAR}",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test-user")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["TEST_VAR"] = originalEnv
} else {
delete process.env["TEST_VAR"]
}
}
})
test("preserves env variables when adding $schema to config", async () => {
const originalEnv = process.env["PRESERVE_VAR"]
process.env["PRESERVE_VAR"] = "secret_value"
try {
await using tmp = await tmpdir({
init: async (dir) => {
// Config without $schema - should trigger auto-add
await Filesystem.write(
path.join(dir, "opencode.json"),
it.instance("preserves env variables when adding $schema to config", () =>
withProcessEnv(
"PRESERVE_VAR",
"secret_value",
Effect.gen(function* () {
const test = yield* TestInstance
// Config without $schema - should trigger auto-add
yield* Effect.promise(() =>
Filesystem.write(
path.join(test.directory, "opencode.json"),
JSON.stringify({
username: "{env:PRESERVE_VAR}",
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("secret_value")
),
)
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
expect(content).toContain("{env:PRESERVE_VAR}")
expect(content).not.toContain("secret_value")
expect(content).toContain("$schema")
},
// Read the file to verify the env variable was preserved
const content = yield* Effect.promise(() => Filesystem.readText(path.join(test.directory, "opencode.json")))
expect(content).toContain("{env:PRESERVE_VAR}")
expect(content).not.toContain("secret_value")
expect(content).toContain("$schema")
}),
),
)
it.instance("handles file inclusion substitution", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "included.txt"), "test-user"))
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.txt}",
})
} finally {
if (originalEnv !== undefined) {
process.env["PRESERVE_VAR"] = originalEnv
} else {
delete process.env["PRESERVE_VAR"]
}
}
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.username).toBe("test-user")
}),
)
it.instance("handles file inclusion with replacement tokens", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Filesystem.write(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`"),
)
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.md}",
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.username).toBe("const out = await Bun.$`echo hi`")
}),
)
test("resolves env templates in account config with account token", async () => {
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
@@ -589,218 +557,132 @@ test("resolves env templates in account config with account token", async () =>
}
})
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "included.txt"), "test-user")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.txt}",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test-user")
},
})
})
it.instance("validates config schema and throws on invalid fields", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
invalid_field: "should cause error",
})
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
test("handles file inclusion with replacement tokens", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.md}",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("const out = await Bun.$`echo hi`")
},
})
})
it.instance("throws error for invalid JSON", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }"))
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
test("validates config schema and throws on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
invalid_field: "should cause error",
})
},
})
await provideTestInstance({
directory: tmp.path,
fn: async (ctx) => {
// Strict schema should throw an error for invalid fields
await expect(load(ctx)).rejects.toThrow()
},
})
})
test("throws error for invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
},
})
await provideTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await expect(load(ctx)).rejects.toThrow()
},
})
})
test("handles agent configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "test/model",
temperature: 0.7,
description: "test agent",
},
},
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({
it.instance("handles agent configuration", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "test/model",
temperature: 0.7,
description: "test agent",
}),
)
},
})
})
test("treats agent variant as model-scoped setting (not provider option)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "openai/gpt-5.2",
variant: "xhigh",
max_tokens: 123,
},
},
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh")
expect(agent?.options).toMatchObject({
max_tokens: 123,
})
expect(agent?.options).not.toHaveProperty("variant")
},
})
})
test("handles command configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
command: {
test_command: {
template: "test template",
description: "test command",
agent: "test_agent",
},
},
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.command?.["test_command"]).toEqual({
template: "test template",
description: "test command",
agent: "test_agent",
})
},
})
})
test("migrates autoshare to share field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
autoshare: true,
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true)
},
})
})
test("migrates mode field to agent field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mode: {
test_mode: {
model: "test/model",
temperature: 0.5,
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test_mode"]).toEqual({
},
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({
model: "test/model",
temperature: 0.5,
mode: "primary",
options: {},
permission: {},
})
},
})
})
temperature: 0.7,
description: "test agent",
}),
)
}),
)
it.instance("treats agent variant as model-scoped setting (not provider option)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "openai/gpt-5.2",
variant: "xhigh",
max_tokens: 123,
},
},
})
const config = yield* Config.Service.use((svc) => svc.get())
const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh")
expect(agent?.options).toMatchObject({
max_tokens: 123,
})
expect(agent?.options).not.toHaveProperty("variant")
}),
)
it.instance("handles command configuration", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
command: {
test_command: {
template: "test template",
description: "test command",
agent: "test_agent",
},
},
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.command?.["test_command"]).toEqual({
template: "test template",
description: "test command",
agent: "test_agent",
})
}),
)
it.instance("migrates autoshare to share field", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
autoshare: true,
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true)
}),
)
it.instance("migrates mode field to agent field", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
mode: {
test_mode: {
model: "test/model",
temperature: 0.5,
},
},
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.agent?.["test_mode"]).toEqual({
model: "test/model",
temperature: 0.5,
mode: "primary",
options: {},
permission: {},
})
}),
)
test("loads config from .opencode directory", async () => {
await using tmp = await tmpdir({
+150 -227
View File
@@ -87,10 +87,6 @@ async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceC
return run(ctx, (provider) => provider.getModel(providerID, modelID))
}
async function getLanguage(model: Provider.Model, ctx: InstanceContext) {
return run(ctx, (provider) => provider.getLanguage(model))
}
async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) {
return run(ctx, (provider) => provider.closest(providerID, query))
}
@@ -99,10 +95,6 @@ async function getSmallModel(providerID: ProviderID, ctx: InstanceContext) {
return run(ctx, (provider) => provider.getSmallModel(providerID))
}
async function defaultModel(ctx: InstanceContext) {
return run(ctx, (provider) => provider.defaultModel())
}
function paid(providers: Awaited<ReturnType<typeof list>>) {
const item = providers[ProviderID.make("opencode")]
expect(item).toBeDefined()
@@ -228,81 +220,65 @@ it.instance(
},
)
test("custom model alias via config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
models: {
"my-alias": {
id: "claude-sonnet-4-20250514",
name: "My Custom Alias",
},
},
it.instance(
"custom model alias via config",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
}),
{
config: {
provider: {
anthropic: {
models: {
"my-alias": {
id: "claude-sonnet-4-20250514",
name: "My Custom Alias",
},
},
}),
)
},
},
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
},
})
})
},
)
test("custom provider with npm package", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"custom-model": {
name: "Custom Model",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
it.instance(
"custom provider with npm package",
Effect.gen(function* () {
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
}),
{
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"custom-model": {
name: "Custom Model",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
}),
)
options: {
apiKey: "custom-key",
},
},
},
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const providers = await list(ctx)
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
},
})
})
},
)
it.instance(
"filters alpha provider models by default",
@@ -324,123 +300,95 @@ experimentalModels.instance(
{ config: alphaProviderConfig },
)
test("custom DeepSeek openai-compatible model defaults interleaved reasoning field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": {
name: "DeepSeek R1",
},
"deepseek-details": {
name: "DeepSeek Details",
interleaved: { field: "reasoning_details" },
},
"custom-model": {
name: "Custom Model",
},
},
options: {
apiKey: "custom-key",
},
it.instance(
"custom DeepSeek openai-compatible model defaults interleaved reasoning field",
Effect.gen(function* () {
const providers = yield* Provider.Service.use((provider) => provider.list())
const provider = providers[ProviderID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
expect(providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe(
false,
)
}),
{
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": {
name: "DeepSeek R1",
},
"custom-anthropic-provider": {
name: "Custom Anthropic Provider",
npm: "@ai-sdk/anthropic",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": {
name: "DeepSeek R1",
},
},
options: {
apiKey: "custom-key",
},
"deepseek-details": {
name: "DeepSeek Details",
interleaved: { field: "reasoning_details" },
},
"custom-model": {
name: "Custom Model",
},
},
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const providers = await list(ctx)
const provider = providers[ProviderID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
expect(
providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved,
).toBe(false)
},
})
})
test("env variable takes precedence, config merges options", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
options: {
timeout: 60000,
chunkTimeout: 15000,
},
options: {
apiKey: "custom-key",
},
},
"custom-anthropic-provider": {
name: "Custom Anthropic Provider",
npm: "@ai-sdk/anthropic",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": {
name: "DeepSeek R1",
},
},
}),
)
options: {
apiKey: "custom-key",
},
},
},
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await set(ctx, "ANTHROPIC_API_KEY", "env-api-key")
const providers = await list(ctx)
expect(providers[ProviderID.anthropic]).toBeDefined()
// Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
},
})
})
},
)
test("getModel returns model for valid provider/model", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
it.instance(
"env variable takes precedence, config merges options",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "env-api-key")
const providers = yield* Provider.Service.use((provider) => provider.list())
expect(providers[ProviderID.anthropic]).toBeDefined()
// Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
}),
{
config: {
provider: {
anthropic: {
options: {
timeout: 60000,
chunkTimeout: 15000,
},
},
},
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
const language = await getLanguage(model, ctx)
expect(language).toBeDefined()
},
})
})
},
)
it.instance("getModel returns model for valid provider/model", () =>
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
const language = yield* provider.getLanguage(model)
expect(language).toBeDefined()
}),
)
test("getModel throws ModelNotFoundError for invalid model", async () => {
await using tmp = await tmpdir({
@@ -493,50 +441,25 @@ test("parseModel handles model IDs with slashes", () => {
expect(String(result.modelID)).toBe("anthropic/claude-3-opus")
})
test("defaultModel returns first available model when no config set", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel(ctx)
expect(model.providerID).toBeDefined()
expect(model.modelID).toBeDefined()
},
})
})
it.instance("defaultModel returns first available model when no config set", () =>
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const model = yield* Provider.Service.use((provider) => provider.defaultModel())
expect(model.providerID).toBeDefined()
expect(model.modelID).toBeDefined()
}),
)
test("defaultModel respects config model setting", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: "anthropic/claude-sonnet-4-20250514",
}),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
const model = await defaultModel(ctx)
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
},
})
})
it.instance(
"defaultModel respects config model setting",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const model = yield* Provider.Service.use((provider) => provider.defaultModel())
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
}),
{ config: { model: "anthropic/claude-sonnet-4-20250514" } },
)
it.instance(
"provider with baseURL from config",
+40 -2
View File
@@ -1,14 +1,15 @@
import { describe, expect, beforeEach, afterAll } from "bun:test"
import { provideTmpdirInstance } from "../fixture/fixture"
import { Effect, Layer, Schema } from "effect"
import { Deferred, Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { SyncEvent } from "../../src/sync"
import { Database, eq } from "@/storage/db"
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { MessageID } from "../../src/session/schema"
import { initProjectors } from "../../src/server/projectors"
import { testEffect } from "../lib/effect"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
const it = testEffect(
@@ -139,6 +140,43 @@ describe("SyncEvent", () => {
}),
),
)
// Regression for the EffectBridge migration. GlobalBus.emit used to fire
// synchronously inside the Database.effect post-commit callback. After the
// migration it fires inside the forked publish Effect, AFTER bus.publish
// completes. Consumers don't care about microsecond-level ordering, but
// we still need to prove the emit actually fires.
it.live(
"emits sync events to GlobalBus after publishing to ProjectBus",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
// Filter for OUR specific event in the handler so we ignore any
// stray sync events from other tests' lingering forks.
const received = yield* Deferred.make<GlobalEvent>()
const handler = (evt: GlobalEvent) => {
if (evt.payload?.type === "sync" && evt.payload?.syncEvent?.type === "item.created.1") {
Deferred.doneUnsafe(received, Effect.succeed(evt))
}
}
GlobalBus.on("event", handler)
try {
yield* SyncEvent.use.run(Created, { id: "evt_global_1", name: "global" })
const event = yield* awaitWithTimeout(
Deferred.await(received),
"timed out waiting for sync event on GlobalBus",
"2 seconds",
)
expect(event.payload).toMatchObject({
type: "sync",
syncEvent: { type: "item.created.1", data: { id: "evt_global_1", name: "global" } },
})
} finally {
GlobalBus.off("event", handler)
}
}),
),
)
})
describe("replay", () => {
+5
View File
@@ -69,6 +69,11 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
| HTTP listen PTY ticket tests restart the same listener topology twice | Folded directory-scoped ticket regression into the broader unsafe-ticket test | 7.051s | 6.170s | keep | Two targeted reruns passed after the change: 6.76s, 6.17s; still covers mint failure and successful same-directory upgrade. |
| File watcher readiness can write before async native subscriptions are active | Retried short readiness writes and accepted symlink-realpath HEAD events | failed | 4.62s | keep | Three sequential focused watcher runs passed: 4.62s, 4.57s, 4.64s; full suite no longer failed in `watcher.test.ts`. |
| First provider config/env/filtering block can use Effect-aware instance fixtures | Migrated six `tmpdir` + `withTestInstance` cases to `it.instance` | 6.06s | 6.07s | keep | Neutral timing, but removes manual config file writes and instance plumbing; use as the pattern for later provider slices. |
| Custom provider/model config cases can use Effect-aware instance fixtures | Migrated three more config-heavy provider cases to `it.instance` | 6.07s | 6.12s | keep | Neutral timing within noise, but continues removing manual config file writes on top of the first provider fixture PR. |
| Provider env precedence and model lookup cases can use Effect-aware instance fixtures | Migrated four more provider lookup/default-model cases to `it.instance` | 6.12s | 6.36s | keep | Noisy 5-run median; kept as a small stacked cleanup slice but do not claim speedup from this migration. |
| Simple config load cases can use Effect-aware instance fixtures | Migrated JSON, shell, formatter, and lsp config load cases to `it.instance` | 14.18s | 3.93s | keep | Three-run medians before/after; removes manual `tmpdir` + `withTestInstance` setup from the first simple config block. |
| Config template, file include, and simple agent cases can use Effect-aware instance fixtures | Migrated JSONC, env/file substitution, invalid config, and agent config cases to `it.instance` | 1.87s | 1.90s | keep | Stacked on the first config slice; neutral timing but removes more manual `tmpdir` + instance plumbing. |
| Agent option, command, and legacy migration config cases can use Effect-aware instance fixtures | Migrated agent variant, command, autoshare, and mode migration cases to `it.instance` | 1.90s | 1.83s | keep | Stacked on the config template slice; small neutral-to-positive timing and less manual setup. |
## Profiling Results