agent.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { Effect, Schema } from "effect"
  5. import { AgentV2 } from "@opencode-ai/core/agent"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
  8. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  9. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  10. import { FSUtil } from "@opencode-ai/core/fs-util"
  11. import { PermissionV2 } from "@opencode-ai/core/permission"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { tmpdir } from "../fixture/tmpdir"
  14. import { testEffect } from "../lib/effect"
  15. import { agentHost, host } from "../plugin/host"
  16. const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
  17. const decode = Schema.decodeUnknownSync(Config.Info)
  18. describe("ConfigAgentPlugin.Plugin", () => {
  19. it.effect("applies all global permissions before agent-specific permissions", () =>
  20. Effect.gen(function* () {
  21. const agents = yield* AgentV2.Service
  22. const build = AgentV2.ID.make("build")
  23. yield* agents.transform((editor) =>
  24. editor.update(build, (agent) => {
  25. agent.mode = "primary"
  26. agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
  27. }),
  28. )
  29. const config = Config.Service.of({
  30. entries: () =>
  31. Effect.succeed([
  32. new Config.Document({
  33. type: "document",
  34. info: decode({
  35. permissions: [{ action: "bash", resource: "*", effect: "ask" }],
  36. agents: {
  37. build: {
  38. permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
  39. },
  40. reviewer: {
  41. model: "openrouter/openai/gpt-5",
  42. description: "Review changes",
  43. mode: "subagent",
  44. permissions: [
  45. { action: "edit", resource: "*", effect: "deny" },
  46. { action: "read", resource: "*", effect: "deny" },
  47. ],
  48. },
  49. removed: { description: "Removed later" },
  50. },
  51. }),
  52. }),
  53. new Config.Document({
  54. type: "document",
  55. info: decode({
  56. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  57. agents: {
  58. reviewer: { variant: "high", hidden: true },
  59. removed: { disabled: true },
  60. late: {
  61. permissions: [{ action: "edit", resource: "*", effect: "allow" }],
  62. },
  63. },
  64. }),
  65. }),
  66. ]),
  67. })
  68. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  69. Effect.provideService(Config.Service, config),
  70. )
  71. const buildAgent = yield* agents.get(build)
  72. if (!buildAgent) throw new Error("expected configured build agent")
  73. expect(buildAgent.permissions).toEqual([
  74. { action: "bash", resource: "*", effect: "allow" },
  75. { action: "bash", resource: "*", effect: "ask" },
  76. { action: "read", resource: "*", effect: "allow" },
  77. { action: "bash", resource: "git *", effect: "allow" },
  78. ])
  79. expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
  80. expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
  81. const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
  82. if (!reviewer) throw new Error("expected configured reviewer agent")
  83. expect(reviewer).toMatchObject({
  84. description: "Review changes",
  85. mode: "subagent",
  86. hidden: true,
  87. model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
  88. })
  89. expect(reviewer.permissions).toEqual([
  90. { action: "bash", resource: "*", effect: "ask" },
  91. { action: "read", resource: "*", effect: "allow" },
  92. { action: "edit", resource: "*", effect: "deny" },
  93. { action: "read", resource: "*", effect: "deny" },
  94. ])
  95. expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
  96. expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
  97. { action: "bash", resource: "*", effect: "ask" },
  98. { action: "read", resource: "*", effect: "allow" },
  99. { action: "edit", resource: "*", effect: "allow" },
  100. ])
  101. expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
  102. }),
  103. )
  104. it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
  105. Effect.gen(function* () {
  106. const agents = yield* AgentV2.Service
  107. const config = Config.Service.of({
  108. entries: () =>
  109. Effect.succeed([
  110. new Config.Document({
  111. type: "document",
  112. info: decode({
  113. agents: {
  114. reviewer: {
  115. model: "anthropic/claude-sonnet",
  116. system: "Review carefully.",
  117. description: "Reviews changes",
  118. mode: "subagent",
  119. hidden: true,
  120. color: "warning",
  121. steps: 12,
  122. request: {
  123. headers: { first: "one", shared: "first" },
  124. body: { enabled: true, profile: "review", effort: "medium" },
  125. },
  126. },
  127. },
  128. }),
  129. }),
  130. new Config.Document({
  131. type: "document",
  132. info: decode({
  133. agents: {
  134. reviewer: {
  135. request: {
  136. headers: { shared: "last", second: "two" },
  137. body: { retries: 2, effort: "high" },
  138. },
  139. },
  140. },
  141. }),
  142. }),
  143. ]),
  144. })
  145. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  146. Effect.provideService(Config.Service, config),
  147. )
  148. const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
  149. if (!reviewer) throw new Error("expected configured reviewer agent")
  150. expect(reviewer).toMatchObject({
  151. system: "Review carefully.",
  152. description: "Reviews changes",
  153. mode: "subagent",
  154. hidden: true,
  155. color: "warning",
  156. steps: 12,
  157. model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
  158. })
  159. expect(reviewer.request).toEqual({
  160. headers: { first: "one", shared: "last", second: "two" },
  161. body: { enabled: true, profile: "review", retries: 2, effort: "high" },
  162. })
  163. }),
  164. )
  165. it.effect("removes a built-in agent disabled by configuration", () =>
  166. Effect.gen(function* () {
  167. const agents = yield* AgentV2.Service
  168. const build = AgentV2.ID.make("build")
  169. yield* agents.transform((editor) => editor.update(build, () => {}))
  170. const config = Config.Service.of({
  171. entries: () =>
  172. Effect.succeed([
  173. new Config.Document({
  174. type: "document",
  175. info: decode({ agents: { build: { disabled: true } } }),
  176. }),
  177. ]),
  178. })
  179. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  180. Effect.provideService(Config.Service, config),
  181. )
  182. expect(yield* agents.get(build)).toBeUndefined()
  183. }),
  184. )
  185. it.live("loads legacy file-based agents from config directories", () =>
  186. Effect.acquireRelease(
  187. Effect.promise(() => tmpdir()),
  188. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  189. ).pipe(
  190. Effect.flatMap((tmp) =>
  191. Effect.gen(function* () {
  192. yield* Effect.promise(async () => {
  193. await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
  194. await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true })
  195. await fs.writeFile(
  196. path.join(tmp.path, "agents", "reviewer.md"),
  197. `---
  198. model: openrouter/openai/gpt-5
  199. description: Markdown description
  200. temperature: 0.5
  201. tools:
  202. write: false
  203. ---
  204. Review carefully.`,
  205. )
  206. await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.")
  207. await fs.writeFile(
  208. path.join(tmp.path, "agents", "native.md"),
  209. `---
  210. request:
  211. headers:
  212. x-agent: native
  213. body:
  214. effort: high
  215. permissions:
  216. - action: edit
  217. resource: "*"
  218. effect: deny
  219. ---
  220. Use native v2 fields.`,
  221. )
  222. await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
  223. await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
  224. })
  225. const agents = yield* AgentV2.Service
  226. const config = Config.Service.of({
  227. entries: () =>
  228. Effect.succeed([
  229. new Config.Document({
  230. type: "document",
  231. info: decode({ agents: { reviewer: { description: "JSON description" } } }),
  232. }),
  233. new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
  234. ]),
  235. })
  236. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  237. Effect.provideService(Config.Service, config),
  238. )
  239. expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
  240. model: { providerID: "openrouter", id: "openai/gpt-5" },
  241. system: "Review carefully.",
  242. description: "Markdown description",
  243. request: { body: { temperature: 0.5 } },
  244. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  245. })
  246. expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
  247. expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
  248. system: "Use native v2 fields.",
  249. request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
  250. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  251. })
  252. expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
  253. expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
  254. }),
  255. ),
  256. ),
  257. )
  258. })