| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517 |
- import path from "path"
- import fs from "fs/promises"
- import { describe, expect } from "bun:test"
- import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
- import { FastCheck } from "effect/testing"
- import { Config } from "@opencode-ai/core/config"
- import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
- import { ConfigModel } from "@opencode-ai/schema/config/model"
- import { ConfigProvider } from "@opencode-ai/schema/config/provider"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { Credential } from "@opencode-ai/core/credential"
- import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
- import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
- import { FSUtil } from "@opencode-ai/util/fs-util"
- import { Watcher } from "@opencode-ai/core/filesystem/watcher"
- import { Bus } from "@opencode-ai/core/bus"
- import { Global } from "@opencode-ai/util/global"
- import { Location } from "@opencode-ai/core/location"
- import { Project } from "@opencode-ai/core/project"
- import { Provider } from "@opencode-ai/core/provider"
- import { AbsolutePath } from "@opencode-ai/core/schema"
- import { WellKnown } from "@opencode-ai/core/wellknown"
- import { Integration } from "@opencode-ai/schema/integration"
- import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
- import { location } from "../fixture/location"
- import { tmpdir } from "../fixture/tmpdir"
- import { testEffect } from "../lib/effect"
- const it = testEffect(Layer.empty)
- const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
- function testLayer(
- directory: string,
- globalDirectory = path.join(directory, "global"),
- projectDirectory = directory,
- vcs?: Project.Vcs,
- watcher: Layer.Layer<Watcher.Service | Watcher.Test> = Watcher.testLayer,
- credentialNode = emptyCredentialNode,
- wellknownNode = emptyWellknownNode,
- options?: Config.Options,
- ) {
- const locationLayer = Layer.succeed(
- Location.Service,
- Location.Service.of(
- location(
- { directory: AbsolutePath.make(directory) },
- { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
- ),
- ),
- )
- const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
- [Config.node, Config.configured(options)],
- [Location.node, locationLayer],
- [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
- [Credential.node, credentialNode],
- [WellKnown.node, wellknownNode],
- [Watcher.node, watcher],
- ])
- // Merge the watcher layer by reference so Watcher.Test resolves to the same
- // memoized instance the built graph uses.
- return Layer.mergeAll(built, watcher)
- }
- const provider = {
- package: "native",
- settings: {},
- headers: {},
- body: {},
- models: {},
- }
- describe("Config", () => {
- it.live("loads explicit file and content overrides in priority order", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) => {
- const global = path.join(tmp.path, "global")
- const project = path.join(tmp.path, "project")
- const explicit = path.join(tmp.path, "custom.json")
- return Effect.promise(async () => {
- await fs.mkdir(global, { recursive: true })
- await fs.mkdir(project, { recursive: true })
- await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
- await fs.writeFile(explicit, JSON.stringify({ shell: "explicit" }))
- await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
- }).pipe(
- Effect.andThen(
- Effect.gen(function* () {
- const config = yield* Config.Service
- const entries = yield* config.entries()
- expect(
- entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
- ).toEqual(["global", "explicit", "project", "content"])
- expect(Config.latest(entries, "shell")).toBe("content")
- }).pipe(
- Effect.provide(
- testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
- file: explicit,
- content: JSON.stringify({ shell: "content" }),
- }),
- ),
- ),
- ),
- )
- }),
- ),
- )
- it.live("skips project configuration when project discovery is disabled", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) => {
- const global = path.join(tmp.path, "global")
- const project = path.join(tmp.path, "project")
- return Effect.promise(async () => {
- await fs.mkdir(global, { recursive: true })
- await fs.mkdir(project, { recursive: true })
- await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
- await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
- }).pipe(
- Effect.andThen(
- Effect.gen(function* () {
- const config = yield* Config.Service
- expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
- }).pipe(
- Effect.provide(
- testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
- project: false,
- }),
- ),
- ),
- ),
- )
- }),
- ),
- )
- it.live("reloads external config and publishes directory updates", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- const global = path.join(tmp.path, "global")
- const project = path.join(tmp.path, "project")
- const file = path.join(global, "opencode.json")
- yield* Effect.promise(async () => {
- await fs.mkdir(global, { recursive: true })
- await fs.mkdir(project, { recursive: true })
- await fs.writeFile(file, JSON.stringify({ shell: "first" }))
- })
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const bus = yield* Bus.Service
- const watcher = yield* Watcher.Test
- const changed = yield* bus
- .subscribe(Event.Updated)
- .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
- yield* Effect.sleep("10 millis")
- yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
- yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
- yield* watcher.emit({ type: "update", path: file })
- expect(yield* Fiber.join(changed)).toHaveLength(1)
- expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
- }).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
- }),
- ),
- ),
- )
- it.live("exposes filesystem updates under config roots through changes", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- const global = path.join(tmp.path, "global")
- const project = path.join(tmp.path, "project")
- yield* Effect.promise(async () => {
- await fs.mkdir(path.join(global, "commands"), { recursive: true })
- await fs.mkdir(project, { recursive: true })
- })
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const watcher = yield* Watcher.Test
- const received = yield* config
- .changes()
- .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
- yield* Effect.sleep("10 millis")
- const file = path.join(global, "commands", "review.md")
- yield* watcher.emit({ type: "update", path: file })
- const collected = yield* Fiber.join(received).pipe(Effect.timeout("1 second"))
- expect(Array.from(collected)).toEqual([{ type: "update", path: file }])
- }).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
- }),
- ),
- ),
- )
- // Real watcher on purpose: the regression this pins (a deleted config file's
- // watch being torn down, making recreation invisible) only reproduces with
- // path-faithful event delivery.
- it.live("keeps watching a deleted config file so recreating it reloads", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- const global = path.join(tmp.path, "global")
- const project = path.join(tmp.path, "project")
- const file = path.join(project, "opencode.json")
- yield* Effect.promise(async () => {
- await fs.mkdir(global, { recursive: true })
- await fs.mkdir(project, { recursive: true })
- await fs.writeFile(file, JSON.stringify({ shell: "one" }))
- })
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const bus = yield* Bus.Service
- expect(Config.latest(yield* config.entries(), "shell")).toBe("one")
- yield* Effect.sleep("10 millis")
- const removed = yield* bus
- .subscribe(Event.Updated)
- .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
- yield* Effect.promise(() => fs.rm(file))
- yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds"))
- expect(Config.latest(yield* config.entries(), "shell")).toBeUndefined()
- const recreated = yield* bus
- .subscribe(Event.Updated)
- .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
- yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "two" })))
- yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds"))
- expect(Config.latest(yield* config.entries(), "shell")).toBe("two")
- }).pipe(
- Effect.provide(
- AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
- [
- Location.node,
- Layer.succeed(
- Location.Service,
- Location.Service.of(location({ directory: AbsolutePath.make(project) })),
- ),
- ],
- [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
- [Credential.node, emptyCredentialNode],
- [WellKnown.node, emptyWellknownNode],
- ]),
- ),
- )
- }),
- ),
- ),
- )
- it.effect("backs Config.Service and Config.Test with one shared test implementation", () =>
- Effect.gen(function* () {
- const config = yield* Config.Service
- const test = yield* Config.Test
- expect(yield* config.entries()).toEqual([])
- const entry = new Document({ type: "document", info: new Info({}) })
- yield* test.setEntries([entry])
- expect(yield* config.entries()).toEqual([entry])
- const received = yield* config
- .changes()
- .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
- yield* Effect.yieldNow
- yield* test.emitChange({ type: "create", path: "/root/commands/review.md" })
- expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "create", path: "/root/commands/review.md" }])
- }).pipe(Effect.provide(Config.testLayer())),
- )
- it.effect("returns the latest defined scalar from priority-ordered documents", () =>
- Effect.sync(() => {
- const entries = [
- new Document({
- type: "document",
- info: new Info({ model: selection("openrouter/openai/gpt-5") }),
- }),
- new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
- new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
- new Document({ type: "document", info: new Info({}) }),
- new Document({
- type: "document",
- info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
- }),
- ]
- expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
- expect(Config.latest(entries, "default_agent")).toBeUndefined()
- }),
- )
- it.live("loads authenticated wellknown config before user configuration", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- const global = path.join(tmp.path, "global")
- const project = path.join(tmp.path, "project")
- yield* Effect.promise(async () => {
- await fs.mkdir(global, { recursive: true })
- await fs.mkdir(project, { recursive: true })
- await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
- await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
- })
- const integrationID = Integration.ID.make("https://example.com")
- let key = "secret"
- const credentialNode = makeGlobalNode({
- service: Credential.Service,
- layer: Layer.succeed(
- Credential.Service,
- Credential.Service.of({
- all: () => Effect.die("unused Credential.all"),
- list: () =>
- Effect.succeed([
- new Credential.Info({
- id: Credential.ID.create(),
- integrationID,
- label: "default",
- value: Credential.Key.make({ type: "key", key }),
- }),
- ]),
- get: () => Effect.die("unused Credential.get"),
- create: () => Effect.die("unused Credential.create"),
- update: () => Effect.die("unused Credential.update"),
- remove: () => Effect.die("unused Credential.remove"),
- }),
- ),
- deps: [],
- })
- const entry: WellKnown.Entry = {
- origin: "https://example.com",
- integrationID,
- manifest: { auth: { command: ["login"], env: "TOKEN" } },
- }
- const wellknownNode = makeGlobalNode({
- service: WellKnown.Service,
- layer: Layer.succeed(
- WellKnown.Service,
- WellKnown.Service.of({
- entries: () => Effect.succeed([entry]),
- snapshot: () => [entry],
- refresh: () => Effect.succeed(false),
- add: () => Effect.die("unused Wellknown.add"),
- remove: () => Effect.die("unused Wellknown.remove"),
- resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
- }),
- ),
- deps: [],
- })
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const bus = yield* Bus.Service
- const initial = yield* config.entries()
- expect(Config.latest(initial, "shell")).toBe("project")
- expect(
- initial.flatMap((entry) =>
- entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
- ),
- ).toEqual(["secret", "global", "project"])
- const updated = yield* bus
- .subscribe(Event.Updated)
- .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
- yield* Effect.yieldNow
- key = "next"
- yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
- expect(yield* Fiber.join(updated)).toHaveLength(1)
- const refreshed = yield* config.entries()
- expect(Config.latest(refreshed, "shell")).toBe("project")
- expect(
- refreshed.flatMap((entry) =>
- entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
- ),
- ).toEqual(["next", "global", "project"])
- }).pipe(
- Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
- )
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- )
- it.live("logs redacted source-aware diagnostics for every config source", () => {
- const output: Array<Record<string, unknown>> = []
- const logger = Logger.map(Logger.formatStructured, (entry) => {
- if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
- const details = entry.message[1]
- if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
- })
- return Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- const global = path.join(tmp.path, "global")
- const project = path.join(tmp.path, "project")
- const malformed = path.join(tmp.path, "malformed.json")
- yield* Effect.promise(async () => {
- await fs.mkdir(global, { recursive: true })
- await fs.mkdir(project, { recursive: true })
- await fs.writeFile(path.join(global, "opencode.json"), "null")
- await fs.writeFile(path.join(project, "opencode.json"), "")
- await fs.writeFile(malformed, '{ "credential": "file-secret"')
- })
- const integrationID = Integration.ID.make("https://invalid.example.com")
- const entry: WellKnown.Entry = {
- origin: "https://invalid.example.com",
- integrationID,
- manifest: { auth: { command: ["login"], env: "TOKEN" } },
- }
- const credentialNode = makeGlobalNode({
- service: Credential.Service,
- layer: Layer.succeed(
- Credential.Service,
- Credential.Service.of({
- all: () => Effect.die("unused Credential.all"),
- list: () =>
- Effect.succeed([
- new Credential.Info({
- id: Credential.ID.create(),
- integrationID,
- label: "default",
- value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
- }),
- ]),
- get: () => Effect.die("unused Credential.get"),
- create: () => Effect.die("unused Credential.create"),
- update: () => Effect.die("unused Credential.update"),
- remove: () => Effect.die("unused Credential.remove"),
- }),
- ),
- deps: [],
- })
- const wellknownNode = makeGlobalNode({
- service: WellKnown.Service,
- layer: Layer.succeed(
- WellKnown.Service,
- WellKnown.Service.of({
- entries: () => Effect.succeed([entry]),
- snapshot: () => [entry],
- refresh: () => Effect.succeed(false),
- add: () => Effect.die("unused Wellknown.add"),
- remove: () => Effect.die("unused Wellknown.remove"),
- // Exercise the loader boundary against a malformed implementation response.
- resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
- }),
- ),
- deps: [],
- })
- yield* Config.Service.use((config) => config.entries()).pipe(
- Effect.provide(
- testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
- file: malformed,
- content: "",
- }),
- ),
- )
- expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
- [
- `${path.join(global, "opencode.json")}:$:invalid`,
- `${path.join(project, "opencode.json")}:$:invalid`,
- `${malformed}:$:invalid`,
- "https://invalid.example.com:$:invalid",
- "OPENCODE_CONFIG_CONTENT:$:invalid",
- ].toSorted(),
- )
- expect(JSON.stringify(output)).not.toContain("secret")
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(Effect.provide(Logger.layer([logger])))
- })
- it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
- Effect.sync(() => {
- FastCheck.assert(
- FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
- const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
- Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(
- Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
- ),
- )
- Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
- }),
- { numRuns: 100 },
- )
- }),
- )
- it.effect("migrates the v1 experimental subagent depth", () =>
- Effect.sync(() => {
- expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
- }),
- )
- it.effect("migrates the v1 small model to the title agent", () =>
- Effect.sync(() => {
- expect(
- ConfigMigrateV1.migrate({
- small_model: "anthropic/claude-haiku-4-5",
- agent: { title: { prompt: "Custom title prompt" } },
- }).agents?.title,
- ).toEqual({
- model: { providerID: "anthropic", model: "claude-haiku-4-5" },
- system: "Custom title prompt",
- })
- }),
- )
- it.effect("migrates v1 provider lists to policies", () =>
- Effect.sync(() => {
- expect(
- ConfigMigrateV1.migrate({
- enabled_providers: ["anthropic", "openai"],
- disabled_providers: ["openai"],
- }).experimental?.policies,
- ).toEqual([
- { action: "provider.use", resource: "*", effect: "deny" },
- { action: "provider.use", resource: "anthropic", effect: "allow" },
- { action: "provider.use", resource: "openai", effect: "allow" },
- { action: "provider.use", resource: "openai", effect: "deny" },
- ])
- expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
- { action: "provider.use", resource: "*", effect: "deny" },
- ])
- }),
- )
- it.effect("migrates v1 provider setup options into AISDK settings", () =>
- Effect.sync(() => {
- const migrated = ConfigMigrateV1.migrate({
- provider: {
- bedrock: {
- npm: "@ai-sdk/amazon-bedrock",
- options: {
- headers: { "x-test": "1" },
- body: { trace: true },
- region: "us-east-1",
- profile: "dev",
- },
- },
- },
- })
- expect(migrated.providers?.bedrock).toMatchObject({
- package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
- settings: { region: "us-east-1", profile: "dev" },
- headers: { "x-test": "1" },
- body: { trace: true },
- })
- }),
- )
- it.effect("renames old provider IDs while migrating v1 configuration", () =>
- Effect.sync(() => {
- const migrated = ConfigMigrateV1.migrate({
- model: "azure-cognitive-services/deployment",
- enabled_providers: ["google-vertex-anthropic"],
- disabled_providers: ["azure-cognitive-services"],
- agent: {
- reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
- },
- command: {
- review: { template: "Review", model: "azure-cognitive-services/deployment" },
- },
- provider: {
- "azure-cognitive-services": {
- npm: "@ai-sdk/azure",
- env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
- models: { deployment: {} },
- },
- "google-vertex-anthropic": {
- npm: "@ai-sdk/google-vertex/anthropic",
- options: { project: "test-project", location: "us-central1" },
- models: { "claude-sonnet": {} },
- },
- },
- })
- expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
- expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
- expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
- expect(migrated.experimental?.policies).toEqual([
- { action: "provider.use", resource: "*", effect: "deny" },
- { action: "provider.use", resource: "google-vertex", effect: "allow" },
- { action: "provider.use", resource: "azure", effect: "deny" },
- ])
- expect(migrated.providers?.azure).toMatchObject({
- env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
- package: Provider.aisdk("@ai-sdk/azure"),
- models: { deployment: {} },
- })
- expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
- expect(migrated.providers?.["google-vertex"]).toMatchObject({
- settings: { project: "test-project", location: "us-central1" },
- models: {
- "claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
- },
- })
- expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
- expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
- }),
- )
- it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
- Effect.sync(() => {
- const migrated = ConfigMigrateV1.migrate({
- provider: {
- "azure-cognitive-services": {
- npm: "@ai-sdk/openai-compatible",
- env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
- },
- },
- })
- expect(migrated.providers?.azure).toMatchObject({
- env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
- package: Provider.aisdk("@ai-sdk/openai-compatible"),
- settings: {
- baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
- },
- })
- }),
- )
- it.effect("ignores old provider IDs when the current provider ID is configured", () =>
- Effect.sync(() => {
- const migrated = ConfigMigrateV1.migrate({
- provider: {
- azure: { models: { current: {} } },
- "azure-cognitive-services": { models: { legacy: {} } },
- "google-vertex": { models: { gemini: {} } },
- "google-vertex-anthropic": { models: { claude: {} } },
- },
- })
- expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
- expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
- }),
- )
- it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
- Effect.sync(() => {
- const migrated = ConfigMigrateV1.migrate({
- provider: {
- "google-vertex-anthropic": {
- models: { claude: {} },
- },
- },
- })
- expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
- expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
- Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
- )
- }),
- )
- it.effect("migrates v1 interleaved fields to compatibility", () =>
- Effect.sync(() => {
- const migrated = ConfigMigrateV1.migrate({
- provider: {
- custom: {
- models: {
- object: { interleaved: { field: "vendor_reasoning" } },
- string: { interleaved: "reasoning_text" },
- boolean: { interleaved: true },
- },
- },
- },
- })
- expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
- reasoningField: "vendor_reasoning",
- })
- expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
- expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
- }),
- )
- it.effect("migrates v1 command configuration", () =>
- Effect.sync(() => {
- expect(
- ConfigMigrateV1.migrate({
- command: {
- review: {
- template: "Review changes",
- description: "Review code",
- agent: "reviewer",
- model: "anthropic/claude",
- variant: "high",
- subtask: true,
- },
- },
- }).commands,
- ).toEqual({
- review: {
- template: "Review changes",
- description: "Review code",
- agent: "reviewer",
- model: { providerID: "anthropic", model: "claude", variant: "high" },
- subtask: true,
- },
- })
- }),
- )
- it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
- Effect.sync(() => {
- expect(
- ConfigMigrateV1.migrate({
- permission: {
- task: "ask",
- bash: { "git status": "allow", "*": "deny" },
- write: "deny",
- read: "allow",
- },
- }).permissions,
- ).toEqual([
- { action: "subagent", resource: "*", effect: "ask" },
- { action: "shell", resource: "git status", effect: "allow" },
- { action: "shell", resource: "*", effect: "deny" },
- { action: "edit", resource: "*", effect: "deny" },
- { action: "read", resource: "*", effect: "allow" },
- ])
- }),
- )
- it.live("returns an empty configuration when directory files do not exist", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- const config = yield* Config.Service
- const entries = yield* config.entries()
- expect(entries).toEqual([
- new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
- ])
- }).pipe(Effect.provide(testLayer(tmp.path))),
- ),
- ),
- )
- it.live("deduplicates global ecosystem directories found during upward discovery", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- const global = path.join(tmp.path, "global")
- const home = path.join(global, "home")
- const project = path.join(home, "project")
- yield* Effect.promise(() =>
- Promise.all([
- fs.mkdir(path.join(home, ".claude"), { recursive: true }),
- fs.mkdir(path.join(home, ".agents"), { recursive: true }),
- fs.mkdir(project, { recursive: true }),
- ]),
- )
- const entries = yield* Config.Service.use((config) => config.entries()).pipe(
- Effect.provide(testLayer(project, global)),
- )
- expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
- AbsolutePath.make(path.join(home, ".claude")),
- ])
- expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
- AbsolutePath.make(path.join(home, ".agents")),
- ])
- }),
- ),
- ),
- )
- it.live("does not watch ecosystem config roots", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- Promise.all([
- fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
- fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
- ]),
- )
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const watcher = yield* Watcher.Test
- yield* config.entries()
- expect(yield* watcher.subscriptions()).toEqual([
- {
- type: "directory",
- path: AbsolutePath.make(path.join(tmp.path, "global")),
- ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
- },
- ])
- }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
- }),
- ),
- ),
- )
- it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- Promise.all([
- fs.writeFile(
- path.join(tmp.path, "opencode.json"),
- JSON.stringify({ $schema: "base", providers: { base: provider } }),
- ),
- fs.writeFile(
- path.join(tmp.path, "opencode.jsonc"),
- `{
- // Later global files override scalar fields while retaining providers.
- "$schema": "last",
- "providers": { "last": ${JSON.stringify(provider)} },
- }`,
- ),
- ]),
- )
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
- expect(documents).toHaveLength(2)
- expect(documents.map((document) => document.type)).toEqual(["document", "document"])
- expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
- expect(documents[0]).toBeInstanceOf(Document)
- expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
- expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
- yield* Effect.promise(() =>
- fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
- )
- expect(
- (yield* config.entries())
- .filter((entry) => entry.type === "document")
- .map((document) => document.info.$schema),
- ).toEqual(["base", "last"])
- }).pipe(Effect.provide(testLayer(tmp.path)))
- }),
- ),
- ),
- )
- it.live("substitutes environment variables and relative file contents", () =>
- Effect.acquireUseRelease(
- Effect.sync(() => {
- const previous = {
- token: process.env.OPENCODE_TEST_MCP_TOKEN,
- missing: process.env.OPENCODE_TEST_MISSING,
- }
- process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
- delete process.env.OPENCODE_TEST_MISSING
- return previous
- }),
- () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- Promise.all([
- fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
- fs.writeFile(
- path.join(tmp.path, "opencode.jsonc"),
- `{
- // Ignored reference: {file:missing.txt}
- "username": "user-{env:OPENCODE_TEST_MISSING}",
- "mcp": {
- "servers": {
- "remote": {
- "type": "remote",
- "url": "https://example.com/mcp",
- "headers": {
- "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
- "X-Token": "{file:token.txt}"
- }
- }
- }
- }
- }`,
- ),
- ]),
- )
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const document = (yield* config.entries()).find((entry) => entry.type === "document")
- expect(document?.info.username).toBe("user-")
- const remote = document?.info.mcp?.servers?.remote
- expect(remote?.type).toBe("remote")
- if (remote?.type !== "remote") return
- expect(remote.headers).toEqual({
- Authorization: "Bearer secret",
- "X-Token": 'file\n"token"',
- })
- }).pipe(Effect.provide(testLayer(tmp.path)))
- }),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ),
- (previous) =>
- Effect.sync(() => {
- if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
- else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
- if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
- else process.env.OPENCODE_TEST_MISSING = previous.missing
- }),
- ),
- )
- it.live("does not load legacy config.json files", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
- )
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
- expect(documents).toHaveLength(0)
- }).pipe(Effect.provide(testLayer(tmp.path)))
- }),
- ),
- ),
- )
- it.live("accepts $schema metadata without writing it into config files", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- const file = path.join(tmp.path, "opencode.json")
- const contents = JSON.stringify({
- shell: "/bin/zsh",
- providers: { local: provider },
- })
- yield* Effect.promise(() => fs.writeFile(file, contents))
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
- expect(documents[0]?.info.$schema).toBeUndefined()
- expect(documents[0]?.info.shell).toBe("/bin/zsh")
- expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
- }).pipe(Effect.provide(testLayer(tmp.path)))
- }),
- ),
- ),
- )
- it.live("loads supported scalar and resource configuration", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- fs.writeFile(
- path.join(tmp.path, "opencode.json"),
- JSON.stringify({
- shell: "/bin/bash",
- model: "anthropic/claude",
- default_agent: "reviewer",
- autoupdate: "notify",
- share: "disabled",
- enterprise: { url: "https://share.example.com" },
- username: "test-user",
- permissions: [
- { action: "bash", resource: "*", effect: "ask" },
- { action: "bash", resource: "git status", effect: "allow" },
- ],
- agents: {
- reviewer: {
- model: "openrouter/openai/gpt-5#high",
- request: {
- headers: { "x-agent": "reviewer" },
- body: { reasoningEffort: "high" },
- },
- description: "Review changes for correctness",
- system: "Find regressions.",
- mode: "subagent",
- hidden: false,
- color: "#ff6b6b",
- steps: 12,
- disabled: false,
- permissions: [{ action: "edit", resource: "*", effect: "deny" }],
- },
- },
- snapshots: false,
- watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
- formatter: {
- prettier: { disabled: true },
- custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
- },
- lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
- media: {
- image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
- },
- tool_output: { max_lines: 1000, max_bytes: 32768 },
- mcp: {
- timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
- servers: {
- local: {
- type: "local",
- command: ["node", "./mcp/server.js"],
- environment: { API_KEY: "secret" },
- disabled: false,
- codemode: false,
- timeout: { catalog: 10000 },
- },
- remote: {
- type: "remote",
- url: "https://mcp.example.com/mcp",
- headers: { Authorization: "Bearer token" },
- oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
- disabled: true,
- codemode: false,
- timeout: { startup: 15000 },
- },
- },
- },
- compaction: {
- auto: true,
- prune: false,
- keep: { tokens: 2000 },
- buffer: 10000,
- },
- skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
- instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
- references: {
- local: { path: "../library" },
- sdk: { repository: "github.com/example/sdk", branch: "main" },
- shorthand: "github.com/example/docs",
- },
- plugins: [
- "opencode-helicone-session",
- { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
- ],
- }),
- ),
- )
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
- expect(documents).toHaveLength(1)
- expect(documents[0]?.info.shell).toBe("/bin/bash")
- expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
- expect(documents[0]?.info.default_agent).toBe("reviewer")
- expect(documents[0]?.info.autoupdate).toBe("notify")
- expect(documents[0]?.info.share).toBe("disabled")
- expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
- expect(documents[0]?.info.username).toBe("test-user")
- expect(documents[0]?.info.permissions).toEqual([
- { action: "bash", resource: "*", effect: "ask" },
- { action: "bash", resource: "git status", effect: "allow" },
- ])
- const reviewer = documents[0]?.info.agents?.reviewer
- expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high"))
- expect(reviewer?.request).toEqual({
- headers: { "x-agent": "reviewer" },
- body: { reasoningEffort: "high" },
- })
- expect(reviewer?.description).toBe("Review changes for correctness")
- expect(reviewer?.system).toBe("Find regressions.")
- expect(reviewer?.mode).toBe("subagent")
- expect(reviewer?.hidden).toBe(false)
- expect(reviewer?.color).toBe("#ff6b6b")
- expect(reviewer?.steps).toBe(12)
- expect(reviewer?.disabled).toBe(false)
- expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
- expect(documents[0]?.info.snapshots).toBe(false)
- expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
- expect(documents[0]?.info.formatter).toEqual({
- prettier: { disabled: true },
- custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
- })
- expect(documents[0]?.info.lsp).toEqual({
- typescript: { disabled: true },
- custom: { command: ["custom-lsp"], extensions: [".foo"] },
- })
- expect(documents[0]?.info.media).toEqual({
- image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
- })
- expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
- expect(documents[0]?.info.mcp).toEqual({
- timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
- servers: {
- local: {
- type: "local",
- command: ["node", "./mcp/server.js"],
- environment: { API_KEY: "secret" },
- disabled: false,
- codemode: false,
- timeout: { catalog: 10000 },
- },
- remote: {
- type: "remote",
- url: "https://mcp.example.com/mcp",
- headers: { Authorization: "Bearer token" },
- oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
- disabled: true,
- codemode: false,
- timeout: { startup: 15000 },
- },
- },
- })
- expect(documents[0]?.info.compaction).toEqual({
- auto: true,
- keep: { tokens: 2000 },
- buffer: 10000,
- })
- expect(documents[0]?.info.skills).toEqual([
- "./skills",
- "~/shared-skills",
- "https://example.com/.well-known/skills/",
- ])
- expect(documents[0]?.info.instructions).toEqual([
- "CONTRIBUTING.md",
- ".cursor/rules/*.md",
- "https://example.com/shared-rules.md",
- ])
- expect(documents[0]?.info.references).toEqual({
- local: { path: "../library" },
- sdk: { repository: "github.com/example/sdk", branch: "main" },
- shorthand: "github.com/example/docs",
- })
- expect(documents[0]?.info.plugins).toEqual([
- "opencode-helicone-session",
- { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
- ])
- }).pipe(Effect.provide(testLayer(tmp.path)))
- }),
- ),
- ),
- )
- it.live("migrates the deprecated reference key into references", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- fs.writeFile(
- path.join(tmp.path, "opencode.json"),
- JSON.stringify({
- reference: {
- local: { path: "../library" },
- sdk: { repository: "github.com/example/sdk", branch: "main" },
- shorthand: "github.com/example/docs",
- },
- }),
- ),
- )
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
- expect(documents).toHaveLength(1)
- expect(documents[0]?.info.references).toEqual({
- local: { path: "../library" },
- sdk: { repository: "github.com/example/sdk", branch: "main" },
- shorthand: "github.com/example/docs",
- })
- }).pipe(Effect.provide(testLayer(tmp.path)))
- }),
- ),
- ),
- )
- it.live("migrates v1 configuration when a v1-only key is present", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- fs.writeFile(
- path.join(tmp.path, "opencode.json"),
- JSON.stringify({
- shell: "/bin/zsh",
- default_agent: "reviewer",
- snapshot: false,
- autoshare: true,
- permission: {
- bash: "ask",
- edit: { "*.md": "allow", "*": "deny" },
- question: "deny",
- },
- agent: {
- reviewer: {
- prompt: "Review changes.",
- disable: true,
- temperature: 0.2,
- permission: { read: "allow" },
- },
- },
- plugin: [
- "opencode-helicone-session",
- ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
- ],
- skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
- references: {
- docs: { path: "../docs", description: "Use for product documentation", hidden: true },
- },
- attachment: { image: { auto_resize: false, max_width: 1200 } },
- provider: {
- custom: {
- options: { apiKey: "secret" },
- models: {
- model: {
- options: { reasoningEffort: "high" },
- variants: { fast: { temperature: 0.2 } },
- },
- },
- },
- openai: {
- npm: "@ai-sdk/openai",
- options: { apiKey: "secret", organization: "org" },
- models: {
- model: {
- options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
- variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
- },
- },
- },
- anthropic: {
- npm: "@ai-sdk/anthropic",
- models: {
- model: {
- options: {
- effort: "high",
- taskBudget: 4096,
- metadata: { userId: "user-1" },
- },
- },
- },
- },
- },
- compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
- experimental: { mcp_timeout: 5000 },
- mcp: {
- local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
- remote: {
- type: "remote",
- url: "https://mcp.example.com",
- oauth: { clientId: "client", callbackPort: 19876 },
- timeout: 20000,
- },
- },
- }),
- ),
- )
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
- expect(documents).toHaveLength(1)
- expect(documents[0]?.info).toBeInstanceOf(Info)
- expect(documents[0]?.info.shell).toBe("/bin/zsh")
- expect(documents[0]?.info.default_agent).toBe("reviewer")
- expect(documents[0]?.info.snapshots).toBe(false)
- expect(documents[0]?.info.share).toBe("auto")
- expect(documents[0]?.info.permissions).toEqual([
- { action: "shell", resource: "*", effect: "ask" },
- { action: "edit", resource: "*.md", effect: "allow" },
- { action: "edit", resource: "*", effect: "deny" },
- { action: "question", resource: "*", effect: "deny" },
- ])
- expect(documents[0]?.info.agents?.reviewer).toMatchObject({
- system: "Review changes.",
- disabled: true,
- request: { body: { temperature: 0.2 } },
- permissions: [{ action: "read", resource: "*", effect: "allow" }],
- })
- expect(documents[0]?.info.plugins).toEqual([
- "opencode-helicone-session",
- { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
- ])
- expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
- expect(documents[0]?.info.references).toEqual({
- docs: { path: "../docs", description: "Use for product documentation", hidden: true },
- })
- expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
- expect(documents[0]?.info.providers?.custom).toMatchObject({
- settings: { apiKey: "secret" },
- models: {
- model: {
- settings: { reasoningEffort: "high" },
- variants: [{ id: "fast", settings: { temperature: 0.2 } }],
- },
- },
- })
- expect(documents[0]?.info.providers?.openai).toMatchObject({
- package: Provider.aisdk("@ai-sdk/openai"),
- settings: { apiKey: "secret", organization: "org" },
- models: {
- model: {
- settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
- variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }],
- },
- },
- })
- expect(documents[0]?.info.providers?.anthropic).toMatchObject({
- package: Provider.aisdk("@ai-sdk/anthropic"),
- models: {
- model: {
- settings: {
- effort: "high",
- taskBudget: 4096,
- metadata: { userId: "user-1" },
- },
- },
- },
- })
- expect(documents[0]?.info.compaction).toEqual({
- auto: true,
- keep: { tokens: 2000 },
- buffer: 10000,
- })
- expect(documents[0]?.info.mcp).toMatchObject({
- timeout: { catalog: 5000, execution: 5000 },
- servers: {
- local: {
- type: "local",
- command: ["node", "server.js"],
- disabled: true,
- timeout: { catalog: 10000, execution: 10000 },
- },
- remote: {
- type: "remote",
- url: "https://mcp.example.com",
- oauth: { client_id: "client", callback_port: 19876 },
- timeout: { catalog: 20000, execution: 20000 },
- },
- },
- })
- }).pipe(Effect.provide(testLayer(tmp.path)))
- }),
- ),
- ),
- )
- it.live("ignores an invalid file while loading valid config values", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- yield* Effect.promise(() =>
- Promise.all([
- fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
- fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
- ]),
- )
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
- expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
- }).pipe(Effect.provide(testLayer(tmp.path)))
- }),
- ),
- ),
- )
- it.live("loads global and ancestor configuration across the project boundary", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) => {
- const global = path.join(tmp.path, "global")
- const root = path.join(tmp.path, "repo")
- const parent = path.join(root, "packages")
- const directory = path.join(parent, "app")
- const globalAgents = path.join(global, "home", ".agents")
- const globalClaude = path.join(global, "home", ".claude")
- return Effect.gen(function* () {
- yield* Effect.promise(async () => {
- await fs.mkdir(global, { recursive: true })
- await fs.mkdir(globalAgents, { recursive: true })
- await fs.mkdir(globalClaude, { recursive: true })
- await fs.mkdir(directory, { recursive: true })
- await fs.mkdir(path.join(root, ".agents"), { recursive: true })
- await fs.mkdir(path.join(root, ".claude"), { recursive: true })
- await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
- await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
- await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
- await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
- await Promise.all([
- fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
- fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
- fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
- fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
- fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
- fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
- fs.writeFile(
- path.join(directory, ".opencode", "opencode.jsonc"),
- JSON.stringify({ $schema: "directory-dot" }),
- ),
- ])
- })
- return yield* Effect.gen(function* () {
- const config = yield* Config.Service
- const entries = yield* config.entries()
- const documents = entries.filter((entry) => entry.type === "document")
- expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
- AbsolutePath.make(global),
- AbsolutePath.make(path.join(root, ".opencode")),
- AbsolutePath.make(path.join(directory, ".opencode")),
- ])
- expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
- AbsolutePath.make(globalAgents),
- AbsolutePath.make(path.join(directory, ".agents")),
- AbsolutePath.make(path.join(root, ".agents")),
- ])
- expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
- AbsolutePath.make(globalClaude),
- AbsolutePath.make(path.join(directory, ".claude")),
- AbsolutePath.make(path.join(root, ".claude")),
- ])
- expect(documents.map((document) => document.info.$schema)).toEqual([
- "global",
- "outside",
- "root",
- "parent",
- "directory",
- "root-dot",
- "directory-dot",
- ])
- expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
- AbsolutePath.make(globalClaude),
- AbsolutePath.make(path.join(directory, ".claude")),
- AbsolutePath.make(path.join(root, ".claude")),
- AbsolutePath.make(globalAgents),
- AbsolutePath.make(path.join(directory, ".agents")),
- AbsolutePath.make(path.join(root, ".agents")),
- "global",
- AbsolutePath.make(global),
- "outside",
- AbsolutePath.make(path.join(tmp.path, "opencode.json")),
- "root",
- AbsolutePath.make(path.join(root, "opencode.json")),
- "parent",
- AbsolutePath.make(path.join(parent, "opencode.jsonc")),
- "directory",
- AbsolutePath.make(path.join(directory, "opencode.json")),
- "root-dot",
- AbsolutePath.make(path.join(root, ".opencode")),
- "directory-dot",
- AbsolutePath.make(path.join(directory, ".opencode")),
- ])
- }).pipe(
- Effect.provide(
- testLayer(directory, global, root, {
- type: "git",
- store: AbsolutePath.make(path.join(root, ".git")),
- }),
- ),
- )
- })
- }),
- ),
- )
- })
|