config.test.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer, Schema } from "effect"
  5. import { FastCheck } from "effect/testing"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { ConfigProvider } from "@opencode-ai/core/config/provider"
  8. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  9. import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
  10. import { FSUtil } from "@opencode-ai/core/fs-util"
  11. import { Global } from "@opencode-ai/core/global"
  12. import { Location } from "@opencode-ai/core/location"
  13. import { Policy } from "@opencode-ai/core/policy"
  14. import { Project } from "@opencode-ai/core/project"
  15. import { AbsolutePath } from "@opencode-ai/core/schema"
  16. import { location } from "../fixture/location"
  17. import { tmpdir } from "../fixture/tmpdir"
  18. import { testEffect } from "../lib/effect"
  19. const it = testEffect(Layer.empty)
  20. function testLayer(
  21. directory: string,
  22. globalDirectory = path.join(directory, "global"),
  23. projectDirectory = directory,
  24. vcs?: Project.Vcs,
  25. ) {
  26. return Config.locationLayer.pipe(
  27. Layer.provide(FSUtil.defaultLayer),
  28. Layer.provide(Global.layerWith({ config: globalDirectory })),
  29. Layer.provide(
  30. Layer.succeed(
  31. Location.Service,
  32. Location.Service.of(
  33. location(
  34. { directory: AbsolutePath.make(directory) },
  35. { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
  36. ),
  37. ),
  38. ),
  39. ),
  40. )
  41. }
  42. const provider = {
  43. api: { type: "native", settings: {} },
  44. request: {
  45. headers: {},
  46. body: {},
  47. },
  48. models: {},
  49. }
  50. describe("Config", () => {
  51. it.effect("returns the latest defined scalar from priority-ordered documents", () =>
  52. Effect.sync(() => {
  53. const entries = [
  54. new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5" }) }),
  55. new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
  56. new Config.Document({ type: "document", info: new Config.Info({}) }),
  57. new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5.5" }) }),
  58. ]
  59. expect(Config.latest(entries, "model")).toBe("openrouter/openai/gpt-5.5")
  60. expect(Config.latest(entries, "default_agent")).toBeUndefined()
  61. }),
  62. )
  63. it.effect("detects v1 configuration from any v1-only top-level key", () =>
  64. Effect.sync(() => {
  65. expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
  66. expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
  67. expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
  68. expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
  69. expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
  70. }),
  71. )
  72. it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
  73. Effect.sync(() => {
  74. FastCheck.assert(
  75. FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
  76. Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(info), { errors: "all" })
  77. }),
  78. { numRuns: 100 },
  79. )
  80. }),
  81. )
  82. it.effect("migrates v1 provider setup options into AISDK settings", () =>
  83. Effect.sync(() => {
  84. const migrated = ConfigMigrateV1.migrate({
  85. provider: {
  86. bedrock: {
  87. npm: "@ai-sdk/amazon-bedrock",
  88. options: {
  89. headers: { "x-test": "1" },
  90. body: { trace: true },
  91. region: "us-east-1",
  92. profile: "dev",
  93. },
  94. },
  95. },
  96. })
  97. expect(migrated.providers?.bedrock?.api).toEqual({
  98. type: "aisdk",
  99. package: "@ai-sdk/amazon-bedrock",
  100. settings: { region: "us-east-1", profile: "dev" },
  101. })
  102. expect(migrated.providers?.bedrock?.request).toEqual({
  103. headers: { "x-test": "1" },
  104. body: { trace: true },
  105. })
  106. }),
  107. )
  108. it.effect("migrates v1 command configuration", () =>
  109. Effect.sync(() => {
  110. expect(
  111. ConfigMigrateV1.migrate({
  112. command: {
  113. review: {
  114. template: "Review changes",
  115. description: "Review code",
  116. agent: "reviewer",
  117. model: "anthropic/claude",
  118. variant: "high",
  119. subtask: true,
  120. },
  121. },
  122. }).commands,
  123. ).toEqual({
  124. review: {
  125. template: "Review changes",
  126. description: "Review code",
  127. agent: "reviewer",
  128. model: "anthropic/claude",
  129. variant: "high",
  130. subtask: true,
  131. },
  132. })
  133. }),
  134. )
  135. it.live("returns an empty configuration when directory files do not exist", () =>
  136. Effect.acquireRelease(
  137. Effect.promise(() => tmpdir()),
  138. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  139. ).pipe(
  140. Effect.flatMap((tmp) =>
  141. Effect.gen(function* () {
  142. const config = yield* Config.Service
  143. const entries = yield* config.entries()
  144. expect(entries).toEqual([
  145. new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  146. ])
  147. }).pipe(Effect.provide(testLayer(tmp.path))),
  148. ),
  149. ),
  150. )
  151. it.live("loads JSON and JSONC files from lowest to highest priority", () =>
  152. Effect.acquireRelease(
  153. Effect.promise(() => tmpdir()),
  154. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  155. ).pipe(
  156. Effect.flatMap((tmp) =>
  157. Effect.gen(function* () {
  158. yield* Effect.promise(() =>
  159. Promise.all([
  160. fs.writeFile(
  161. path.join(tmp.path, "config.json"),
  162. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  163. ),
  164. fs.writeFile(
  165. path.join(tmp.path, "opencode.json"),
  166. JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
  167. ),
  168. fs.writeFile(
  169. path.join(tmp.path, "opencode.jsonc"),
  170. `{
  171. // Later global files override scalar fields while retaining providers.
  172. "$schema": "last",
  173. "providers": { "last": ${JSON.stringify(provider)} },
  174. }`,
  175. ),
  176. ]),
  177. )
  178. return yield* Effect.gen(function* () {
  179. const config = yield* Config.Service
  180. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  181. expect(documents).toHaveLength(3)
  182. expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"])
  183. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
  184. expect(documents[0]).toBeInstanceOf(Config.Document)
  185. expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json"))
  186. expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  187. yield* Effect.promise(() =>
  188. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  189. )
  190. expect(
  191. (yield* config.entries())
  192. .filter((entry) => entry.type === "document")
  193. .map((document) => document.info.$schema),
  194. ).toEqual(["base", "middle", "last"])
  195. }).pipe(Effect.provide(testLayer(tmp.path)))
  196. }),
  197. ),
  198. ),
  199. )
  200. it.live("accepts $schema metadata without writing it into config files", () =>
  201. Effect.acquireRelease(
  202. Effect.promise(() => tmpdir()),
  203. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  204. ).pipe(
  205. Effect.flatMap((tmp) =>
  206. Effect.gen(function* () {
  207. const file = path.join(tmp.path, "opencode.json")
  208. const contents = JSON.stringify({
  209. shell: "/bin/zsh",
  210. experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
  211. providers: { local: provider },
  212. })
  213. yield* Effect.promise(() => fs.writeFile(file, contents))
  214. return yield* Effect.gen(function* () {
  215. const config = yield* Config.Service
  216. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  217. expect(documents[0]?.info.$schema).toBeUndefined()
  218. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  219. expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
  220. effect: "deny",
  221. action: "provider.use",
  222. resource: "openai",
  223. })
  224. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  225. }).pipe(Effect.provide(testLayer(tmp.path)))
  226. }),
  227. ),
  228. ),
  229. )
  230. it.live("loads supported scalar and resource configuration", () =>
  231. Effect.acquireRelease(
  232. Effect.promise(() => tmpdir()),
  233. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  234. ).pipe(
  235. Effect.flatMap((tmp) =>
  236. Effect.gen(function* () {
  237. yield* Effect.promise(() =>
  238. fs.writeFile(
  239. path.join(tmp.path, "opencode.json"),
  240. JSON.stringify({
  241. shell: "/bin/bash",
  242. model: "anthropic/claude",
  243. default_agent: "reviewer",
  244. autoupdate: "notify",
  245. share: "disabled",
  246. enterprise: { url: "https://share.example.com" },
  247. username: "test-user",
  248. permissions: [
  249. { action: "bash", resource: "*", effect: "ask" },
  250. { action: "bash", resource: "git status", effect: "allow" },
  251. ],
  252. agents: {
  253. reviewer: {
  254. model: "openrouter/openai/gpt-5",
  255. variant: "high",
  256. request: {
  257. headers: { "x-agent": "reviewer" },
  258. body: { reasoningEffort: "high" },
  259. },
  260. description: "Review changes for correctness",
  261. system: "Find regressions.",
  262. mode: "subagent",
  263. hidden: false,
  264. color: "warning",
  265. steps: 12,
  266. disabled: false,
  267. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  268. },
  269. },
  270. snapshots: false,
  271. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  272. formatter: {
  273. prettier: { disabled: true },
  274. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  275. },
  276. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  277. attachments: {
  278. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  279. },
  280. tool_output: { max_lines: 1000, max_bytes: 32768 },
  281. mcp: {
  282. timeout: 5000,
  283. servers: {
  284. local: {
  285. type: "local",
  286. command: ["node", "./mcp/server.js"],
  287. environment: { API_KEY: "secret" },
  288. disabled: false,
  289. timeout: 10000,
  290. },
  291. remote: {
  292. type: "remote",
  293. url: "https://mcp.example.com/mcp",
  294. headers: { Authorization: "Bearer token" },
  295. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  296. disabled: true,
  297. },
  298. },
  299. },
  300. compaction: {
  301. auto: true,
  302. prune: false,
  303. keep: { tokens: 2000 },
  304. buffer: 10000,
  305. },
  306. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  307. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  308. references: {
  309. local: { path: "../library" },
  310. sdk: { repository: "github.com/example/sdk", branch: "main" },
  311. shorthand: "github.com/example/docs",
  312. },
  313. plugins: [
  314. "opencode-helicone-session",
  315. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  316. ],
  317. }),
  318. ),
  319. )
  320. return yield* Effect.gen(function* () {
  321. const config = yield* Config.Service
  322. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  323. expect(documents).toHaveLength(1)
  324. expect(documents[0]?.info.shell).toBe("/bin/bash")
  325. expect(documents[0]?.info.model).toBe("anthropic/claude")
  326. expect(documents[0]?.info.default_agent).toBe("reviewer")
  327. expect(documents[0]?.info.autoupdate).toBe("notify")
  328. expect(documents[0]?.info.share).toBe("disabled")
  329. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  330. expect(documents[0]?.info.username).toBe("test-user")
  331. expect(documents[0]?.info.permissions).toEqual([
  332. { action: "bash", resource: "*", effect: "ask" },
  333. { action: "bash", resource: "git status", effect: "allow" },
  334. ])
  335. const reviewer = documents[0]?.info.agents?.reviewer
  336. expect(reviewer?.model).toBe("openrouter/openai/gpt-5")
  337. expect(reviewer?.variant).toBe("high")
  338. expect(reviewer?.request).toEqual({
  339. headers: { "x-agent": "reviewer" },
  340. body: { reasoningEffort: "high" },
  341. })
  342. expect(reviewer?.description).toBe("Review changes for correctness")
  343. expect(reviewer?.system).toBe("Find regressions.")
  344. expect(reviewer?.mode).toBe("subagent")
  345. expect(reviewer?.hidden).toBe(false)
  346. expect(reviewer?.color).toBe("warning")
  347. expect(reviewer?.steps).toBe(12)
  348. expect(reviewer?.disabled).toBe(false)
  349. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  350. expect(documents[0]?.info.snapshots).toBe(false)
  351. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  352. expect(documents[0]?.info.formatter).toEqual({
  353. prettier: { disabled: true },
  354. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  355. })
  356. expect(documents[0]?.info.lsp).toEqual({
  357. typescript: { disabled: true },
  358. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  359. })
  360. expect(documents[0]?.info.attachments).toEqual({
  361. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  362. })
  363. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  364. expect(documents[0]?.info.mcp).toEqual({
  365. timeout: 5000,
  366. servers: {
  367. local: {
  368. type: "local",
  369. command: ["node", "./mcp/server.js"],
  370. environment: { API_KEY: "secret" },
  371. disabled: false,
  372. timeout: 10000,
  373. },
  374. remote: {
  375. type: "remote",
  376. url: "https://mcp.example.com/mcp",
  377. headers: { Authorization: "Bearer token" },
  378. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  379. disabled: true,
  380. },
  381. },
  382. })
  383. expect(documents[0]?.info.compaction).toEqual({
  384. auto: true,
  385. prune: false,
  386. keep: { tokens: 2000 },
  387. buffer: 10000,
  388. })
  389. expect(documents[0]?.info.skills).toEqual([
  390. "./skills",
  391. "~/shared-skills",
  392. "https://example.com/.well-known/skills/",
  393. ])
  394. expect(documents[0]?.info.instructions).toEqual([
  395. "CONTRIBUTING.md",
  396. ".cursor/rules/*.md",
  397. "https://example.com/shared-rules.md",
  398. ])
  399. expect(documents[0]?.info.references).toEqual({
  400. local: { path: "../library" },
  401. sdk: { repository: "github.com/example/sdk", branch: "main" },
  402. shorthand: "github.com/example/docs",
  403. })
  404. expect(documents[0]?.info.plugins).toEqual([
  405. "opencode-helicone-session",
  406. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  407. ])
  408. }).pipe(Effect.provide(testLayer(tmp.path)))
  409. }),
  410. ),
  411. ),
  412. )
  413. it.live("migrates the deprecated reference key into references", () =>
  414. Effect.acquireRelease(
  415. Effect.promise(() => tmpdir()),
  416. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  417. ).pipe(
  418. Effect.flatMap((tmp) =>
  419. Effect.gen(function* () {
  420. yield* Effect.promise(() =>
  421. fs.writeFile(
  422. path.join(tmp.path, "opencode.json"),
  423. JSON.stringify({
  424. reference: {
  425. local: { path: "../library" },
  426. sdk: { repository: "github.com/example/sdk", branch: "main" },
  427. shorthand: "github.com/example/docs",
  428. },
  429. }),
  430. ),
  431. )
  432. return yield* Effect.gen(function* () {
  433. const config = yield* Config.Service
  434. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  435. expect(documents).toHaveLength(1)
  436. expect(documents[0]?.info.references).toEqual({
  437. local: { path: "../library" },
  438. sdk: { repository: "github.com/example/sdk", branch: "main" },
  439. shorthand: "github.com/example/docs",
  440. })
  441. }).pipe(Effect.provide(testLayer(tmp.path)))
  442. }),
  443. ),
  444. ),
  445. )
  446. it.live("migrates v1 configuration when a v1-only key is present", () =>
  447. Effect.acquireRelease(
  448. Effect.promise(() => tmpdir()),
  449. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  450. ).pipe(
  451. Effect.flatMap((tmp) =>
  452. Effect.gen(function* () {
  453. yield* Effect.promise(() =>
  454. fs.writeFile(
  455. path.join(tmp.path, "opencode.json"),
  456. JSON.stringify({
  457. shell: "/bin/zsh",
  458. default_agent: "reviewer",
  459. snapshot: false,
  460. autoshare: true,
  461. permission: {
  462. bash: "ask",
  463. edit: { "*.md": "allow", "*": "deny" },
  464. question: "deny",
  465. },
  466. agent: {
  467. reviewer: {
  468. prompt: "Review changes.",
  469. disable: true,
  470. temperature: 0.2,
  471. permission: { read: "allow" },
  472. },
  473. },
  474. plugin: [
  475. "opencode-helicone-session",
  476. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  477. ],
  478. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  479. references: {
  480. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  481. },
  482. attachment: { image: { auto_resize: false, max_width: 1200 } },
  483. provider: {
  484. custom: {
  485. options: { apiKey: "secret" },
  486. models: {
  487. model: {
  488. options: { reasoningEffort: "high" },
  489. variants: { fast: { temperature: 0.2 } },
  490. },
  491. },
  492. },
  493. openai: {
  494. npm: "@ai-sdk/openai",
  495. options: { apiKey: "secret", organization: "org" },
  496. models: {
  497. model: {
  498. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  499. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  500. },
  501. },
  502. },
  503. anthropic: {
  504. npm: "@ai-sdk/anthropic",
  505. models: {
  506. model: {
  507. options: {
  508. effort: "high",
  509. taskBudget: 4096,
  510. metadata: { userId: "user-1" },
  511. },
  512. },
  513. },
  514. },
  515. },
  516. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  517. experimental: { mcp_timeout: 5000 },
  518. mcp: {
  519. local: { type: "local", command: ["node", "server.js"], enabled: false },
  520. remote: {
  521. type: "remote",
  522. url: "https://mcp.example.com",
  523. oauth: { clientId: "client", callbackPort: 19876 },
  524. },
  525. },
  526. }),
  527. ),
  528. )
  529. return yield* Effect.gen(function* () {
  530. const config = yield* Config.Service
  531. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  532. expect(documents).toHaveLength(1)
  533. expect(documents[0]?.info).toBeInstanceOf(Config.Info)
  534. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  535. expect(documents[0]?.info.default_agent).toBe("reviewer")
  536. expect(documents[0]?.info.snapshots).toBe(false)
  537. expect(documents[0]?.info.share).toBe("auto")
  538. expect(documents[0]?.info.permissions).toEqual([
  539. { action: "bash", resource: "*", effect: "ask" },
  540. { action: "edit", resource: "*.md", effect: "allow" },
  541. { action: "edit", resource: "*", effect: "deny" },
  542. { action: "question", resource: "*", effect: "deny" },
  543. ])
  544. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  545. system: "Review changes.",
  546. disabled: true,
  547. request: { body: { temperature: 0.2 } },
  548. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  549. })
  550. expect(documents[0]?.info.plugins).toEqual([
  551. "opencode-helicone-session",
  552. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  553. ])
  554. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  555. expect(documents[0]?.info.references).toEqual({
  556. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  557. })
  558. expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  559. expect(documents[0]?.info.providers?.custom).toMatchObject({
  560. request: { body: { apiKey: "secret" } },
  561. models: {
  562. model: {
  563. request: { body: { reasoningEffort: "high" } },
  564. variants: [{ id: "fast", body: { temperature: 0.2 } }],
  565. },
  566. },
  567. })
  568. expect(documents[0]?.info.providers?.openai).toMatchObject({
  569. api: { settings: {} },
  570. request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } },
  571. models: {
  572. model: {
  573. request: {
  574. body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" },
  575. },
  576. variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }],
  577. },
  578. },
  579. })
  580. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  581. models: {
  582. model: {
  583. request: {
  584. body: {
  585. output_config: { effort: "high", task_budget: 4096 },
  586. metadata: { user_id: "user-1" },
  587. },
  588. },
  589. },
  590. },
  591. })
  592. expect(documents[0]?.info.compaction).toEqual({
  593. auto: true,
  594. prune: undefined,
  595. keep: { tokens: 2000 },
  596. buffer: 10000,
  597. })
  598. expect(documents[0]?.info.mcp).toMatchObject({
  599. timeout: 5000,
  600. servers: {
  601. local: { type: "local", command: ["node", "server.js"], disabled: true },
  602. remote: {
  603. type: "remote",
  604. url: "https://mcp.example.com",
  605. oauth: { client_id: "client", callback_port: 19876 },
  606. },
  607. },
  608. })
  609. }).pipe(Effect.provide(testLayer(tmp.path)))
  610. }),
  611. ),
  612. ),
  613. )
  614. it.live("ignores invalid files while loading valid config values", () =>
  615. Effect.acquireRelease(
  616. Effect.promise(() => tmpdir()),
  617. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  618. ).pipe(
  619. Effect.flatMap((tmp) =>
  620. Effect.gen(function* () {
  621. yield* Effect.promise(() =>
  622. Promise.all([
  623. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
  624. fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
  625. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
  626. ]),
  627. )
  628. return yield* Effect.gen(function* () {
  629. const config = yield* Config.Service
  630. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  631. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  632. }).pipe(Effect.provide(testLayer(tmp.path)))
  633. }),
  634. ),
  635. ),
  636. )
  637. it.live("loads policy statements in reverse config order", () =>
  638. Effect.acquireRelease(
  639. Effect.promise(() => tmpdir()),
  640. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  641. ).pipe(
  642. Effect.flatMap((tmp) => {
  643. const global = path.join(tmp.path, "global")
  644. return Effect.gen(function* () {
  645. yield* Effect.promise(async () => {
  646. await fs.mkdir(global, { recursive: true })
  647. await fs.writeFile(
  648. path.join(global, "opencode.json"),
  649. JSON.stringify({
  650. experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
  651. }),
  652. )
  653. await fs.writeFile(
  654. path.join(tmp.path, "opencode.json"),
  655. JSON.stringify({
  656. experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
  657. }),
  658. )
  659. })
  660. return yield* Effect.gen(function* () {
  661. const policy = yield* Policy.Service
  662. expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
  663. }).pipe(Effect.provide(testLayer(tmp.path, global)))
  664. })
  665. }),
  666. ),
  667. )
  668. it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
  669. Effect.acquireRelease(
  670. Effect.promise(() => tmpdir()),
  671. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  672. ).pipe(
  673. Effect.flatMap((tmp) => {
  674. const global = path.join(tmp.path, "global")
  675. const root = path.join(tmp.path, "repo")
  676. const parent = path.join(root, "packages")
  677. const directory = path.join(parent, "app")
  678. return Effect.gen(function* () {
  679. yield* Effect.promise(async () => {
  680. await fs.mkdir(global, { recursive: true })
  681. await fs.mkdir(directory, { recursive: true })
  682. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  683. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  684. await Promise.all([
  685. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  686. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  687. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  688. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  689. fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
  690. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  691. fs.writeFile(
  692. path.join(directory, ".opencode", "opencode.jsonc"),
  693. JSON.stringify({ $schema: "directory-dot" }),
  694. ),
  695. ])
  696. })
  697. return yield* Effect.gen(function* () {
  698. const config = yield* Config.Service
  699. const entries = yield* config.entries()
  700. const documents = entries.filter((entry) => entry.type === "document")
  701. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  702. AbsolutePath.make(global),
  703. AbsolutePath.make(path.join(root, ".opencode")),
  704. AbsolutePath.make(path.join(directory, ".opencode")),
  705. ])
  706. expect(documents.map((document) => document.info.$schema)).toEqual([
  707. "global",
  708. "root",
  709. "parent",
  710. "directory",
  711. "root-dot",
  712. "directory-dot",
  713. ])
  714. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  715. "global",
  716. AbsolutePath.make(global),
  717. "root",
  718. "parent",
  719. "directory",
  720. "root-dot",
  721. AbsolutePath.make(path.join(root, ".opencode")),
  722. "directory-dot",
  723. AbsolutePath.make(path.join(directory, ".opencode")),
  724. ])
  725. }).pipe(
  726. Effect.provide(
  727. testLayer(directory, global, root, {
  728. type: "git",
  729. store: AbsolutePath.make(path.join(root, ".git")),
  730. }),
  731. ),
  732. )
  733. })
  734. }),
  735. ),
  736. )
  737. })