config.test.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  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: { startup: 5000, request: 60000 },
  283. servers: {
  284. local: {
  285. type: "local",
  286. command: ["node", "./mcp/server.js"],
  287. environment: { API_KEY: "secret" },
  288. disabled: false,
  289. timeout: { request: 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. timeout: { startup: 15000 },
  298. },
  299. },
  300. },
  301. compaction: {
  302. auto: true,
  303. prune: false,
  304. keep: { tokens: 2000 },
  305. buffer: 10000,
  306. },
  307. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  308. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  309. references: {
  310. local: { path: "../library" },
  311. sdk: { repository: "github.com/example/sdk", branch: "main" },
  312. shorthand: "github.com/example/docs",
  313. },
  314. plugins: [
  315. "opencode-helicone-session",
  316. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  317. ],
  318. }),
  319. ),
  320. )
  321. return yield* Effect.gen(function* () {
  322. const config = yield* Config.Service
  323. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  324. expect(documents).toHaveLength(1)
  325. expect(documents[0]?.info.shell).toBe("/bin/bash")
  326. expect(documents[0]?.info.model).toBe("anthropic/claude")
  327. expect(documents[0]?.info.default_agent).toBe("reviewer")
  328. expect(documents[0]?.info.autoupdate).toBe("notify")
  329. expect(documents[0]?.info.share).toBe("disabled")
  330. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  331. expect(documents[0]?.info.username).toBe("test-user")
  332. expect(documents[0]?.info.permissions).toEqual([
  333. { action: "bash", resource: "*", effect: "ask" },
  334. { action: "bash", resource: "git status", effect: "allow" },
  335. ])
  336. const reviewer = documents[0]?.info.agents?.reviewer
  337. expect(reviewer?.model).toBe("openrouter/openai/gpt-5")
  338. expect(reviewer?.variant).toBe("high")
  339. expect(reviewer?.request).toEqual({
  340. headers: { "x-agent": "reviewer" },
  341. body: { reasoningEffort: "high" },
  342. })
  343. expect(reviewer?.description).toBe("Review changes for correctness")
  344. expect(reviewer?.system).toBe("Find regressions.")
  345. expect(reviewer?.mode).toBe("subagent")
  346. expect(reviewer?.hidden).toBe(false)
  347. expect(reviewer?.color).toBe("warning")
  348. expect(reviewer?.steps).toBe(12)
  349. expect(reviewer?.disabled).toBe(false)
  350. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  351. expect(documents[0]?.info.snapshots).toBe(false)
  352. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  353. expect(documents[0]?.info.formatter).toEqual({
  354. prettier: { disabled: true },
  355. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  356. })
  357. expect(documents[0]?.info.lsp).toEqual({
  358. typescript: { disabled: true },
  359. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  360. })
  361. expect(documents[0]?.info.attachments).toEqual({
  362. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  363. })
  364. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  365. expect(documents[0]?.info.mcp).toEqual({
  366. timeout: { startup: 5000, request: 60000 },
  367. servers: {
  368. local: {
  369. type: "local",
  370. command: ["node", "./mcp/server.js"],
  371. environment: { API_KEY: "secret" },
  372. disabled: false,
  373. timeout: { request: 10000 },
  374. },
  375. remote: {
  376. type: "remote",
  377. url: "https://mcp.example.com/mcp",
  378. headers: { Authorization: "Bearer token" },
  379. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  380. disabled: true,
  381. timeout: { startup: 15000 },
  382. },
  383. },
  384. })
  385. expect(documents[0]?.info.compaction).toEqual({
  386. auto: true,
  387. prune: false,
  388. keep: { tokens: 2000 },
  389. buffer: 10000,
  390. })
  391. expect(documents[0]?.info.skills).toEqual([
  392. "./skills",
  393. "~/shared-skills",
  394. "https://example.com/.well-known/skills/",
  395. ])
  396. expect(documents[0]?.info.instructions).toEqual([
  397. "CONTRIBUTING.md",
  398. ".cursor/rules/*.md",
  399. "https://example.com/shared-rules.md",
  400. ])
  401. expect(documents[0]?.info.references).toEqual({
  402. local: { path: "../library" },
  403. sdk: { repository: "github.com/example/sdk", branch: "main" },
  404. shorthand: "github.com/example/docs",
  405. })
  406. expect(documents[0]?.info.plugins).toEqual([
  407. "opencode-helicone-session",
  408. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  409. ])
  410. }).pipe(Effect.provide(testLayer(tmp.path)))
  411. }),
  412. ),
  413. ),
  414. )
  415. it.live("migrates the deprecated reference key into references", () =>
  416. Effect.acquireRelease(
  417. Effect.promise(() => tmpdir()),
  418. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  419. ).pipe(
  420. Effect.flatMap((tmp) =>
  421. Effect.gen(function* () {
  422. yield* Effect.promise(() =>
  423. fs.writeFile(
  424. path.join(tmp.path, "opencode.json"),
  425. JSON.stringify({
  426. reference: {
  427. local: { path: "../library" },
  428. sdk: { repository: "github.com/example/sdk", branch: "main" },
  429. shorthand: "github.com/example/docs",
  430. },
  431. }),
  432. ),
  433. )
  434. return yield* Effect.gen(function* () {
  435. const config = yield* Config.Service
  436. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  437. expect(documents).toHaveLength(1)
  438. expect(documents[0]?.info.references).toEqual({
  439. local: { path: "../library" },
  440. sdk: { repository: "github.com/example/sdk", branch: "main" },
  441. shorthand: "github.com/example/docs",
  442. })
  443. }).pipe(Effect.provide(testLayer(tmp.path)))
  444. }),
  445. ),
  446. ),
  447. )
  448. it.live("migrates v1 configuration when a v1-only key is present", () =>
  449. Effect.acquireRelease(
  450. Effect.promise(() => tmpdir()),
  451. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  452. ).pipe(
  453. Effect.flatMap((tmp) =>
  454. Effect.gen(function* () {
  455. yield* Effect.promise(() =>
  456. fs.writeFile(
  457. path.join(tmp.path, "opencode.json"),
  458. JSON.stringify({
  459. shell: "/bin/zsh",
  460. default_agent: "reviewer",
  461. snapshot: false,
  462. autoshare: true,
  463. permission: {
  464. bash: "ask",
  465. edit: { "*.md": "allow", "*": "deny" },
  466. question: "deny",
  467. },
  468. agent: {
  469. reviewer: {
  470. prompt: "Review changes.",
  471. disable: true,
  472. temperature: 0.2,
  473. permission: { read: "allow" },
  474. },
  475. },
  476. plugin: [
  477. "opencode-helicone-session",
  478. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  479. ],
  480. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  481. references: {
  482. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  483. },
  484. attachment: { image: { auto_resize: false, max_width: 1200 } },
  485. provider: {
  486. custom: {
  487. options: { apiKey: "secret" },
  488. models: {
  489. model: {
  490. options: { reasoningEffort: "high" },
  491. variants: { fast: { temperature: 0.2 } },
  492. },
  493. },
  494. },
  495. openai: {
  496. npm: "@ai-sdk/openai",
  497. options: { apiKey: "secret", organization: "org" },
  498. models: {
  499. model: {
  500. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  501. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  502. },
  503. },
  504. },
  505. anthropic: {
  506. npm: "@ai-sdk/anthropic",
  507. models: {
  508. model: {
  509. options: {
  510. effort: "high",
  511. taskBudget: 4096,
  512. metadata: { userId: "user-1" },
  513. },
  514. },
  515. },
  516. },
  517. },
  518. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  519. experimental: { mcp_timeout: 5000 },
  520. mcp: {
  521. local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
  522. remote: {
  523. type: "remote",
  524. url: "https://mcp.example.com",
  525. oauth: { clientId: "client", callbackPort: 19876 },
  526. timeout: 20000,
  527. },
  528. },
  529. }),
  530. ),
  531. )
  532. return yield* Effect.gen(function* () {
  533. const config = yield* Config.Service
  534. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  535. expect(documents).toHaveLength(1)
  536. expect(documents[0]?.info).toBeInstanceOf(Config.Info)
  537. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  538. expect(documents[0]?.info.default_agent).toBe("reviewer")
  539. expect(documents[0]?.info.snapshots).toBe(false)
  540. expect(documents[0]?.info.share).toBe("auto")
  541. expect(documents[0]?.info.permissions).toEqual([
  542. { action: "bash", resource: "*", effect: "ask" },
  543. { action: "edit", resource: "*.md", effect: "allow" },
  544. { action: "edit", resource: "*", effect: "deny" },
  545. { action: "question", resource: "*", effect: "deny" },
  546. ])
  547. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  548. system: "Review changes.",
  549. disabled: true,
  550. request: { body: { temperature: 0.2 } },
  551. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  552. })
  553. expect(documents[0]?.info.plugins).toEqual([
  554. "opencode-helicone-session",
  555. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  556. ])
  557. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  558. expect(documents[0]?.info.references).toEqual({
  559. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  560. })
  561. expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  562. expect(documents[0]?.info.providers?.custom).toMatchObject({
  563. request: { body: { apiKey: "secret" } },
  564. models: {
  565. model: {
  566. request: { body: { reasoningEffort: "high" } },
  567. variants: [{ id: "fast", body: { temperature: 0.2 } }],
  568. },
  569. },
  570. })
  571. expect(documents[0]?.info.providers?.openai).toMatchObject({
  572. api: { settings: {} },
  573. request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } },
  574. models: {
  575. model: {
  576. request: {
  577. body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" },
  578. },
  579. variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }],
  580. },
  581. },
  582. })
  583. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  584. models: {
  585. model: {
  586. request: {
  587. body: {
  588. output_config: { effort: "high", task_budget: 4096 },
  589. metadata: { user_id: "user-1" },
  590. },
  591. },
  592. },
  593. },
  594. })
  595. expect(documents[0]?.info.compaction).toEqual({
  596. auto: true,
  597. prune: undefined,
  598. keep: { tokens: 2000 },
  599. buffer: 10000,
  600. })
  601. expect(documents[0]?.info.mcp).toMatchObject({
  602. timeout: { request: 5000 },
  603. servers: {
  604. local: {
  605. type: "local",
  606. command: ["node", "server.js"],
  607. disabled: true,
  608. timeout: { request: 10000 },
  609. },
  610. remote: {
  611. type: "remote",
  612. url: "https://mcp.example.com",
  613. oauth: { client_id: "client", callback_port: 19876 },
  614. timeout: { request: 20000 },
  615. },
  616. },
  617. })
  618. }).pipe(Effect.provide(testLayer(tmp.path)))
  619. }),
  620. ),
  621. ),
  622. )
  623. it.live("ignores invalid files while loading valid config values", () =>
  624. Effect.acquireRelease(
  625. Effect.promise(() => tmpdir()),
  626. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  627. ).pipe(
  628. Effect.flatMap((tmp) =>
  629. Effect.gen(function* () {
  630. yield* Effect.promise(() =>
  631. Promise.all([
  632. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
  633. fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
  634. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
  635. ]),
  636. )
  637. return yield* Effect.gen(function* () {
  638. const config = yield* Config.Service
  639. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  640. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  641. }).pipe(Effect.provide(testLayer(tmp.path)))
  642. }),
  643. ),
  644. ),
  645. )
  646. it.live("loads policy statements in reverse config order", () =>
  647. Effect.acquireRelease(
  648. Effect.promise(() => tmpdir()),
  649. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  650. ).pipe(
  651. Effect.flatMap((tmp) => {
  652. const global = path.join(tmp.path, "global")
  653. return Effect.gen(function* () {
  654. yield* Effect.promise(async () => {
  655. await fs.mkdir(global, { recursive: true })
  656. await fs.writeFile(
  657. path.join(global, "opencode.json"),
  658. JSON.stringify({
  659. experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
  660. }),
  661. )
  662. await fs.writeFile(
  663. path.join(tmp.path, "opencode.json"),
  664. JSON.stringify({
  665. experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
  666. }),
  667. )
  668. })
  669. return yield* Effect.gen(function* () {
  670. const policy = yield* Policy.Service
  671. expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
  672. }).pipe(Effect.provide(testLayer(tmp.path, global)))
  673. })
  674. }),
  675. ),
  676. )
  677. it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
  678. Effect.acquireRelease(
  679. Effect.promise(() => tmpdir()),
  680. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  681. ).pipe(
  682. Effect.flatMap((tmp) => {
  683. const global = path.join(tmp.path, "global")
  684. const root = path.join(tmp.path, "repo")
  685. const parent = path.join(root, "packages")
  686. const directory = path.join(parent, "app")
  687. return Effect.gen(function* () {
  688. yield* Effect.promise(async () => {
  689. await fs.mkdir(global, { recursive: true })
  690. await fs.mkdir(directory, { recursive: true })
  691. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  692. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  693. await Promise.all([
  694. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  695. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  696. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  697. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  698. fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
  699. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  700. fs.writeFile(
  701. path.join(directory, ".opencode", "opencode.jsonc"),
  702. JSON.stringify({ $schema: "directory-dot" }),
  703. ),
  704. ])
  705. })
  706. return yield* Effect.gen(function* () {
  707. const config = yield* Config.Service
  708. const entries = yield* config.entries()
  709. const documents = entries.filter((entry) => entry.type === "document")
  710. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  711. AbsolutePath.make(global),
  712. AbsolutePath.make(path.join(root, ".opencode")),
  713. AbsolutePath.make(path.join(directory, ".opencode")),
  714. ])
  715. expect(documents.map((document) => document.info.$schema)).toEqual([
  716. "global",
  717. "root",
  718. "parent",
  719. "directory",
  720. "root-dot",
  721. "directory-dot",
  722. ])
  723. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  724. "global",
  725. AbsolutePath.make(global),
  726. "root",
  727. "parent",
  728. "directory",
  729. "root-dot",
  730. AbsolutePath.make(path.join(root, ".opencode")),
  731. "directory-dot",
  732. AbsolutePath.make(path.join(directory, ".opencode")),
  733. ])
  734. }).pipe(
  735. Effect.provide(
  736. testLayer(directory, global, root, {
  737. type: "git",
  738. store: AbsolutePath.make(path.join(root, ".git")),
  739. }),
  740. ),
  741. )
  742. })
  743. }),
  744. ),
  745. )
  746. })