config.test.ts 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
  5. import { FastCheck } from "effect/testing"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { ConfigModel } from "@opencode-ai/core/config/model"
  8. import { Config as ConfigSchema } from "@opencode-ai/schema/config"
  9. import { ConfigProvider } from "@opencode-ai/core/config/provider"
  10. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  11. import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
  12. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  13. import { Credential } from "@opencode-ai/core/credential"
  14. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  15. import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
  16. import { FSUtil } from "@opencode-ai/util/fs-util"
  17. import { Watcher } from "@opencode-ai/core/filesystem/watcher"
  18. import { Bus } from "@opencode-ai/core/bus"
  19. import { Global } from "@opencode-ai/util/global"
  20. import { Location } from "@opencode-ai/core/location"
  21. import { Project } from "@opencode-ai/core/project"
  22. import { Provider } from "@opencode-ai/core/provider"
  23. import { AbsolutePath } from "@opencode-ai/core/schema"
  24. import { WellKnown } from "@opencode-ai/core/wellknown"
  25. import { Integration } from "@opencode-ai/schema/integration"
  26. import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
  27. import { location } from "../fixture/location"
  28. import { tmpdir } from "../fixture/tmpdir"
  29. import { testEffect } from "../lib/effect"
  30. const it = testEffect(Layer.empty)
  31. const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
  32. function testLayer(
  33. directory: string,
  34. globalDirectory = path.join(directory, "global"),
  35. projectDirectory = directory,
  36. vcs?: Project.Vcs,
  37. watcher: Layer.Layer<Watcher.Service | Watcher.Test> = Watcher.testLayer,
  38. credentialNode = emptyCredentialNode,
  39. wellknownNode = emptyWellknownNode,
  40. options?: Config.Options,
  41. ) {
  42. const locationLayer = Layer.succeed(
  43. Location.Service,
  44. Location.Service.of(
  45. location(
  46. { directory: AbsolutePath.make(directory) },
  47. { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
  48. ),
  49. ),
  50. )
  51. const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
  52. [Config.node, Config.configured(options)],
  53. [Location.node, locationLayer],
  54. [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
  55. [Credential.node, credentialNode],
  56. [WellKnown.node, wellknownNode],
  57. [Watcher.node, watcher],
  58. ])
  59. // Merge the watcher layer by reference so Watcher.Test resolves to the same
  60. // memoized instance the built graph uses.
  61. return Layer.mergeAll(built, watcher)
  62. }
  63. const provider = {
  64. package: "native",
  65. settings: {},
  66. headers: {},
  67. body: {},
  68. models: {},
  69. }
  70. describe("Config", () => {
  71. it.live("loads explicit file and content overrides in priority order", () =>
  72. Effect.acquireRelease(
  73. Effect.promise(() => tmpdir()),
  74. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  75. ).pipe(
  76. Effect.flatMap((tmp) => {
  77. const global = path.join(tmp.path, "global")
  78. const project = path.join(tmp.path, "project")
  79. const explicit = path.join(tmp.path, "custom.json")
  80. return Effect.promise(async () => {
  81. await fs.mkdir(global, { recursive: true })
  82. await fs.mkdir(project, { recursive: true })
  83. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  84. await fs.writeFile(explicit, JSON.stringify({ shell: "explicit" }))
  85. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  86. }).pipe(
  87. Effect.andThen(
  88. Effect.gen(function* () {
  89. const config = yield* Config.Service
  90. const entries = yield* config.entries()
  91. expect(
  92. entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
  93. ).toEqual(["global", "explicit", "project", "content"])
  94. expect(Config.latest(entries, "shell")).toBe("content")
  95. }).pipe(
  96. Effect.provide(
  97. testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
  98. file: explicit,
  99. content: JSON.stringify({ shell: "content" }),
  100. }),
  101. ),
  102. ),
  103. ),
  104. )
  105. }),
  106. ),
  107. )
  108. it.live("skips project configuration when project discovery is disabled", () =>
  109. Effect.acquireRelease(
  110. Effect.promise(() => tmpdir()),
  111. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  112. ).pipe(
  113. Effect.flatMap((tmp) => {
  114. const global = path.join(tmp.path, "global")
  115. const project = path.join(tmp.path, "project")
  116. return Effect.promise(async () => {
  117. await fs.mkdir(global, { recursive: true })
  118. await fs.mkdir(project, { recursive: true })
  119. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  120. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  121. }).pipe(
  122. Effect.andThen(
  123. Effect.gen(function* () {
  124. const config = yield* Config.Service
  125. expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
  126. }).pipe(
  127. Effect.provide(
  128. testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
  129. project: false,
  130. }),
  131. ),
  132. ),
  133. ),
  134. )
  135. }),
  136. ),
  137. )
  138. it.live("reloads external config and publishes directory updates", () =>
  139. Effect.acquireRelease(
  140. Effect.promise(() => tmpdir()),
  141. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  142. ).pipe(
  143. Effect.flatMap((tmp) =>
  144. Effect.gen(function* () {
  145. const global = path.join(tmp.path, "global")
  146. const project = path.join(tmp.path, "project")
  147. const file = path.join(global, "opencode.json")
  148. yield* Effect.promise(async () => {
  149. await fs.mkdir(global, { recursive: true })
  150. await fs.mkdir(project, { recursive: true })
  151. await fs.writeFile(file, JSON.stringify({ shell: "first" }))
  152. })
  153. return yield* Effect.gen(function* () {
  154. const config = yield* Config.Service
  155. const bus = yield* Bus.Service
  156. const watcher = yield* Watcher.Test
  157. const changed = yield* bus
  158. .subscribe(ConfigSchema.Event.Updated)
  159. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  160. yield* Effect.sleep("10 millis")
  161. yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
  162. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
  163. yield* watcher.emit({ type: "update", path: file })
  164. expect(yield* Fiber.join(changed)).toHaveLength(1)
  165. expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
  166. }).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
  167. }),
  168. ),
  169. ),
  170. )
  171. it.live("exposes filesystem updates under config roots through changes", () =>
  172. Effect.acquireRelease(
  173. Effect.promise(() => tmpdir()),
  174. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  175. ).pipe(
  176. Effect.flatMap((tmp) =>
  177. Effect.gen(function* () {
  178. const global = path.join(tmp.path, "global")
  179. const project = path.join(tmp.path, "project")
  180. yield* Effect.promise(async () => {
  181. await fs.mkdir(path.join(global, "commands"), { recursive: true })
  182. await fs.mkdir(project, { recursive: true })
  183. })
  184. return yield* Effect.gen(function* () {
  185. const config = yield* Config.Service
  186. const watcher = yield* Watcher.Test
  187. const received = yield* config
  188. .changes()
  189. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
  190. yield* Effect.sleep("10 millis")
  191. const file = path.join(global, "commands", "review.md")
  192. yield* watcher.emit({ type: "update", path: file })
  193. const collected = yield* Fiber.join(received).pipe(Effect.timeout("1 second"))
  194. expect(Array.from(collected)).toEqual([{ type: "update", path: file }])
  195. }).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
  196. }),
  197. ),
  198. ),
  199. )
  200. // Real watcher on purpose: the regression this pins (a deleted config file's
  201. // watch being torn down, making recreation invisible) only reproduces with
  202. // path-faithful event delivery.
  203. it.live("keeps watching a deleted config file so recreating it reloads", () =>
  204. Effect.acquireRelease(
  205. Effect.promise(() => tmpdir()),
  206. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  207. ).pipe(
  208. Effect.flatMap((tmp) =>
  209. Effect.gen(function* () {
  210. const global = path.join(tmp.path, "global")
  211. const project = path.join(tmp.path, "project")
  212. const file = path.join(project, "opencode.json")
  213. yield* Effect.promise(async () => {
  214. await fs.mkdir(global, { recursive: true })
  215. await fs.mkdir(project, { recursive: true })
  216. await fs.writeFile(file, JSON.stringify({ shell: "one" }))
  217. })
  218. return yield* Effect.gen(function* () {
  219. const config = yield* Config.Service
  220. const bus = yield* Bus.Service
  221. expect(Config.latest(yield* config.entries(), "shell")).toBe("one")
  222. yield* Effect.sleep("10 millis")
  223. const removed = yield* bus
  224. .subscribe(ConfigSchema.Event.Updated)
  225. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  226. yield* Effect.promise(() => fs.rm(file))
  227. yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds"))
  228. expect(Config.latest(yield* config.entries(), "shell")).toBeUndefined()
  229. const recreated = yield* bus
  230. .subscribe(ConfigSchema.Event.Updated)
  231. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  232. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "two" })))
  233. yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds"))
  234. expect(Config.latest(yield* config.entries(), "shell")).toBe("two")
  235. }).pipe(
  236. Effect.provide(
  237. AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
  238. [
  239. Location.node,
  240. Layer.succeed(
  241. Location.Service,
  242. Location.Service.of(location({ directory: AbsolutePath.make(project) })),
  243. ),
  244. ],
  245. [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
  246. [Credential.node, emptyCredentialNode],
  247. [WellKnown.node, emptyWellknownNode],
  248. ]),
  249. ),
  250. )
  251. }),
  252. ),
  253. ),
  254. )
  255. it.effect("backs Config.Service and Config.Test with one shared test implementation", () =>
  256. Effect.gen(function* () {
  257. const config = yield* Config.Service
  258. const test = yield* Config.Test
  259. expect(yield* config.entries()).toEqual([])
  260. const entry = new Config.Document({ type: "document", info: new Config.Info({}) })
  261. yield* test.setEntries([entry])
  262. expect(yield* config.entries()).toEqual([entry])
  263. const received = yield* config
  264. .changes()
  265. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
  266. yield* Effect.yieldNow
  267. yield* test.emitChange({ type: "create", path: "/root/commands/review.md" })
  268. expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "create", path: "/root/commands/review.md" }])
  269. }).pipe(Effect.provide(Config.testLayer())),
  270. )
  271. it.effect("returns the latest defined scalar from priority-ordered documents", () =>
  272. Effect.sync(() => {
  273. const entries = [
  274. new Config.Document({
  275. type: "document",
  276. info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
  277. }),
  278. new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
  279. new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
  280. new Config.Document({ type: "document", info: new Config.Info({}) }),
  281. new Config.Document({
  282. type: "document",
  283. info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }),
  284. }),
  285. ]
  286. expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
  287. expect(Config.latest(entries, "default_agent")).toBeUndefined()
  288. }),
  289. )
  290. it.live("loads authenticated wellknown config at highest priority", () =>
  291. Effect.acquireUseRelease(
  292. Effect.promise(() => tmpdir()),
  293. (tmp) =>
  294. Effect.gen(function* () {
  295. const global = path.join(tmp.path, "global")
  296. const project = path.join(tmp.path, "project")
  297. yield* Effect.promise(async () => {
  298. await fs.mkdir(global, { recursive: true })
  299. await fs.mkdir(project, { recursive: true })
  300. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  301. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  302. })
  303. const integrationID = Integration.ID.make("https://example.com")
  304. let key = "secret"
  305. const credentialNode = makeGlobalNode({
  306. service: Credential.Service,
  307. layer: Layer.succeed(
  308. Credential.Service,
  309. Credential.Service.of({
  310. all: () => Effect.die("unused Credential.all"),
  311. list: () =>
  312. Effect.succeed([
  313. new Credential.Info({
  314. id: Credential.ID.create(),
  315. integrationID,
  316. label: "default",
  317. value: Credential.Key.make({ type: "key", key }),
  318. }),
  319. ]),
  320. get: () => Effect.die("unused Credential.get"),
  321. create: () => Effect.die("unused Credential.create"),
  322. update: () => Effect.die("unused Credential.update"),
  323. remove: () => Effect.die("unused Credential.remove"),
  324. }),
  325. ),
  326. deps: [],
  327. })
  328. const entry: WellKnown.Entry = {
  329. origin: "https://example.com",
  330. integrationID,
  331. manifest: { auth: { command: ["login"], env: "TOKEN" } },
  332. }
  333. const wellknownNode = makeGlobalNode({
  334. service: WellKnown.Service,
  335. layer: Layer.succeed(
  336. WellKnown.Service,
  337. WellKnown.Service.of({
  338. entries: () => Effect.succeed([entry]),
  339. snapshot: () => [entry],
  340. refresh: () => Effect.succeed(false),
  341. add: () => Effect.die("unused Wellknown.add"),
  342. remove: () => Effect.die("unused Wellknown.remove"),
  343. resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
  344. }),
  345. ),
  346. deps: [],
  347. })
  348. return yield* Effect.gen(function* () {
  349. const config = yield* Config.Service
  350. const bus = yield* Bus.Service
  351. expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
  352. const updated = yield* bus
  353. .subscribe(ConfigSchema.Event.Updated)
  354. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  355. yield* Effect.yieldNow
  356. key = "next"
  357. yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
  358. expect(yield* Fiber.join(updated)).toHaveLength(1)
  359. expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
  360. }).pipe(
  361. Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
  362. )
  363. }),
  364. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  365. ),
  366. )
  367. it.effect("detects v1 configuration from any v1-only top-level key", () =>
  368. Effect.sync(() => {
  369. expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
  370. expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
  371. expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
  372. expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
  373. expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
  374. }),
  375. )
  376. it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
  377. Effect.sync(() => {
  378. // V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
  379. expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
  380. expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
  381. // Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
  382. expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
  383. expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
  384. expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
  385. }),
  386. )
  387. it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
  388. Effect.sync(() => {
  389. FastCheck.assert(
  390. FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
  391. const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
  392. Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(
  393. Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
  394. ),
  395. )
  396. Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
  397. }),
  398. { numRuns: 100 },
  399. )
  400. }),
  401. )
  402. it.effect("migrates the v1 experimental subagent depth", () =>
  403. Effect.sync(() => {
  404. expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
  405. }),
  406. )
  407. it.effect("migrates v1 provider lists to policies", () =>
  408. Effect.sync(() => {
  409. expect(
  410. ConfigMigrateV1.migrate({
  411. enabled_providers: ["anthropic", "openai"],
  412. disabled_providers: ["openai"],
  413. }).experimental?.policies,
  414. ).toEqual([
  415. { action: "provider.use", resource: "*", effect: "deny" },
  416. { action: "provider.use", resource: "anthropic", effect: "allow" },
  417. { action: "provider.use", resource: "openai", effect: "allow" },
  418. { action: "provider.use", resource: "openai", effect: "deny" },
  419. ])
  420. expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
  421. { action: "provider.use", resource: "*", effect: "deny" },
  422. ])
  423. }),
  424. )
  425. it.effect("migrates v1 provider setup options into AISDK settings", () =>
  426. Effect.sync(() => {
  427. const migrated = ConfigMigrateV1.migrate({
  428. provider: {
  429. bedrock: {
  430. npm: "@ai-sdk/amazon-bedrock",
  431. options: {
  432. headers: { "x-test": "1" },
  433. body: { trace: true },
  434. region: "us-east-1",
  435. profile: "dev",
  436. },
  437. },
  438. },
  439. })
  440. expect(migrated.providers?.bedrock).toMatchObject({
  441. package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
  442. settings: { region: "us-east-1", profile: "dev" },
  443. headers: { "x-test": "1" },
  444. body: { trace: true },
  445. })
  446. }),
  447. )
  448. it.effect("migrates v1 interleaved fields to compatibility", () =>
  449. Effect.sync(() => {
  450. const migrated = ConfigMigrateV1.migrate({
  451. provider: {
  452. custom: {
  453. models: {
  454. object: { interleaved: { field: "vendor_reasoning" } },
  455. string: { interleaved: "reasoning_text" },
  456. boolean: { interleaved: true },
  457. },
  458. },
  459. },
  460. })
  461. expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
  462. reasoningField: "vendor_reasoning",
  463. })
  464. expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
  465. expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
  466. }),
  467. )
  468. it.effect("migrates v1 command configuration", () =>
  469. Effect.sync(() => {
  470. expect(
  471. ConfigMigrateV1.migrate({
  472. command: {
  473. review: {
  474. template: "Review changes",
  475. description: "Review code",
  476. agent: "reviewer",
  477. model: "anthropic/claude",
  478. variant: "high",
  479. subtask: true,
  480. },
  481. },
  482. }).commands,
  483. ).toEqual({
  484. review: {
  485. template: "Review changes",
  486. description: "Review code",
  487. agent: "reviewer",
  488. model: { providerID: "anthropic", model: "claude", variant: "high" },
  489. subtask: true,
  490. },
  491. })
  492. }),
  493. )
  494. it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
  495. Effect.sync(() => {
  496. expect(
  497. ConfigMigrateV1.migrate({
  498. permission: {
  499. task: "ask",
  500. bash: { "git status": "allow", "*": "deny" },
  501. write: "deny",
  502. read: "allow",
  503. },
  504. }).permissions,
  505. ).toEqual([
  506. { action: "subagent", resource: "*", effect: "ask" },
  507. { action: "shell", resource: "git status", effect: "allow" },
  508. { action: "shell", resource: "*", effect: "deny" },
  509. { action: "edit", resource: "*", effect: "deny" },
  510. { action: "read", resource: "*", effect: "allow" },
  511. ])
  512. }),
  513. )
  514. it.live("returns an empty configuration when directory files do not exist", () =>
  515. Effect.acquireRelease(
  516. Effect.promise(() => tmpdir()),
  517. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  518. ).pipe(
  519. Effect.flatMap((tmp) =>
  520. Effect.gen(function* () {
  521. const config = yield* Config.Service
  522. const entries = yield* config.entries()
  523. expect(entries).toEqual([
  524. new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  525. ])
  526. }).pipe(Effect.provide(testLayer(tmp.path))),
  527. ),
  528. ),
  529. )
  530. it.live("does not watch ecosystem config roots", () =>
  531. Effect.acquireRelease(
  532. Effect.promise(() => tmpdir()),
  533. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  534. ).pipe(
  535. Effect.flatMap((tmp) =>
  536. Effect.gen(function* () {
  537. yield* Effect.promise(() =>
  538. Promise.all([
  539. fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
  540. fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
  541. ]),
  542. )
  543. return yield* Effect.gen(function* () {
  544. const config = yield* Config.Service
  545. const watcher = yield* Watcher.Test
  546. yield* config.entries()
  547. expect(yield* watcher.subscriptions()).toEqual([
  548. {
  549. type: "directory",
  550. path: AbsolutePath.make(path.join(tmp.path, "global")),
  551. ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
  552. },
  553. ])
  554. }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
  555. }),
  556. ),
  557. ),
  558. )
  559. it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
  560. Effect.acquireRelease(
  561. Effect.promise(() => tmpdir()),
  562. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  563. ).pipe(
  564. Effect.flatMap((tmp) =>
  565. Effect.gen(function* () {
  566. yield* Effect.promise(() =>
  567. Promise.all([
  568. fs.writeFile(
  569. path.join(tmp.path, "opencode.json"),
  570. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  571. ),
  572. fs.writeFile(
  573. path.join(tmp.path, "opencode.jsonc"),
  574. `{
  575. // Later global files override scalar fields while retaining providers.
  576. "$schema": "last",
  577. "providers": { "last": ${JSON.stringify(provider)} },
  578. }`,
  579. ),
  580. ]),
  581. )
  582. return yield* Effect.gen(function* () {
  583. const config = yield* Config.Service
  584. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  585. expect(documents).toHaveLength(2)
  586. expect(documents.map((document) => document.type)).toEqual(["document", "document"])
  587. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
  588. expect(documents[0]).toBeInstanceOf(Config.Document)
  589. expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
  590. expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  591. yield* Effect.promise(() =>
  592. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  593. )
  594. expect(
  595. (yield* config.entries())
  596. .filter((entry) => entry.type === "document")
  597. .map((document) => document.info.$schema),
  598. ).toEqual(["base", "last"])
  599. }).pipe(Effect.provide(testLayer(tmp.path)))
  600. }),
  601. ),
  602. ),
  603. )
  604. it.live("substitutes environment variables and relative file contents", () =>
  605. Effect.acquireUseRelease(
  606. Effect.sync(() => {
  607. const previous = {
  608. token: process.env.OPENCODE_TEST_MCP_TOKEN,
  609. missing: process.env.OPENCODE_TEST_MISSING,
  610. }
  611. process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
  612. delete process.env.OPENCODE_TEST_MISSING
  613. return previous
  614. }),
  615. () =>
  616. Effect.acquireUseRelease(
  617. Effect.promise(() => tmpdir()),
  618. (tmp) =>
  619. Effect.gen(function* () {
  620. yield* Effect.promise(() =>
  621. Promise.all([
  622. fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
  623. fs.writeFile(
  624. path.join(tmp.path, "opencode.jsonc"),
  625. `{
  626. // Ignored reference: {file:missing.txt}
  627. "username": "user-{env:OPENCODE_TEST_MISSING}",
  628. "mcp": {
  629. "servers": {
  630. "remote": {
  631. "type": "remote",
  632. "url": "https://example.com/mcp",
  633. "headers": {
  634. "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
  635. "X-Token": "{file:token.txt}"
  636. }
  637. }
  638. }
  639. }
  640. }`,
  641. ),
  642. ]),
  643. )
  644. return yield* Effect.gen(function* () {
  645. const config = yield* Config.Service
  646. const document = (yield* config.entries()).find((entry) => entry.type === "document")
  647. expect(document?.info.username).toBe("user-")
  648. const remote = document?.info.mcp?.servers?.remote
  649. expect(remote?.type).toBe("remote")
  650. if (remote?.type !== "remote") return
  651. expect(remote.headers).toEqual({
  652. Authorization: "Bearer secret",
  653. "X-Token": 'file\n"token"',
  654. })
  655. }).pipe(Effect.provide(testLayer(tmp.path)))
  656. }),
  657. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  658. ),
  659. (previous) =>
  660. Effect.sync(() => {
  661. if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
  662. else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
  663. if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
  664. else process.env.OPENCODE_TEST_MISSING = previous.missing
  665. }),
  666. ),
  667. )
  668. it.live("does not load legacy config.json files", () =>
  669. Effect.acquireRelease(
  670. Effect.promise(() => tmpdir()),
  671. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  672. ).pipe(
  673. Effect.flatMap((tmp) =>
  674. Effect.gen(function* () {
  675. yield* Effect.promise(() =>
  676. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
  677. )
  678. return yield* Effect.gen(function* () {
  679. const config = yield* Config.Service
  680. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  681. expect(documents).toHaveLength(0)
  682. }).pipe(Effect.provide(testLayer(tmp.path)))
  683. }),
  684. ),
  685. ),
  686. )
  687. it.live("accepts $schema metadata without writing it into config files", () =>
  688. Effect.acquireRelease(
  689. Effect.promise(() => tmpdir()),
  690. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  691. ).pipe(
  692. Effect.flatMap((tmp) =>
  693. Effect.gen(function* () {
  694. const file = path.join(tmp.path, "opencode.json")
  695. const contents = JSON.stringify({
  696. shell: "/bin/zsh",
  697. providers: { local: provider },
  698. })
  699. yield* Effect.promise(() => fs.writeFile(file, contents))
  700. return yield* Effect.gen(function* () {
  701. const config = yield* Config.Service
  702. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  703. expect(documents[0]?.info.$schema).toBeUndefined()
  704. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  705. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  706. }).pipe(Effect.provide(testLayer(tmp.path)))
  707. }),
  708. ),
  709. ),
  710. )
  711. it.live("loads supported scalar and resource configuration", () =>
  712. Effect.acquireRelease(
  713. Effect.promise(() => tmpdir()),
  714. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  715. ).pipe(
  716. Effect.flatMap((tmp) =>
  717. Effect.gen(function* () {
  718. yield* Effect.promise(() =>
  719. fs.writeFile(
  720. path.join(tmp.path, "opencode.json"),
  721. JSON.stringify({
  722. shell: "/bin/bash",
  723. model: "anthropic/claude",
  724. default_agent: "reviewer",
  725. autoupdate: "notify",
  726. share: "disabled",
  727. enterprise: { url: "https://share.example.com" },
  728. username: "test-user",
  729. permissions: [
  730. { action: "bash", resource: "*", effect: "ask" },
  731. { action: "bash", resource: "git status", effect: "allow" },
  732. ],
  733. agents: {
  734. reviewer: {
  735. model: "openrouter/openai/gpt-5#high",
  736. request: {
  737. headers: { "x-agent": "reviewer" },
  738. body: { reasoningEffort: "high" },
  739. },
  740. description: "Review changes for correctness",
  741. system: "Find regressions.",
  742. mode: "subagent",
  743. hidden: false,
  744. color: "#ff6b6b",
  745. steps: 12,
  746. disabled: false,
  747. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  748. },
  749. },
  750. snapshots: false,
  751. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  752. formatter: {
  753. prettier: { disabled: true },
  754. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  755. },
  756. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  757. attachments: {
  758. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  759. },
  760. tool_output: { max_lines: 1000, max_bytes: 32768 },
  761. mcp: {
  762. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  763. servers: {
  764. local: {
  765. type: "local",
  766. command: ["node", "./mcp/server.js"],
  767. environment: { API_KEY: "secret" },
  768. disabled: false,
  769. codemode: false,
  770. timeout: { catalog: 10000 },
  771. },
  772. remote: {
  773. type: "remote",
  774. url: "https://mcp.example.com/mcp",
  775. headers: { Authorization: "Bearer token" },
  776. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  777. disabled: true,
  778. codemode: false,
  779. timeout: { startup: 15000 },
  780. },
  781. },
  782. },
  783. compaction: {
  784. auto: true,
  785. prune: false,
  786. keep: { tokens: 2000 },
  787. buffer: 10000,
  788. },
  789. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  790. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  791. references: {
  792. local: { path: "../library" },
  793. sdk: { repository: "github.com/example/sdk", branch: "main" },
  794. shorthand: "github.com/example/docs",
  795. },
  796. plugins: [
  797. "opencode-helicone-session",
  798. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  799. ],
  800. }),
  801. ),
  802. )
  803. return yield* Effect.gen(function* () {
  804. const config = yield* Config.Service
  805. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  806. expect(documents).toHaveLength(1)
  807. expect(documents[0]?.info.shell).toBe("/bin/bash")
  808. expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
  809. expect(documents[0]?.info.default_agent).toBe("reviewer")
  810. expect(documents[0]?.info.autoupdate).toBe("notify")
  811. expect(documents[0]?.info.share).toBe("disabled")
  812. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  813. expect(documents[0]?.info.username).toBe("test-user")
  814. expect(documents[0]?.info.permissions).toEqual([
  815. { action: "bash", resource: "*", effect: "ask" },
  816. { action: "bash", resource: "git status", effect: "allow" },
  817. ])
  818. const reviewer = documents[0]?.info.agents?.reviewer
  819. expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high"))
  820. expect(reviewer?.request).toEqual({
  821. headers: { "x-agent": "reviewer" },
  822. body: { reasoningEffort: "high" },
  823. })
  824. expect(reviewer?.description).toBe("Review changes for correctness")
  825. expect(reviewer?.system).toBe("Find regressions.")
  826. expect(reviewer?.mode).toBe("subagent")
  827. expect(reviewer?.hidden).toBe(false)
  828. expect(reviewer?.color).toBe("#ff6b6b")
  829. expect(reviewer?.steps).toBe(12)
  830. expect(reviewer?.disabled).toBe(false)
  831. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  832. expect(documents[0]?.info.snapshots).toBe(false)
  833. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  834. expect(documents[0]?.info.formatter).toEqual({
  835. prettier: { disabled: true },
  836. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  837. })
  838. expect(documents[0]?.info.lsp).toEqual({
  839. typescript: { disabled: true },
  840. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  841. })
  842. expect(documents[0]?.info.attachments).toEqual({
  843. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  844. })
  845. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  846. expect(documents[0]?.info.mcp).toEqual({
  847. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  848. servers: {
  849. local: {
  850. type: "local",
  851. command: ["node", "./mcp/server.js"],
  852. environment: { API_KEY: "secret" },
  853. disabled: false,
  854. codemode: false,
  855. timeout: { catalog: 10000 },
  856. },
  857. remote: {
  858. type: "remote",
  859. url: "https://mcp.example.com/mcp",
  860. headers: { Authorization: "Bearer token" },
  861. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  862. disabled: true,
  863. codemode: false,
  864. timeout: { startup: 15000 },
  865. },
  866. },
  867. })
  868. expect(documents[0]?.info.compaction).toEqual({
  869. auto: true,
  870. keep: { tokens: 2000 },
  871. buffer: 10000,
  872. })
  873. expect(documents[0]?.info.skills).toEqual([
  874. "./skills",
  875. "~/shared-skills",
  876. "https://example.com/.well-known/skills/",
  877. ])
  878. expect(documents[0]?.info.instructions).toEqual([
  879. "CONTRIBUTING.md",
  880. ".cursor/rules/*.md",
  881. "https://example.com/shared-rules.md",
  882. ])
  883. expect(documents[0]?.info.references).toEqual({
  884. local: { path: "../library" },
  885. sdk: { repository: "github.com/example/sdk", branch: "main" },
  886. shorthand: "github.com/example/docs",
  887. })
  888. expect(documents[0]?.info.plugins).toEqual([
  889. "opencode-helicone-session",
  890. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  891. ])
  892. }).pipe(Effect.provide(testLayer(tmp.path)))
  893. }),
  894. ),
  895. ),
  896. )
  897. it.live("migrates the deprecated reference key into references", () =>
  898. Effect.acquireRelease(
  899. Effect.promise(() => tmpdir()),
  900. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  901. ).pipe(
  902. Effect.flatMap((tmp) =>
  903. Effect.gen(function* () {
  904. yield* Effect.promise(() =>
  905. fs.writeFile(
  906. path.join(tmp.path, "opencode.json"),
  907. JSON.stringify({
  908. reference: {
  909. local: { path: "../library" },
  910. sdk: { repository: "github.com/example/sdk", branch: "main" },
  911. shorthand: "github.com/example/docs",
  912. },
  913. }),
  914. ),
  915. )
  916. return yield* Effect.gen(function* () {
  917. const config = yield* Config.Service
  918. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  919. expect(documents).toHaveLength(1)
  920. expect(documents[0]?.info.references).toEqual({
  921. local: { path: "../library" },
  922. sdk: { repository: "github.com/example/sdk", branch: "main" },
  923. shorthand: "github.com/example/docs",
  924. })
  925. }).pipe(Effect.provide(testLayer(tmp.path)))
  926. }),
  927. ),
  928. ),
  929. )
  930. it.live("migrates v1 configuration when a v1-only key is present", () =>
  931. Effect.acquireRelease(
  932. Effect.promise(() => tmpdir()),
  933. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  934. ).pipe(
  935. Effect.flatMap((tmp) =>
  936. Effect.gen(function* () {
  937. yield* Effect.promise(() =>
  938. fs.writeFile(
  939. path.join(tmp.path, "opencode.json"),
  940. JSON.stringify({
  941. shell: "/bin/zsh",
  942. default_agent: "reviewer",
  943. snapshot: false,
  944. autoshare: true,
  945. permission: {
  946. bash: "ask",
  947. edit: { "*.md": "allow", "*": "deny" },
  948. question: "deny",
  949. },
  950. agent: {
  951. reviewer: {
  952. prompt: "Review changes.",
  953. disable: true,
  954. temperature: 0.2,
  955. permission: { read: "allow" },
  956. },
  957. },
  958. plugin: [
  959. "opencode-helicone-session",
  960. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  961. ],
  962. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  963. references: {
  964. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  965. },
  966. attachment: { image: { auto_resize: false, max_width: 1200 } },
  967. provider: {
  968. custom: {
  969. options: { apiKey: "secret" },
  970. models: {
  971. model: {
  972. options: { reasoningEffort: "high" },
  973. variants: { fast: { temperature: 0.2 } },
  974. },
  975. },
  976. },
  977. openai: {
  978. npm: "@ai-sdk/openai",
  979. options: { apiKey: "secret", organization: "org" },
  980. models: {
  981. model: {
  982. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  983. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  984. },
  985. },
  986. },
  987. anthropic: {
  988. npm: "@ai-sdk/anthropic",
  989. models: {
  990. model: {
  991. options: {
  992. effort: "high",
  993. taskBudget: 4096,
  994. metadata: { userId: "user-1" },
  995. },
  996. },
  997. },
  998. },
  999. },
  1000. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  1001. experimental: { mcp_timeout: 5000 },
  1002. mcp: {
  1003. local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
  1004. remote: {
  1005. type: "remote",
  1006. url: "https://mcp.example.com",
  1007. oauth: { clientId: "client", callbackPort: 19876 },
  1008. timeout: 20000,
  1009. },
  1010. },
  1011. }),
  1012. ),
  1013. )
  1014. return yield* Effect.gen(function* () {
  1015. const config = yield* Config.Service
  1016. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1017. expect(documents).toHaveLength(1)
  1018. expect(documents[0]?.info).toBeInstanceOf(Config.Info)
  1019. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  1020. expect(documents[0]?.info.default_agent).toBe("reviewer")
  1021. expect(documents[0]?.info.snapshots).toBe(false)
  1022. expect(documents[0]?.info.share).toBe("auto")
  1023. expect(documents[0]?.info.permissions).toEqual([
  1024. { action: "shell", resource: "*", effect: "ask" },
  1025. { action: "edit", resource: "*.md", effect: "allow" },
  1026. { action: "edit", resource: "*", effect: "deny" },
  1027. { action: "question", resource: "*", effect: "deny" },
  1028. ])
  1029. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  1030. system: "Review changes.",
  1031. disabled: true,
  1032. request: { body: { temperature: 0.2 } },
  1033. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  1034. })
  1035. expect(documents[0]?.info.plugins).toEqual([
  1036. "opencode-helicone-session",
  1037. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1038. ])
  1039. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  1040. expect(documents[0]?.info.references).toEqual({
  1041. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  1042. })
  1043. expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  1044. expect(documents[0]?.info.providers?.custom).toMatchObject({
  1045. settings: { apiKey: "secret" },
  1046. models: {
  1047. model: {
  1048. settings: { reasoningEffort: "high" },
  1049. variants: [{ id: "fast", settings: { temperature: 0.2 } }],
  1050. },
  1051. },
  1052. })
  1053. expect(documents[0]?.info.providers?.openai).toMatchObject({
  1054. package: Provider.aisdk("@ai-sdk/openai"),
  1055. settings: { apiKey: "secret", organization: "org" },
  1056. models: {
  1057. model: {
  1058. settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  1059. variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }],
  1060. },
  1061. },
  1062. })
  1063. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  1064. package: Provider.aisdk("@ai-sdk/anthropic"),
  1065. models: {
  1066. model: {
  1067. settings: {
  1068. effort: "high",
  1069. taskBudget: 4096,
  1070. metadata: { userId: "user-1" },
  1071. },
  1072. },
  1073. },
  1074. })
  1075. expect(documents[0]?.info.compaction).toEqual({
  1076. auto: true,
  1077. keep: { tokens: 2000 },
  1078. buffer: 10000,
  1079. })
  1080. expect(documents[0]?.info.mcp).toMatchObject({
  1081. timeout: { catalog: 5000, execution: 5000 },
  1082. servers: {
  1083. local: {
  1084. type: "local",
  1085. command: ["node", "server.js"],
  1086. disabled: true,
  1087. timeout: { catalog: 10000, execution: 10000 },
  1088. },
  1089. remote: {
  1090. type: "remote",
  1091. url: "https://mcp.example.com",
  1092. oauth: { client_id: "client", callback_port: 19876 },
  1093. timeout: { catalog: 20000, execution: 20000 },
  1094. },
  1095. },
  1096. })
  1097. }).pipe(Effect.provide(testLayer(tmp.path)))
  1098. }),
  1099. ),
  1100. ),
  1101. )
  1102. it.live("ignores an invalid file while loading valid config values", () =>
  1103. Effect.acquireRelease(
  1104. Effect.promise(() => tmpdir()),
  1105. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1106. ).pipe(
  1107. Effect.flatMap((tmp) =>
  1108. Effect.gen(function* () {
  1109. yield* Effect.promise(() =>
  1110. Promise.all([
  1111. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
  1112. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
  1113. ]),
  1114. )
  1115. return yield* Effect.gen(function* () {
  1116. const config = yield* Config.Service
  1117. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1118. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  1119. }).pipe(Effect.provide(testLayer(tmp.path)))
  1120. }),
  1121. ),
  1122. ),
  1123. )
  1124. it.live("loads global and ancestor configuration across the project boundary", () =>
  1125. Effect.acquireRelease(
  1126. Effect.promise(() => tmpdir()),
  1127. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1128. ).pipe(
  1129. Effect.flatMap((tmp) => {
  1130. const global = path.join(tmp.path, "global")
  1131. const root = path.join(tmp.path, "repo")
  1132. const parent = path.join(root, "packages")
  1133. const directory = path.join(parent, "app")
  1134. const globalAgents = path.join(global, "home", ".agents")
  1135. const globalClaude = path.join(global, "home", ".claude")
  1136. return Effect.gen(function* () {
  1137. yield* Effect.promise(async () => {
  1138. await fs.mkdir(global, { recursive: true })
  1139. await fs.mkdir(globalAgents, { recursive: true })
  1140. await fs.mkdir(globalClaude, { recursive: true })
  1141. await fs.mkdir(directory, { recursive: true })
  1142. await fs.mkdir(path.join(root, ".agents"), { recursive: true })
  1143. await fs.mkdir(path.join(root, ".claude"), { recursive: true })
  1144. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  1145. await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
  1146. await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
  1147. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  1148. await Promise.all([
  1149. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  1150. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  1151. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  1152. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  1153. fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
  1154. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  1155. fs.writeFile(
  1156. path.join(directory, ".opencode", "opencode.jsonc"),
  1157. JSON.stringify({ $schema: "directory-dot" }),
  1158. ),
  1159. ])
  1160. })
  1161. return yield* Effect.gen(function* () {
  1162. const config = yield* Config.Service
  1163. const entries = yield* config.entries()
  1164. const documents = entries.filter((entry) => entry.type === "document")
  1165. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  1166. AbsolutePath.make(global),
  1167. AbsolutePath.make(path.join(root, ".opencode")),
  1168. AbsolutePath.make(path.join(directory, ".opencode")),
  1169. ])
  1170. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  1171. AbsolutePath.make(globalAgents),
  1172. AbsolutePath.make(path.join(directory, ".agents")),
  1173. AbsolutePath.make(path.join(root, ".agents")),
  1174. ])
  1175. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  1176. AbsolutePath.make(globalClaude),
  1177. AbsolutePath.make(path.join(directory, ".claude")),
  1178. AbsolutePath.make(path.join(root, ".claude")),
  1179. ])
  1180. expect(documents.map((document) => document.info.$schema)).toEqual([
  1181. "global",
  1182. "outside",
  1183. "root",
  1184. "parent",
  1185. "directory",
  1186. "root-dot",
  1187. "directory-dot",
  1188. ])
  1189. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  1190. AbsolutePath.make(globalClaude),
  1191. AbsolutePath.make(path.join(directory, ".claude")),
  1192. AbsolutePath.make(path.join(root, ".claude")),
  1193. AbsolutePath.make(globalAgents),
  1194. AbsolutePath.make(path.join(directory, ".agents")),
  1195. AbsolutePath.make(path.join(root, ".agents")),
  1196. "global",
  1197. AbsolutePath.make(global),
  1198. "outside",
  1199. AbsolutePath.make(path.join(tmp.path, "opencode.json")),
  1200. "root",
  1201. AbsolutePath.make(path.join(root, "opencode.json")),
  1202. "parent",
  1203. AbsolutePath.make(path.join(parent, "opencode.jsonc")),
  1204. "directory",
  1205. AbsolutePath.make(path.join(directory, "opencode.json")),
  1206. "root-dot",
  1207. AbsolutePath.make(path.join(root, ".opencode")),
  1208. "directory-dot",
  1209. AbsolutePath.make(path.join(directory, ".opencode")),
  1210. ])
  1211. }).pipe(
  1212. Effect.provide(
  1213. testLayer(directory, global, root, {
  1214. type: "git",
  1215. store: AbsolutePath.make(path.join(root, ".git")),
  1216. }),
  1217. ),
  1218. )
  1219. })
  1220. }),
  1221. ),
  1222. )
  1223. })