agent.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. import { describe, expect, test } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { Effect, Fiber, Schema, Stream } from "effect"
  5. import { Agent } from "@opencode-ai/core/agent"
  6. import { Bus } from "@opencode-ai/core/bus"
  7. import { Config } from "@opencode-ai/core/config"
  8. import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
  9. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  10. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  11. import { FSUtil } from "@opencode-ai/util/fs-util"
  12. import { Global } from "@opencode-ai/util/global"
  13. import { Permission } from "@opencode-ai/core/permission"
  14. import { AbsolutePath } from "@opencode-ai/core/schema"
  15. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  16. import { advance, drain } from "../lib/clock"
  17. import { tmpdir } from "../fixture/tmpdir"
  18. import { testEffect } from "../lib/effect"
  19. import { agentHost, host } from "../plugin/host"
  20. const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
  21. const decode = Schema.decodeUnknownSync(Config.Info)
  22. const defaultPermissions = [
  23. { action: "*", resource: "*", effect: "allow" },
  24. { action: "external_directory", resource: "*", effect: "ask" },
  25. ] satisfies Permission.Ruleset
  26. test("rejects named agent color tokens", () => {
  27. expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
  28. })
  29. describe("ConfigAgentPlugin.Plugin", () => {
  30. it.effect("matches POSIX paths against home-relative permissions", () =>
  31. Effect.gen(function* () {
  32. const permissions = yield* loadHomePermissions("/home/test")
  33. expect(Permission.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe("allow")
  34. expect(Permission.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
  35. expect(Permission.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
  36. expect(Permission.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
  37. expect(permissions).toContainEqual({ action: "shell", resource: "$HOME/private/**", effect: "deny" })
  38. expect(permissions).not.toContainEqual({ action: "shell", resource: "/home/test/private/**", effect: "deny" })
  39. expect(Permission.evaluate("shell", "$HOME/private/key", permissions).effect).toBe("deny")
  40. }),
  41. )
  42. it.effect("matches Windows paths against home-relative permissions", () =>
  43. Effect.gen(function* () {
  44. const permissions = yield* loadHomePermissions("C:\\Users\\test")
  45. expect(
  46. Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
  47. ).toBe("allow")
  48. expect(Permission.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
  49. "deny",
  50. )
  51. }),
  52. )
  53. it.effect("applies all global permissions before agent-specific permissions", () =>
  54. Effect.gen(function* () {
  55. const agents = yield* Agent.Service
  56. const build = Agent.ID.make("build")
  57. yield* agents.transform((editor) =>
  58. editor.update(build, (agent) => {
  59. agent.mode = "primary"
  60. agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
  61. }),
  62. )
  63. const entries = [
  64. new Config.Document({
  65. type: "document",
  66. info: decode({
  67. permissions: [{ action: "bash", resource: "*", effect: "ask" }],
  68. agents: {
  69. build: {
  70. permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
  71. },
  72. reviewer: {
  73. model: "openrouter/openai/gpt-5",
  74. description: "Review changes",
  75. mode: "subagent",
  76. permissions: [
  77. { action: "edit", resource: "*", effect: "deny" },
  78. { action: "read", resource: "*", effect: "deny" },
  79. ],
  80. },
  81. removed: { description: "Removed later" },
  82. },
  83. }),
  84. }),
  85. new Config.Document({
  86. type: "document",
  87. info: decode({
  88. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  89. agents: {
  90. reviewer: { model: "openrouter/openai/gpt-5#high", hidden: true },
  91. removed: { disabled: true },
  92. late: {
  93. permissions: [{ action: "edit", resource: "*", effect: "allow" }],
  94. },
  95. },
  96. }),
  97. }),
  98. ]
  99. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  100. Effect.provide(Config.testLayer(entries)),
  101. )
  102. const buildAgent = yield* agents.get(build)
  103. if (!buildAgent) throw new Error("expected configured build agent")
  104. expect(buildAgent.permissions).toEqual([
  105. ...defaultPermissions,
  106. { action: "bash", resource: "*", effect: "allow" },
  107. { action: "bash", resource: "*", effect: "ask" },
  108. { action: "read", resource: "*", effect: "allow" },
  109. { action: "bash", resource: "git *", effect: "allow" },
  110. ])
  111. expect(Permission.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
  112. expect(Permission.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
  113. const reviewer = yield* agents.get(Agent.ID.make("reviewer"))
  114. if (!reviewer) throw new Error("expected configured reviewer agent")
  115. expect(reviewer).toMatchObject({
  116. description: "Review changes",
  117. mode: "subagent",
  118. hidden: true,
  119. model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
  120. })
  121. expect(reviewer.permissions).toEqual([
  122. ...defaultPermissions,
  123. { action: "bash", resource: "*", effect: "ask" },
  124. { action: "read", resource: "*", effect: "allow" },
  125. { action: "edit", resource: "*", effect: "deny" },
  126. { action: "read", resource: "*", effect: "deny" },
  127. ])
  128. expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
  129. expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
  130. ...defaultPermissions,
  131. { action: "bash", resource: "*", effect: "ask" },
  132. { action: "read", resource: "*", effect: "allow" },
  133. { action: "edit", resource: "*", effect: "allow" },
  134. ])
  135. expect(yield* agents.get(Agent.ID.make("removed"))).toBeUndefined()
  136. }),
  137. )
  138. it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
  139. Effect.gen(function* () {
  140. const agents = yield* Agent.Service
  141. const entries = [
  142. new Config.Document({
  143. type: "document",
  144. info: decode({
  145. agents: {
  146. reviewer: {
  147. model: "anthropic/claude-sonnet",
  148. system: "Review carefully.",
  149. description: "Reviews changes",
  150. mode: "subagent",
  151. hidden: true,
  152. color: "#ff6b6b",
  153. steps: 12,
  154. request: {
  155. headers: { first: "one", shared: "first" },
  156. body: { enabled: true, profile: "review", effort: "medium" },
  157. },
  158. },
  159. },
  160. }),
  161. }),
  162. new Config.Document({
  163. type: "document",
  164. info: decode({
  165. agents: {
  166. reviewer: {
  167. request: {
  168. headers: { shared: "last", second: "two" },
  169. body: { retries: 2, effort: "high" },
  170. },
  171. },
  172. },
  173. }),
  174. }),
  175. ]
  176. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  177. Effect.provide(Config.testLayer(entries)),
  178. )
  179. const reviewer = yield* agents.get(Agent.ID.make("reviewer"))
  180. if (!reviewer) throw new Error("expected configured reviewer agent")
  181. expect(reviewer).toMatchObject({
  182. system: "Review carefully.",
  183. description: "Reviews changes",
  184. mode: "subagent",
  185. hidden: true,
  186. color: "#ff6b6b",
  187. steps: 12,
  188. model: { providerID: "anthropic", id: "claude-sonnet" },
  189. })
  190. expect(reviewer.request).toEqual({
  191. settings: {},
  192. headers: { first: "one", shared: "last", second: "two" },
  193. body: { enabled: true, profile: "review", retries: 2, effort: "high" },
  194. })
  195. }),
  196. )
  197. it.effect("removes a built-in agent disabled by configuration", () =>
  198. Effect.gen(function* () {
  199. const agents = yield* Agent.Service
  200. const build = Agent.ID.make("build")
  201. yield* agents.transform((editor) => editor.update(build, () => {}))
  202. const entries = [
  203. new Config.Document({
  204. type: "document",
  205. info: decode({ agents: { build: { disabled: true } } }),
  206. }),
  207. ]
  208. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  209. Effect.provide(Config.testLayer(entries)),
  210. )
  211. expect(yield* agents.get(build)).toBeUndefined()
  212. }),
  213. )
  214. it.live("loads legacy file-based agents from config directories", () =>
  215. Effect.acquireRelease(
  216. Effect.promise(() => tmpdir()),
  217. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  218. ).pipe(
  219. Effect.flatMap((tmp) =>
  220. Effect.gen(function* () {
  221. yield* Effect.promise(async () => {
  222. await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
  223. await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true })
  224. await fs.writeFile(
  225. path.join(tmp.path, "agents", "reviewer.md"),
  226. `---
  227. model: openrouter/openai/gpt-5
  228. description: Markdown description
  229. temperature: 0.5
  230. tools:
  231. write: false
  232. ---
  233. Review carefully.`,
  234. )
  235. await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.")
  236. await fs.writeFile(
  237. path.join(tmp.path, "agents", "native.md"),
  238. `---
  239. request:
  240. headers:
  241. x-agent: native
  242. body:
  243. effort: high
  244. permissions:
  245. - action: edit
  246. resource: "*"
  247. effect: deny
  248. ---
  249. Use native v2 fields.`,
  250. )
  251. await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
  252. await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
  253. })
  254. const agents = yield* Agent.Service
  255. const entries = [
  256. new Config.Document({
  257. type: "document",
  258. info: decode({ agents: { reviewer: { description: "JSON description" } } }),
  259. }),
  260. directoryEntry(tmp.path),
  261. ]
  262. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  263. Effect.provide(Config.testLayer(entries)),
  264. )
  265. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
  266. model: { providerID: "openrouter", id: "openai/gpt-5" },
  267. system: "Review carefully.",
  268. description: "Markdown description",
  269. request: { body: { temperature: 0.5 } },
  270. permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
  271. })
  272. expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
  273. expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
  274. system: "Use native v2 fields.",
  275. request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
  276. permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
  277. })
  278. expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
  279. expect(yield* agents.get(Agent.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
  280. }),
  281. ),
  282. ),
  283. )
  284. for (const testCase of sourceCases()) {
  285. it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
  286. Effect.acquireRelease(
  287. Effect.promise(() => tmpdir()),
  288. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  289. ).pipe(
  290. Effect.flatMap((tmp) =>
  291. Effect.gen(function* () {
  292. const directory = path.join(tmp.path, testCase.source)
  293. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  294. yield* testCase.prepare(directory)
  295. const agents = yield* Agent.Service
  296. const bus = yield* Bus.Service
  297. const configTest = yield* Config.Test
  298. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
  299. // Verify inside the subscription so the update event is a read barrier:
  300. // committed state must be visible at event delivery time.
  301. let received = 0
  302. const changed = yield* bus.subscribe(Agent.Event.Updated).pipe(
  303. Stream.take(1),
  304. Stream.tap(() => Effect.sync(() => received++)),
  305. Stream.mapEffect(() => testCase.verify(agents)),
  306. Stream.runDrain,
  307. Effect.forkScoped({ startImmediately: true }),
  308. )
  309. yield* Effect.yieldNow
  310. const updates = yield* testCase.mutate(directory)
  311. yield* Effect.forEach(updates, (update) => configTest.emitChange(update), { discard: true })
  312. yield* advance(() => received === 1)
  313. yield* Fiber.join(changed)
  314. }).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
  315. ),
  316. ),
  317. )
  318. }
  319. it.effect("coalesces updates inside the debounce window into one rebuild", () =>
  320. Effect.acquireRelease(
  321. Effect.promise(() => tmpdir()),
  322. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  323. ).pipe(
  324. Effect.flatMap((tmp) =>
  325. Effect.gen(function* () {
  326. const directory = path.join(tmp.path, "agents")
  327. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  328. const agents = yield* Agent.Service
  329. const configTest = yield* Config.Test
  330. let reloads = 0
  331. yield* ConfigAgentPlugin.Plugin.effect(
  332. host({
  333. agent: {
  334. ...agentHost(agents),
  335. reload: () => agents.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
  336. },
  337. }),
  338. )
  339. yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
  340. yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
  341. yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
  342. yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
  343. yield* advance(() => reloads >= 1)
  344. expect(reloads).toBe(1)
  345. yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review twice"))
  346. yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
  347. yield* advance(() => reloads >= 2)
  348. expect(reloads).toBe(2)
  349. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review twice" })
  350. }).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
  351. ),
  352. ),
  353. )
  354. it.effect("ignores updates outside agent source directories", () =>
  355. Effect.acquireRelease(
  356. Effect.promise(() => tmpdir()),
  357. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  358. ).pipe(
  359. Effect.flatMap((tmp) =>
  360. Effect.gen(function* () {
  361. const directory = path.join(tmp.path, "agents")
  362. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  363. const agents = yield* Agent.Service
  364. const configTest = yield* Config.Test
  365. let reloads = 0
  366. yield* ConfigAgentPlugin.Plugin.effect(
  367. host({
  368. agent: {
  369. ...agentHost(agents),
  370. reload: () => agents.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
  371. },
  372. }),
  373. )
  374. yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "commands", "review.md") })
  375. yield* configTest.emitChange({ type: "update", path: path.join(tmp.path, "opencode.json") })
  376. yield* drain
  377. expect(reloads).toBe(0)
  378. // The feed stays live after unrelated updates.
  379. yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review related"))
  380. yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
  381. yield* advance(() => reloads >= 1)
  382. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review related" })
  383. }).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
  384. ),
  385. ),
  386. )
  387. })
  388. function directoryEntry(directory: string) {
  389. return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
  390. }
  391. function sourceCases() {
  392. return [
  393. {
  394. name: "created",
  395. source: "agents",
  396. prepare: () => Effect.void,
  397. mutate: (directory: string) =>
  398. Effect.promise(async () => {
  399. const file = path.join(directory, "reviewer.md")
  400. await fs.writeFile(file, "Review changes")
  401. return [{ type: "create" as const, path: file }]
  402. }),
  403. verify: (agents: Agent.Interface) =>
  404. Effect.gen(function* () {
  405. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review changes" })
  406. }),
  407. },
  408. {
  409. name: "created in a legacy modes directory",
  410. source: "modes",
  411. prepare: () => Effect.void,
  412. mutate: (directory: string) =>
  413. Effect.promise(async () => {
  414. const file = path.join(directory, "plan.md")
  415. await fs.writeFile(file, "Make a plan")
  416. return [{ type: "create" as const, path: file }]
  417. }),
  418. verify: (agents: Agent.Interface) =>
  419. Effect.gen(function* () {
  420. expect(yield* agents.get(Agent.ID.make("plan"))).toMatchObject({ system: "Make a plan", mode: "primary" })
  421. }),
  422. },
  423. {
  424. name: "updated",
  425. source: "agents",
  426. prepare: (directory: string) =>
  427. Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review first")),
  428. mutate: (directory: string) =>
  429. Effect.promise(async () => {
  430. const file = path.join(directory, "reviewer.md")
  431. await fs.writeFile(file, "Review updated")
  432. return [{ type: "update" as const, path: file }]
  433. }),
  434. verify: (agents: Agent.Interface) =>
  435. Effect.gen(function* () {
  436. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review updated" })
  437. }),
  438. },
  439. {
  440. name: "renamed",
  441. source: "agents",
  442. prepare: (directory: string) =>
  443. Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review renamed")),
  444. mutate: (directory: string) =>
  445. Effect.promise(async () => {
  446. const previous = path.join(directory, "reviewer.md")
  447. const next = path.join(directory, "release.md")
  448. await fs.rename(previous, next)
  449. return [
  450. { type: "delete" as const, path: previous },
  451. { type: "create" as const, path: next },
  452. ]
  453. }),
  454. verify: (agents: Agent.Interface) =>
  455. Effect.gen(function* () {
  456. expect(yield* agents.get(Agent.ID.make("reviewer"))).toBeUndefined()
  457. expect(yield* agents.get(Agent.ID.make("release"))).toMatchObject({ system: "Review renamed" })
  458. }),
  459. },
  460. {
  461. name: "deleted",
  462. source: "agents",
  463. prepare: (directory: string) =>
  464. Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review deleted")),
  465. mutate: (directory: string) =>
  466. Effect.promise(async () => {
  467. const file = path.join(directory, "reviewer.md")
  468. await fs.unlink(file)
  469. return [{ type: "delete" as const, path: file }]
  470. }),
  471. verify: (agents: Agent.Interface) =>
  472. Effect.gen(function* () {
  473. expect(yield* agents.get(Agent.ID.make("reviewer"))).toBeUndefined()
  474. }),
  475. },
  476. ] as const
  477. }
  478. function loadHomePermissions(home: string) {
  479. return Effect.gen(function* () {
  480. const agents = yield* Agent.Service
  481. const build = Agent.ID.make("build")
  482. yield* agents.transform((editor) => editor.update(build, () => {}))
  483. const entries = [
  484. new Config.Document({
  485. type: "document",
  486. info: decode(
  487. ConfigMigrateV1.migrate({
  488. permission: {
  489. external_directory: {
  490. "~/p/**": "allow",
  491. "/some/~/path": "deny",
  492. "$HOMELESS/**": "deny",
  493. },
  494. bash: {
  495. "$HOME/private/**": "deny",
  496. },
  497. },
  498. agent: {
  499. build: {
  500. permission: {
  501. external_directory: {
  502. "$HOME/cache/**": "deny",
  503. },
  504. },
  505. },
  506. },
  507. }),
  508. ),
  509. }),
  510. ]
  511. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  512. Effect.provide(Config.testLayer(entries)),
  513. Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
  514. )
  515. const agent = yield* agents.get(build)
  516. if (!agent) throw new Error("expected configured build agent")
  517. return agent.permissions
  518. })
  519. }