config.test.ts 62 KB

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