agent.test.ts 10 KB

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