agent.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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. const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
  14. const decode = Schema.decodeUnknownSync(Config.Info)
  15. describe("ConfigAgentPlugin.Plugin", () => {
  16. it.effect("applies all global permissions before agent-specific permissions", () =>
  17. Effect.gen(function* () {
  18. const agents = yield* AgentV2.Service
  19. const build = AgentV2.ID.make("build")
  20. const defaults = yield* agents.transform()
  21. yield* defaults((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.pipe(
  67. Effect.provideService(Config.Service, config),
  68. Effect.provideService(AgentV2.Service, agents),
  69. )
  70. const buildAgent = yield* agents.get(build)
  71. if (!buildAgent) throw new Error("expected configured build agent")
  72. expect(buildAgent.permissions).toEqual([
  73. { action: "bash", resource: "*", effect: "allow" },
  74. { action: "bash", resource: "*", effect: "ask" },
  75. { action: "read", resource: "*", effect: "allow" },
  76. { action: "bash", resource: "git *", effect: "allow" },
  77. ])
  78. expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
  79. expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
  80. const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
  81. if (!reviewer) throw new Error("expected configured reviewer agent")
  82. expect(reviewer).toMatchObject({
  83. description: "Review changes",
  84. mode: "subagent",
  85. hidden: true,
  86. model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
  87. })
  88. expect(reviewer.permissions).toEqual([
  89. { action: "bash", resource: "*", effect: "ask" },
  90. { action: "read", resource: "*", effect: "allow" },
  91. { action: "edit", resource: "*", effect: "deny" },
  92. { action: "read", resource: "*", effect: "deny" },
  93. ])
  94. expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
  95. expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
  96. { action: "bash", resource: "*", effect: "ask" },
  97. { action: "read", resource: "*", effect: "allow" },
  98. { action: "edit", resource: "*", effect: "allow" },
  99. ])
  100. expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
  101. }),
  102. )
  103. it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
  104. Effect.gen(function* () {
  105. const agents = yield* AgentV2.Service
  106. const config = Config.Service.of({
  107. entries: () =>
  108. Effect.succeed([
  109. new Config.Document({
  110. type: "document",
  111. info: decode({
  112. agents: {
  113. reviewer: {
  114. model: "anthropic/claude-sonnet",
  115. system: "Review carefully.",
  116. description: "Reviews changes",
  117. mode: "subagent",
  118. hidden: true,
  119. color: "warning",
  120. steps: 12,
  121. request: {
  122. headers: { first: "one", shared: "first" },
  123. body: { enabled: true, profile: "review", effort: "medium" },
  124. },
  125. },
  126. },
  127. }),
  128. }),
  129. new Config.Document({
  130. type: "document",
  131. info: decode({
  132. agents: {
  133. reviewer: {
  134. request: {
  135. headers: { shared: "last", second: "two" },
  136. body: { retries: 2, effort: "high" },
  137. },
  138. },
  139. },
  140. }),
  141. }),
  142. ]),
  143. })
  144. yield* ConfigAgentPlugin.Plugin.effect.pipe(
  145. Effect.provideService(Config.Service, config),
  146. Effect.provideService(AgentV2.Service, agents),
  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. const defaults = yield* agents.transform()
  170. yield* defaults((editor) => editor.update(build, () => {}))
  171. const config = Config.Service.of({
  172. entries: () =>
  173. Effect.succeed([
  174. new Config.Document({
  175. type: "document",
  176. info: decode({ agents: { build: { disabled: true } } }),
  177. }),
  178. ]),
  179. })
  180. yield* ConfigAgentPlugin.Plugin.effect.pipe(
  181. Effect.provideService(Config.Service, config),
  182. Effect.provideService(AgentV2.Service, agents),
  183. )
  184. expect(yield* agents.get(build)).toBeUndefined()
  185. }),
  186. )
  187. it.live("loads legacy file-based agents from config directories", () =>
  188. Effect.acquireRelease(
  189. Effect.promise(() => tmpdir()),
  190. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  191. ).pipe(
  192. Effect.flatMap((tmp) =>
  193. Effect.gen(function* () {
  194. yield* Effect.promise(async () => {
  195. await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
  196. await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true })
  197. await fs.writeFile(
  198. path.join(tmp.path, "agents", "reviewer.md"),
  199. `---
  200. model: openrouter/openai/gpt-5
  201. description: Markdown description
  202. temperature: 0.5
  203. tools:
  204. write: false
  205. ---
  206. Review carefully.`,
  207. )
  208. await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.")
  209. await fs.writeFile(
  210. path.join(tmp.path, "agents", "native.md"),
  211. `---
  212. request:
  213. headers:
  214. x-agent: native
  215. body:
  216. effort: high
  217. permissions:
  218. - action: edit
  219. resource: "*"
  220. effect: deny
  221. ---
  222. Use native v2 fields.`,
  223. )
  224. await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
  225. await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
  226. })
  227. const agents = yield* AgentV2.Service
  228. const config = Config.Service.of({
  229. entries: () =>
  230. Effect.succeed([
  231. new Config.Document({
  232. type: "document",
  233. info: decode({ agents: { reviewer: { description: "JSON description" } } }),
  234. }),
  235. new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
  236. ]),
  237. })
  238. yield* ConfigAgentPlugin.Plugin.effect.pipe(
  239. Effect.provideService(Config.Service, config),
  240. Effect.provideService(AgentV2.Service, agents),
  241. )
  242. expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
  243. model: { providerID: "openrouter", id: "openai/gpt-5" },
  244. system: "Review carefully.",
  245. description: "Markdown description",
  246. request: { body: { temperature: 0.5 } },
  247. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  248. })
  249. expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
  250. expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
  251. system: "Use native v2 fields.",
  252. request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
  253. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  254. })
  255. expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
  256. expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
  257. }),
  258. ),
  259. ),
  260. )
  261. })