config.test.ts 32 KB

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