config.test.ts 62 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
  5. import { FastCheck } from "effect/testing"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
  8. import { ConfigModel } from "@opencode-ai/schema/config/model"
  9. import { ConfigProvider } from "@opencode-ai/schema/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(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(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(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 Document({ type: "document", info: new 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 Document({
  275. type: "document",
  276. info: new Info({ model: selection("openrouter/openai/gpt-5") }),
  277. }),
  278. new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
  279. new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
  280. new Document({ type: "document", info: new Info({}) }),
  281. new Document({
  282. type: "document",
  283. info: new 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 before user configuration", () =>
  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. const initial = yield* config.entries()
  352. expect(Config.latest(initial, "shell")).toBe("project")
  353. expect(
  354. initial.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
  355. ).toEqual(["secret", "global", "project"])
  356. const updated = yield* bus
  357. .subscribe(Event.Updated)
  358. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  359. yield* Effect.yieldNow
  360. key = "next"
  361. yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
  362. expect(yield* Fiber.join(updated)).toHaveLength(1)
  363. const refreshed = yield* config.entries()
  364. expect(Config.latest(refreshed, "shell")).toBe("project")
  365. expect(
  366. refreshed.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
  367. ).toEqual(["next", "global", "project"])
  368. }).pipe(
  369. Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
  370. )
  371. }),
  372. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  373. ),
  374. )
  375. it.live("logs redacted source-aware diagnostics for every config source", () => {
  376. const output: Array<Record<string, unknown>> = []
  377. const logger = Logger.map(Logger.formatStructured, (entry) => {
  378. if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
  379. const details = entry.message[1]
  380. if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
  381. })
  382. return Effect.acquireUseRelease(
  383. Effect.promise(() => tmpdir()),
  384. (tmp) =>
  385. Effect.gen(function* () {
  386. const global = path.join(tmp.path, "global")
  387. const project = path.join(tmp.path, "project")
  388. const malformed = path.join(tmp.path, "malformed.json")
  389. yield* Effect.promise(async () => {
  390. await fs.mkdir(global, { recursive: true })
  391. await fs.mkdir(project, { recursive: true })
  392. await fs.writeFile(path.join(global, "opencode.json"), "null")
  393. await fs.writeFile(path.join(project, "opencode.json"), "")
  394. await fs.writeFile(malformed, '{ "credential": "file-secret"')
  395. })
  396. const integrationID = Integration.ID.make("https://invalid.example.com")
  397. const entry: WellKnown.Entry = {
  398. origin: "https://invalid.example.com",
  399. integrationID,
  400. manifest: { auth: { command: ["login"], env: "TOKEN" } },
  401. }
  402. const credentialNode = makeGlobalNode({
  403. service: Credential.Service,
  404. layer: Layer.succeed(
  405. Credential.Service,
  406. Credential.Service.of({
  407. all: () => Effect.die("unused Credential.all"),
  408. list: () =>
  409. Effect.succeed([
  410. new Credential.Info({
  411. id: Credential.ID.create(),
  412. integrationID,
  413. label: "default",
  414. value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
  415. }),
  416. ]),
  417. get: () => Effect.die("unused Credential.get"),
  418. create: () => Effect.die("unused Credential.create"),
  419. update: () => Effect.die("unused Credential.update"),
  420. remove: () => Effect.die("unused Credential.remove"),
  421. }),
  422. ),
  423. deps: [],
  424. })
  425. const wellknownNode = makeGlobalNode({
  426. service: WellKnown.Service,
  427. layer: Layer.succeed(
  428. WellKnown.Service,
  429. WellKnown.Service.of({
  430. entries: () => Effect.succeed([entry]),
  431. snapshot: () => [entry],
  432. refresh: () => Effect.succeed(false),
  433. add: () => Effect.die("unused Wellknown.add"),
  434. remove: () => Effect.die("unused Wellknown.remove"),
  435. // Exercise the loader boundary against a malformed implementation response.
  436. resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
  437. }),
  438. ),
  439. deps: [],
  440. })
  441. yield* Config.Service.use((config) => config.entries()).pipe(
  442. Effect.provide(
  443. testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
  444. file: malformed,
  445. content: "",
  446. }),
  447. ),
  448. )
  449. expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
  450. [
  451. `${path.join(global, "opencode.json")}:$:invalid`,
  452. `${path.join(project, "opencode.json")}:$:invalid`,
  453. `${malformed}:$:invalid`,
  454. "https://invalid.example.com:$:invalid",
  455. "OPENCODE_CONFIG_CONTENT:$:invalid",
  456. ].toSorted(),
  457. )
  458. expect(JSON.stringify(output)).not.toContain("secret")
  459. }),
  460. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  461. ).pipe(Effect.provide(Logger.layer([logger])))
  462. })
  463. it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
  464. Effect.sync(() => {
  465. FastCheck.assert(
  466. FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
  467. const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
  468. Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(
  469. Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
  470. ),
  471. )
  472. Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
  473. }),
  474. { numRuns: 100 },
  475. )
  476. }),
  477. )
  478. it.effect("migrates the v1 experimental subagent depth", () =>
  479. Effect.sync(() => {
  480. expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
  481. }),
  482. )
  483. it.effect("migrates the v1 small model to the title agent", () =>
  484. Effect.sync(() => {
  485. expect(
  486. ConfigMigrateV1.migrate({
  487. small_model: "anthropic/claude-haiku-4-5",
  488. agent: { title: { prompt: "Custom title prompt" } },
  489. }).agents?.title,
  490. ).toEqual({
  491. model: { providerID: "anthropic", model: "claude-haiku-4-5" },
  492. system: "Custom title prompt",
  493. })
  494. }),
  495. )
  496. it.effect("migrates v1 provider lists to policies", () =>
  497. Effect.sync(() => {
  498. expect(
  499. ConfigMigrateV1.migrate({
  500. enabled_providers: ["anthropic", "openai"],
  501. disabled_providers: ["openai"],
  502. }).experimental?.policies,
  503. ).toEqual([
  504. { action: "provider.use", resource: "*", effect: "deny" },
  505. { action: "provider.use", resource: "anthropic", effect: "allow" },
  506. { action: "provider.use", resource: "openai", effect: "allow" },
  507. { action: "provider.use", resource: "openai", effect: "deny" },
  508. ])
  509. expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
  510. { action: "provider.use", resource: "*", effect: "deny" },
  511. ])
  512. }),
  513. )
  514. it.effect("migrates v1 provider setup options into AISDK settings", () =>
  515. Effect.sync(() => {
  516. const migrated = ConfigMigrateV1.migrate({
  517. provider: {
  518. bedrock: {
  519. npm: "@ai-sdk/amazon-bedrock",
  520. options: {
  521. headers: { "x-test": "1" },
  522. body: { trace: true },
  523. region: "us-east-1",
  524. profile: "dev",
  525. },
  526. },
  527. },
  528. })
  529. expect(migrated.providers?.bedrock).toMatchObject({
  530. package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
  531. settings: { region: "us-east-1", profile: "dev" },
  532. headers: { "x-test": "1" },
  533. body: { trace: true },
  534. })
  535. }),
  536. )
  537. it.effect("renames old provider IDs while migrating v1 configuration", () =>
  538. Effect.sync(() => {
  539. const migrated = ConfigMigrateV1.migrate({
  540. model: "azure-cognitive-services/deployment",
  541. enabled_providers: ["google-vertex-anthropic"],
  542. disabled_providers: ["azure-cognitive-services"],
  543. agent: {
  544. reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
  545. },
  546. command: {
  547. review: { template: "Review", model: "azure-cognitive-services/deployment" },
  548. },
  549. provider: {
  550. "azure-cognitive-services": {
  551. npm: "@ai-sdk/azure",
  552. env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
  553. models: { deployment: {} },
  554. },
  555. "google-vertex-anthropic": {
  556. npm: "@ai-sdk/google-vertex/anthropic",
  557. options: { project: "test-project", location: "us-central1" },
  558. models: { "claude-sonnet": {} },
  559. },
  560. },
  561. })
  562. expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
  563. expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
  564. expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
  565. expect(migrated.experimental?.policies).toEqual([
  566. { action: "provider.use", resource: "*", effect: "deny" },
  567. { action: "provider.use", resource: "google-vertex", effect: "allow" },
  568. { action: "provider.use", resource: "azure", effect: "deny" },
  569. ])
  570. expect(migrated.providers?.azure).toMatchObject({
  571. env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
  572. package: Provider.aisdk("@ai-sdk/azure"),
  573. models: { deployment: {} },
  574. })
  575. expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
  576. expect(migrated.providers?.["google-vertex"]).toMatchObject({
  577. settings: { project: "test-project", location: "us-central1" },
  578. models: {
  579. "claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
  580. },
  581. })
  582. expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
  583. expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
  584. }),
  585. )
  586. it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
  587. Effect.sync(() => {
  588. const migrated = ConfigMigrateV1.migrate({
  589. provider: {
  590. "azure-cognitive-services": {
  591. npm: "@ai-sdk/openai-compatible",
  592. env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
  593. },
  594. },
  595. })
  596. expect(migrated.providers?.azure).toMatchObject({
  597. env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
  598. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  599. settings: {
  600. baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
  601. },
  602. })
  603. }),
  604. )
  605. it.effect("ignores old provider IDs when the current provider ID is configured", () =>
  606. Effect.sync(() => {
  607. const migrated = ConfigMigrateV1.migrate({
  608. provider: {
  609. azure: { models: { current: {} } },
  610. "azure-cognitive-services": { models: { legacy: {} } },
  611. "google-vertex": { models: { gemini: {} } },
  612. "google-vertex-anthropic": { models: { claude: {} } },
  613. },
  614. })
  615. expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
  616. expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
  617. }),
  618. )
  619. it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
  620. Effect.sync(() => {
  621. const migrated = ConfigMigrateV1.migrate({
  622. provider: {
  623. "google-vertex-anthropic": {
  624. models: { claude: {} },
  625. },
  626. },
  627. })
  628. expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
  629. expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
  630. Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
  631. )
  632. }),
  633. )
  634. it.effect("migrates v1 interleaved fields to compatibility", () =>
  635. Effect.sync(() => {
  636. const migrated = ConfigMigrateV1.migrate({
  637. provider: {
  638. custom: {
  639. models: {
  640. object: { interleaved: { field: "vendor_reasoning" } },
  641. string: { interleaved: "reasoning_text" },
  642. boolean: { interleaved: true },
  643. },
  644. },
  645. },
  646. })
  647. expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
  648. reasoningField: "vendor_reasoning",
  649. })
  650. expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
  651. expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
  652. }),
  653. )
  654. it.effect("migrates v1 command configuration", () =>
  655. Effect.sync(() => {
  656. expect(
  657. ConfigMigrateV1.migrate({
  658. command: {
  659. review: {
  660. template: "Review changes",
  661. description: "Review code",
  662. agent: "reviewer",
  663. model: "anthropic/claude",
  664. variant: "high",
  665. subtask: true,
  666. },
  667. },
  668. }).commands,
  669. ).toEqual({
  670. review: {
  671. template: "Review changes",
  672. description: "Review code",
  673. agent: "reviewer",
  674. model: { providerID: "anthropic", model: "claude", variant: "high" },
  675. subtask: true,
  676. },
  677. })
  678. }),
  679. )
  680. it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
  681. Effect.sync(() => {
  682. expect(
  683. ConfigMigrateV1.migrate({
  684. permission: {
  685. task: "ask",
  686. bash: { "git status": "allow", "*": "deny" },
  687. write: "deny",
  688. read: "allow",
  689. },
  690. }).permissions,
  691. ).toEqual([
  692. { action: "subagent", resource: "*", effect: "ask" },
  693. { action: "shell", resource: "git status", effect: "allow" },
  694. { action: "shell", resource: "*", effect: "deny" },
  695. { action: "edit", resource: "*", effect: "deny" },
  696. { action: "read", resource: "*", effect: "allow" },
  697. ])
  698. }),
  699. )
  700. it.live("returns an empty configuration when directory files do not exist", () =>
  701. Effect.acquireRelease(
  702. Effect.promise(() => tmpdir()),
  703. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  704. ).pipe(
  705. Effect.flatMap((tmp) =>
  706. Effect.gen(function* () {
  707. const config = yield* Config.Service
  708. const entries = yield* config.entries()
  709. expect(entries).toEqual([
  710. new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  711. ])
  712. }).pipe(Effect.provide(testLayer(tmp.path))),
  713. ),
  714. ),
  715. )
  716. it.live("deduplicates global ecosystem directories found during upward discovery", () =>
  717. Effect.acquireRelease(
  718. Effect.promise(() => tmpdir()),
  719. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  720. ).pipe(
  721. Effect.flatMap((tmp) =>
  722. Effect.gen(function* () {
  723. const global = path.join(tmp.path, "global")
  724. const home = path.join(global, "home")
  725. const project = path.join(home, "project")
  726. yield* Effect.promise(() =>
  727. Promise.all([
  728. fs.mkdir(path.join(home, ".claude"), { recursive: true }),
  729. fs.mkdir(path.join(home, ".agents"), { recursive: true }),
  730. fs.mkdir(project, { recursive: true }),
  731. ]),
  732. )
  733. const entries = yield* Config.Service.use((config) => config.entries()).pipe(
  734. Effect.provide(testLayer(project, global)),
  735. )
  736. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  737. AbsolutePath.make(path.join(home, ".claude")),
  738. ])
  739. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  740. AbsolutePath.make(path.join(home, ".agents")),
  741. ])
  742. }),
  743. ),
  744. ),
  745. )
  746. it.live("does not watch ecosystem config roots", () =>
  747. Effect.acquireRelease(
  748. Effect.promise(() => tmpdir()),
  749. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  750. ).pipe(
  751. Effect.flatMap((tmp) =>
  752. Effect.gen(function* () {
  753. yield* Effect.promise(() =>
  754. Promise.all([
  755. fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
  756. fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
  757. ]),
  758. )
  759. return yield* Effect.gen(function* () {
  760. const config = yield* Config.Service
  761. const watcher = yield* Watcher.Test
  762. yield* config.entries()
  763. expect(yield* watcher.subscriptions()).toEqual([
  764. {
  765. type: "directory",
  766. path: AbsolutePath.make(path.join(tmp.path, "global")),
  767. ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
  768. },
  769. ])
  770. }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
  771. }),
  772. ),
  773. ),
  774. )
  775. it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
  776. Effect.acquireRelease(
  777. Effect.promise(() => tmpdir()),
  778. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  779. ).pipe(
  780. Effect.flatMap((tmp) =>
  781. Effect.gen(function* () {
  782. yield* Effect.promise(() =>
  783. Promise.all([
  784. fs.writeFile(
  785. path.join(tmp.path, "opencode.json"),
  786. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  787. ),
  788. fs.writeFile(
  789. path.join(tmp.path, "opencode.jsonc"),
  790. `{
  791. // Later global files override scalar fields while retaining providers.
  792. "$schema": "last",
  793. "providers": { "last": ${JSON.stringify(provider)} },
  794. }`,
  795. ),
  796. ]),
  797. )
  798. return yield* Effect.gen(function* () {
  799. const config = yield* Config.Service
  800. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  801. expect(documents).toHaveLength(2)
  802. expect(documents.map((document) => document.type)).toEqual(["document", "document"])
  803. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
  804. expect(documents[0]).toBeInstanceOf(Document)
  805. expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
  806. expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  807. yield* Effect.promise(() =>
  808. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  809. )
  810. expect(
  811. (yield* config.entries())
  812. .filter((entry) => entry.type === "document")
  813. .map((document) => document.info.$schema),
  814. ).toEqual(["base", "last"])
  815. }).pipe(Effect.provide(testLayer(tmp.path)))
  816. }),
  817. ),
  818. ),
  819. )
  820. it.live("substitutes environment variables and relative file contents", () =>
  821. Effect.acquireUseRelease(
  822. Effect.sync(() => {
  823. const previous = {
  824. token: process.env.OPENCODE_TEST_MCP_TOKEN,
  825. missing: process.env.OPENCODE_TEST_MISSING,
  826. }
  827. process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
  828. delete process.env.OPENCODE_TEST_MISSING
  829. return previous
  830. }),
  831. () =>
  832. Effect.acquireUseRelease(
  833. Effect.promise(() => tmpdir()),
  834. (tmp) =>
  835. Effect.gen(function* () {
  836. yield* Effect.promise(() =>
  837. Promise.all([
  838. fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
  839. fs.writeFile(
  840. path.join(tmp.path, "opencode.jsonc"),
  841. `{
  842. // Ignored reference: {file:missing.txt}
  843. "username": "user-{env:OPENCODE_TEST_MISSING}",
  844. "mcp": {
  845. "servers": {
  846. "remote": {
  847. "type": "remote",
  848. "url": "https://example.com/mcp",
  849. "headers": {
  850. "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
  851. "X-Token": "{file:token.txt}"
  852. }
  853. }
  854. }
  855. }
  856. }`,
  857. ),
  858. ]),
  859. )
  860. return yield* Effect.gen(function* () {
  861. const config = yield* Config.Service
  862. const document = (yield* config.entries()).find((entry) => entry.type === "document")
  863. expect(document?.info.username).toBe("user-")
  864. const remote = document?.info.mcp?.servers?.remote
  865. expect(remote?.type).toBe("remote")
  866. if (remote?.type !== "remote") return
  867. expect(remote.headers).toEqual({
  868. Authorization: "Bearer secret",
  869. "X-Token": 'file\n"token"',
  870. })
  871. }).pipe(Effect.provide(testLayer(tmp.path)))
  872. }),
  873. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  874. ),
  875. (previous) =>
  876. Effect.sync(() => {
  877. if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
  878. else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
  879. if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
  880. else process.env.OPENCODE_TEST_MISSING = previous.missing
  881. }),
  882. ),
  883. )
  884. it.live("does not load legacy config.json files", () =>
  885. Effect.acquireRelease(
  886. Effect.promise(() => tmpdir()),
  887. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  888. ).pipe(
  889. Effect.flatMap((tmp) =>
  890. Effect.gen(function* () {
  891. yield* Effect.promise(() =>
  892. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
  893. )
  894. return yield* Effect.gen(function* () {
  895. const config = yield* Config.Service
  896. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  897. expect(documents).toHaveLength(0)
  898. }).pipe(Effect.provide(testLayer(tmp.path)))
  899. }),
  900. ),
  901. ),
  902. )
  903. it.live("accepts $schema metadata without writing it into config files", () =>
  904. Effect.acquireRelease(
  905. Effect.promise(() => tmpdir()),
  906. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  907. ).pipe(
  908. Effect.flatMap((tmp) =>
  909. Effect.gen(function* () {
  910. const file = path.join(tmp.path, "opencode.json")
  911. const contents = JSON.stringify({
  912. shell: "/bin/zsh",
  913. providers: { local: provider },
  914. })
  915. yield* Effect.promise(() => fs.writeFile(file, contents))
  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[0]?.info.$schema).toBeUndefined()
  920. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  921. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  922. }).pipe(Effect.provide(testLayer(tmp.path)))
  923. }),
  924. ),
  925. ),
  926. )
  927. it.live("loads supported scalar and resource configuration", () =>
  928. Effect.acquireRelease(
  929. Effect.promise(() => tmpdir()),
  930. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  931. ).pipe(
  932. Effect.flatMap((tmp) =>
  933. Effect.gen(function* () {
  934. yield* Effect.promise(() =>
  935. fs.writeFile(
  936. path.join(tmp.path, "opencode.json"),
  937. JSON.stringify({
  938. shell: "/bin/bash",
  939. model: "anthropic/claude",
  940. default_agent: "reviewer",
  941. autoupdate: "notify",
  942. share: "disabled",
  943. enterprise: { url: "https://share.example.com" },
  944. username: "test-user",
  945. permissions: [
  946. { action: "bash", resource: "*", effect: "ask" },
  947. { action: "bash", resource: "git status", effect: "allow" },
  948. ],
  949. agents: {
  950. reviewer: {
  951. model: "openrouter/openai/gpt-5#high",
  952. request: {
  953. headers: { "x-agent": "reviewer" },
  954. body: { reasoningEffort: "high" },
  955. },
  956. description: "Review changes for correctness",
  957. system: "Find regressions.",
  958. mode: "subagent",
  959. hidden: false,
  960. color: "#ff6b6b",
  961. steps: 12,
  962. disabled: false,
  963. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  964. },
  965. },
  966. snapshots: false,
  967. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  968. formatter: {
  969. prettier: { disabled: true },
  970. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  971. },
  972. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  973. media: {
  974. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  975. },
  976. tool_output: { max_lines: 1000, max_bytes: 32768 },
  977. mcp: {
  978. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  979. servers: {
  980. local: {
  981. type: "local",
  982. command: ["node", "./mcp/server.js"],
  983. environment: { API_KEY: "secret" },
  984. disabled: false,
  985. codemode: false,
  986. timeout: { catalog: 10000 },
  987. },
  988. remote: {
  989. type: "remote",
  990. url: "https://mcp.example.com/mcp",
  991. headers: { Authorization: "Bearer token" },
  992. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  993. disabled: true,
  994. codemode: false,
  995. timeout: { startup: 15000 },
  996. },
  997. },
  998. },
  999. compaction: {
  1000. auto: true,
  1001. prune: false,
  1002. keep: { tokens: 2000 },
  1003. buffer: 10000,
  1004. },
  1005. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  1006. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  1007. references: {
  1008. local: { path: "../library" },
  1009. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1010. shorthand: "github.com/example/docs",
  1011. },
  1012. plugins: [
  1013. "opencode-helicone-session",
  1014. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1015. ],
  1016. }),
  1017. ),
  1018. )
  1019. return yield* Effect.gen(function* () {
  1020. const config = yield* Config.Service
  1021. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1022. expect(documents).toHaveLength(1)
  1023. expect(documents[0]?.info.shell).toBe("/bin/bash")
  1024. expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
  1025. expect(documents[0]?.info.default_agent).toBe("reviewer")
  1026. expect(documents[0]?.info.autoupdate).toBe("notify")
  1027. expect(documents[0]?.info.share).toBe("disabled")
  1028. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  1029. expect(documents[0]?.info.username).toBe("test-user")
  1030. expect(documents[0]?.info.permissions).toEqual([
  1031. { action: "bash", resource: "*", effect: "ask" },
  1032. { action: "bash", resource: "git status", effect: "allow" },
  1033. ])
  1034. const reviewer = documents[0]?.info.agents?.reviewer
  1035. expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high"))
  1036. expect(reviewer?.request).toEqual({
  1037. headers: { "x-agent": "reviewer" },
  1038. body: { reasoningEffort: "high" },
  1039. })
  1040. expect(reviewer?.description).toBe("Review changes for correctness")
  1041. expect(reviewer?.system).toBe("Find regressions.")
  1042. expect(reviewer?.mode).toBe("subagent")
  1043. expect(reviewer?.hidden).toBe(false)
  1044. expect(reviewer?.color).toBe("#ff6b6b")
  1045. expect(reviewer?.steps).toBe(12)
  1046. expect(reviewer?.disabled).toBe(false)
  1047. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  1048. expect(documents[0]?.info.snapshots).toBe(false)
  1049. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  1050. expect(documents[0]?.info.formatter).toEqual({
  1051. prettier: { disabled: true },
  1052. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  1053. })
  1054. expect(documents[0]?.info.lsp).toEqual({
  1055. typescript: { disabled: true },
  1056. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  1057. })
  1058. expect(documents[0]?.info.media).toEqual({
  1059. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  1060. })
  1061. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  1062. expect(documents[0]?.info.mcp).toEqual({
  1063. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  1064. servers: {
  1065. local: {
  1066. type: "local",
  1067. command: ["node", "./mcp/server.js"],
  1068. environment: { API_KEY: "secret" },
  1069. disabled: false,
  1070. codemode: false,
  1071. timeout: { catalog: 10000 },
  1072. },
  1073. remote: {
  1074. type: "remote",
  1075. url: "https://mcp.example.com/mcp",
  1076. headers: { Authorization: "Bearer token" },
  1077. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  1078. disabled: true,
  1079. codemode: false,
  1080. timeout: { startup: 15000 },
  1081. },
  1082. },
  1083. })
  1084. expect(documents[0]?.info.compaction).toEqual({
  1085. auto: true,
  1086. keep: { tokens: 2000 },
  1087. buffer: 10000,
  1088. })
  1089. expect(documents[0]?.info.skills).toEqual([
  1090. "./skills",
  1091. "~/shared-skills",
  1092. "https://example.com/.well-known/skills/",
  1093. ])
  1094. expect(documents[0]?.info.instructions).toEqual([
  1095. "CONTRIBUTING.md",
  1096. ".cursor/rules/*.md",
  1097. "https://example.com/shared-rules.md",
  1098. ])
  1099. expect(documents[0]?.info.references).toEqual({
  1100. local: { path: "../library" },
  1101. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1102. shorthand: "github.com/example/docs",
  1103. })
  1104. expect(documents[0]?.info.plugins).toEqual([
  1105. "opencode-helicone-session",
  1106. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1107. ])
  1108. }).pipe(Effect.provide(testLayer(tmp.path)))
  1109. }),
  1110. ),
  1111. ),
  1112. )
  1113. it.live("migrates the deprecated reference key into references", () =>
  1114. Effect.acquireRelease(
  1115. Effect.promise(() => tmpdir()),
  1116. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1117. ).pipe(
  1118. Effect.flatMap((tmp) =>
  1119. Effect.gen(function* () {
  1120. yield* Effect.promise(() =>
  1121. fs.writeFile(
  1122. path.join(tmp.path, "opencode.json"),
  1123. JSON.stringify({
  1124. reference: {
  1125. local: { path: "../library" },
  1126. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1127. shorthand: "github.com/example/docs",
  1128. },
  1129. }),
  1130. ),
  1131. )
  1132. return yield* Effect.gen(function* () {
  1133. const config = yield* Config.Service
  1134. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1135. expect(documents).toHaveLength(1)
  1136. expect(documents[0]?.info.references).toEqual({
  1137. local: { path: "../library" },
  1138. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1139. shorthand: "github.com/example/docs",
  1140. })
  1141. }).pipe(Effect.provide(testLayer(tmp.path)))
  1142. }),
  1143. ),
  1144. ),
  1145. )
  1146. it.live("migrates v1 configuration when a v1-only key is present", () =>
  1147. Effect.acquireRelease(
  1148. Effect.promise(() => tmpdir()),
  1149. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1150. ).pipe(
  1151. Effect.flatMap((tmp) =>
  1152. Effect.gen(function* () {
  1153. yield* Effect.promise(() =>
  1154. fs.writeFile(
  1155. path.join(tmp.path, "opencode.json"),
  1156. JSON.stringify({
  1157. shell: "/bin/zsh",
  1158. default_agent: "reviewer",
  1159. snapshot: false,
  1160. autoshare: true,
  1161. permission: {
  1162. bash: "ask",
  1163. edit: { "*.md": "allow", "*": "deny" },
  1164. question: "deny",
  1165. },
  1166. agent: {
  1167. reviewer: {
  1168. prompt: "Review changes.",
  1169. disable: true,
  1170. temperature: 0.2,
  1171. permission: { read: "allow" },
  1172. },
  1173. },
  1174. plugin: [
  1175. "opencode-helicone-session",
  1176. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  1177. ],
  1178. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  1179. references: {
  1180. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  1181. },
  1182. attachment: { image: { auto_resize: false, max_width: 1200 } },
  1183. provider: {
  1184. custom: {
  1185. options: { apiKey: "secret" },
  1186. models: {
  1187. model: {
  1188. options: { reasoningEffort: "high" },
  1189. variants: { fast: { temperature: 0.2 } },
  1190. },
  1191. },
  1192. },
  1193. openai: {
  1194. npm: "@ai-sdk/openai",
  1195. options: { apiKey: "secret", organization: "org" },
  1196. models: {
  1197. model: {
  1198. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  1199. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  1200. },
  1201. },
  1202. },
  1203. anthropic: {
  1204. npm: "@ai-sdk/anthropic",
  1205. models: {
  1206. model: {
  1207. options: {
  1208. effort: "high",
  1209. taskBudget: 4096,
  1210. metadata: { userId: "user-1" },
  1211. },
  1212. },
  1213. },
  1214. },
  1215. },
  1216. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  1217. experimental: { mcp_timeout: 5000 },
  1218. mcp: {
  1219. local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
  1220. remote: {
  1221. type: "remote",
  1222. url: "https://mcp.example.com",
  1223. oauth: { clientId: "client", callbackPort: 19876 },
  1224. timeout: 20000,
  1225. },
  1226. },
  1227. }),
  1228. ),
  1229. )
  1230. return yield* Effect.gen(function* () {
  1231. const config = yield* Config.Service
  1232. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1233. expect(documents).toHaveLength(1)
  1234. expect(documents[0]?.info).toBeInstanceOf(Info)
  1235. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  1236. expect(documents[0]?.info.default_agent).toBe("reviewer")
  1237. expect(documents[0]?.info.snapshots).toBe(false)
  1238. expect(documents[0]?.info.share).toBe("auto")
  1239. expect(documents[0]?.info.permissions).toEqual([
  1240. { action: "shell", resource: "*", effect: "ask" },
  1241. { action: "edit", resource: "*.md", effect: "allow" },
  1242. { action: "edit", resource: "*", effect: "deny" },
  1243. { action: "question", resource: "*", effect: "deny" },
  1244. ])
  1245. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  1246. system: "Review changes.",
  1247. disabled: true,
  1248. request: { body: { temperature: 0.2 } },
  1249. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  1250. })
  1251. expect(documents[0]?.info.plugins).toEqual([
  1252. "opencode-helicone-session",
  1253. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1254. ])
  1255. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  1256. expect(documents[0]?.info.references).toEqual({
  1257. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  1258. })
  1259. expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  1260. expect(documents[0]?.info.providers?.custom).toMatchObject({
  1261. settings: { apiKey: "secret" },
  1262. models: {
  1263. model: {
  1264. settings: { reasoningEffort: "high" },
  1265. variants: [{ id: "fast", settings: { temperature: 0.2 } }],
  1266. },
  1267. },
  1268. })
  1269. expect(documents[0]?.info.providers?.openai).toMatchObject({
  1270. package: Provider.aisdk("@ai-sdk/openai"),
  1271. settings: { apiKey: "secret", organization: "org" },
  1272. models: {
  1273. model: {
  1274. settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  1275. variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }],
  1276. },
  1277. },
  1278. })
  1279. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  1280. package: Provider.aisdk("@ai-sdk/anthropic"),
  1281. models: {
  1282. model: {
  1283. settings: {
  1284. effort: "high",
  1285. taskBudget: 4096,
  1286. metadata: { userId: "user-1" },
  1287. },
  1288. },
  1289. },
  1290. })
  1291. expect(documents[0]?.info.compaction).toEqual({
  1292. auto: true,
  1293. keep: { tokens: 2000 },
  1294. buffer: 10000,
  1295. })
  1296. expect(documents[0]?.info.mcp).toMatchObject({
  1297. timeout: { catalog: 5000, execution: 5000 },
  1298. servers: {
  1299. local: {
  1300. type: "local",
  1301. command: ["node", "server.js"],
  1302. disabled: true,
  1303. timeout: { catalog: 10000, execution: 10000 },
  1304. },
  1305. remote: {
  1306. type: "remote",
  1307. url: "https://mcp.example.com",
  1308. oauth: { client_id: "client", callback_port: 19876 },
  1309. timeout: { catalog: 20000, execution: 20000 },
  1310. },
  1311. },
  1312. })
  1313. }).pipe(Effect.provide(testLayer(tmp.path)))
  1314. }),
  1315. ),
  1316. ),
  1317. )
  1318. it.live("ignores an invalid file while loading valid config values", () =>
  1319. Effect.acquireRelease(
  1320. Effect.promise(() => tmpdir()),
  1321. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1322. ).pipe(
  1323. Effect.flatMap((tmp) =>
  1324. Effect.gen(function* () {
  1325. yield* Effect.promise(() =>
  1326. Promise.all([
  1327. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
  1328. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
  1329. ]),
  1330. )
  1331. return yield* Effect.gen(function* () {
  1332. const config = yield* Config.Service
  1333. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1334. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  1335. }).pipe(Effect.provide(testLayer(tmp.path)))
  1336. }),
  1337. ),
  1338. ),
  1339. )
  1340. it.live("loads global and ancestor configuration across the project boundary", () =>
  1341. Effect.acquireRelease(
  1342. Effect.promise(() => tmpdir()),
  1343. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1344. ).pipe(
  1345. Effect.flatMap((tmp) => {
  1346. const global = path.join(tmp.path, "global")
  1347. const root = path.join(tmp.path, "repo")
  1348. const parent = path.join(root, "packages")
  1349. const directory = path.join(parent, "app")
  1350. const globalAgents = path.join(global, "home", ".agents")
  1351. const globalClaude = path.join(global, "home", ".claude")
  1352. return Effect.gen(function* () {
  1353. yield* Effect.promise(async () => {
  1354. await fs.mkdir(global, { recursive: true })
  1355. await fs.mkdir(globalAgents, { recursive: true })
  1356. await fs.mkdir(globalClaude, { recursive: true })
  1357. await fs.mkdir(directory, { recursive: true })
  1358. await fs.mkdir(path.join(root, ".agents"), { recursive: true })
  1359. await fs.mkdir(path.join(root, ".claude"), { recursive: true })
  1360. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  1361. await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
  1362. await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
  1363. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  1364. await Promise.all([
  1365. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  1366. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  1367. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  1368. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  1369. fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
  1370. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  1371. fs.writeFile(
  1372. path.join(directory, ".opencode", "opencode.jsonc"),
  1373. JSON.stringify({ $schema: "directory-dot" }),
  1374. ),
  1375. ])
  1376. })
  1377. return yield* Effect.gen(function* () {
  1378. const config = yield* Config.Service
  1379. const entries = yield* config.entries()
  1380. const documents = entries.filter((entry) => entry.type === "document")
  1381. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  1382. AbsolutePath.make(global),
  1383. AbsolutePath.make(path.join(root, ".opencode")),
  1384. AbsolutePath.make(path.join(directory, ".opencode")),
  1385. ])
  1386. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  1387. AbsolutePath.make(globalAgents),
  1388. AbsolutePath.make(path.join(directory, ".agents")),
  1389. AbsolutePath.make(path.join(root, ".agents")),
  1390. ])
  1391. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  1392. AbsolutePath.make(globalClaude),
  1393. AbsolutePath.make(path.join(directory, ".claude")),
  1394. AbsolutePath.make(path.join(root, ".claude")),
  1395. ])
  1396. expect(documents.map((document) => document.info.$schema)).toEqual([
  1397. "global",
  1398. "outside",
  1399. "root",
  1400. "parent",
  1401. "directory",
  1402. "root-dot",
  1403. "directory-dot",
  1404. ])
  1405. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  1406. AbsolutePath.make(globalClaude),
  1407. AbsolutePath.make(path.join(directory, ".claude")),
  1408. AbsolutePath.make(path.join(root, ".claude")),
  1409. AbsolutePath.make(globalAgents),
  1410. AbsolutePath.make(path.join(directory, ".agents")),
  1411. AbsolutePath.make(path.join(root, ".agents")),
  1412. "global",
  1413. AbsolutePath.make(global),
  1414. "outside",
  1415. AbsolutePath.make(path.join(tmp.path, "opencode.json")),
  1416. "root",
  1417. AbsolutePath.make(path.join(root, "opencode.json")),
  1418. "parent",
  1419. AbsolutePath.make(path.join(parent, "opencode.jsonc")),
  1420. "directory",
  1421. AbsolutePath.make(path.join(directory, "opencode.json")),
  1422. "root-dot",
  1423. AbsolutePath.make(path.join(root, ".opencode")),
  1424. "directory-dot",
  1425. AbsolutePath.make(path.join(directory, ".opencode")),
  1426. ])
  1427. }).pipe(
  1428. Effect.provide(
  1429. testLayer(directory, global, root, {
  1430. type: "git",
  1431. store: AbsolutePath.make(path.join(root, ".git")),
  1432. }),
  1433. ),
  1434. )
  1435. })
  1436. }),
  1437. ),
  1438. )
  1439. })