agent.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
  4. import { TestClock } from "effect/testing"
  5. import { Agent } from "@opencode-ai/core/agent"
  6. import { Bus } from "@opencode-ai/core/bus"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  9. import { Location } from "@opencode-ai/core/location"
  10. import { Permission } from "@opencode-ai/core/permission"
  11. import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { Global } from "@opencode-ai/util/global"
  14. import { location } from "./fixture/location"
  15. import { testEffect } from "./lib/effect"
  16. import { agentHost, host } from "./plugin/host"
  17. const testLocation = location({ directory: AbsolutePath.make("/project") })
  18. const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
  19. const global = Global.make({ data: "/data", config: "/config", tmp: "/tmp/opencode" })
  20. const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
  21. const it = testEffect(
  22. AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
  23. [Global.node, globalLayer],
  24. [Location.node, locationLayer],
  25. ]) as unknown as Layer.Layer<unknown, never>,
  26. )
  27. describe("Agent", () => {
  28. it.effect("publishes an updated event after agent changes", () =>
  29. Effect.gen(function* () {
  30. const agent = yield* Agent.Service
  31. const bus = yield* Bus.Service
  32. const updated = yield* bus
  33. .subscribe(Agent.Event.Updated)
  34. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  35. yield* Effect.yieldNow
  36. yield* agent.transform((editor) => editor.update(Agent.ID.make("reviewer"), () => {}))
  37. expect(yield* Fiber.join(updated)).toMatchObject([{ location: { directory: testLocation.directory } }])
  38. }),
  39. )
  40. it.effect("starts without agents", () =>
  41. Effect.gen(function* () {
  42. const agent = yield* Agent.Service
  43. expect(yield* agent.list()).toEqual([])
  44. expect(yield* agent.get(Agent.ID.make("build"))).toBeUndefined()
  45. }),
  46. )
  47. it.effect("materializes replayable agent transforms", () =>
  48. Effect.gen(function* () {
  49. const agent = yield* Agent.Service
  50. const id = Agent.ID.make("reviewer")
  51. yield* agent.transform((editor) =>
  52. editor.update(id, (info) => {
  53. info.description = "Reviews code"
  54. info.mode = "subagent"
  55. }),
  56. )
  57. expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" })
  58. expect((yield* agent.list()).map((info) => info.id)).toEqual([id])
  59. }),
  60. )
  61. it.effect("lists the selected default agent first", () =>
  62. Effect.gen(function* () {
  63. const agent = yield* Agent.Service
  64. yield* agent.transform((editor) => {
  65. editor.update(Agent.ID.make("build"), (info) => {
  66. info.mode = "primary"
  67. })
  68. editor.update(Agent.ID.make("reviewer"), (info) => {
  69. info.mode = "primary"
  70. })
  71. editor.update(Agent.ID.make("explore"), (info) => {
  72. info.mode = "subagent"
  73. })
  74. editor.default(Agent.ID.make("reviewer"))
  75. })
  76. expect((yield* agent.list()).map((info) => String(info.id))).toEqual(["reviewer", "build", "explore"])
  77. }),
  78. )
  79. it.effect("rebuilds state when a transform is replaced", () =>
  80. Effect.gen(function* () {
  81. const agent = yield* Agent.Service
  82. const id = Agent.ID.make("reviewer")
  83. let description = "Old description"
  84. let hidden = true
  85. yield* agent.transform((editor) =>
  86. editor.update(id, (info) => {
  87. info.description = description
  88. info.hidden = hidden
  89. }),
  90. )
  91. description = "New description"
  92. hidden = false
  93. const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true }))
  94. yield* TestClock.adjust("500 millis")
  95. yield* Fiber.join(reload)
  96. expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
  97. }),
  98. )
  99. it.effect("removes a transform when its scope closes", () =>
  100. Effect.gen(function* () {
  101. const agent = yield* Agent.Service
  102. const id = Agent.ID.make("scoped")
  103. const scope = yield* Scope.make()
  104. yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope))
  105. expect(yield* agent.get(id)).toBeDefined()
  106. yield* Scope.close(scope, Exit.void)
  107. expect(yield* agent.get(id)).toBeUndefined()
  108. }),
  109. )
  110. it.effect("applies direct agent updates", () =>
  111. Effect.gen(function* () {
  112. const agent = yield* Agent.Service
  113. const id = Agent.ID.make("build")
  114. yield* agent.transform((editor) =>
  115. editor.update(id, (info) => {
  116. info.mode = "primary"
  117. info.hidden = true
  118. }),
  119. )
  120. expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true })
  121. }),
  122. )
  123. it.effect("creates agents with runtime defaults and supports direct removal", () =>
  124. Effect.gen(function* () {
  125. const agent = yield* Agent.Service
  126. const id = Agent.ID.make("custom")
  127. yield* agent.transform((editor) => editor.update(id, () => {}))
  128. const info = yield* agent.get(id)
  129. expect(info?.mode).toBe("primary")
  130. expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
  131. Agent.Info.default(id).permissions,
  132. )
  133. expect(
  134. Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? [])
  135. .effect,
  136. ).toBe("allow")
  137. expect(
  138. Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? [])
  139. .effect,
  140. ).toBe("allow")
  141. expect(
  142. Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect,
  143. ).toBe("allow")
  144. expect(
  145. Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect,
  146. ).toBe("allow")
  147. yield* agent.transform((editor) => editor.remove(id))
  148. expect(yield* agent.get(id)).toBeUndefined()
  149. }),
  150. )
  151. it.effect("applies managed external directories without opting built-in agents into bash", () =>
  152. Effect.gen(function* () {
  153. const agent = yield* Agent.Service
  154. yield* AgentPlugin.Plugin.effect(
  155. host({
  156. agent: agentHost(agent),
  157. }),
  158. )
  159. const agents = yield* agent.list()
  160. expect(agents.map((item) => String(item.id)).sort()).toEqual([
  161. "build",
  162. "compaction",
  163. "explore",
  164. "general",
  165. "summary",
  166. "title",
  167. ])
  168. expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
  169. const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
  170. expect(
  171. Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
  172. ).toBe("allow")
  173. expect(
  174. Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), permissions).effect,
  175. ).toBe("allow")
  176. expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
  177. expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
  178. const explore = yield* agent.get(Agent.ID.make("explore"))
  179. expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
  180. expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
  181. expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
  182. expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
  183. for (const item of agents) {
  184. expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
  185. }
  186. }),
  187. )
  188. it.effect("denies the subagent tool for built-in subagents", () =>
  189. Effect.gen(function* () {
  190. const agent = yield* Agent.Service
  191. yield* AgentPlugin.Plugin.effect(
  192. host({
  193. agent: agentHost(agent),
  194. }),
  195. )
  196. yield* Effect.forEach(["general", "explore"], (id) =>
  197. Effect.gen(function* () {
  198. const info = yield* agent.get(Agent.ID.make(id))
  199. if (!info) throw new Error(`expected built-in agent: ${id}`)
  200. expect(info.mode).toBe("subagent")
  201. expect(info.permissions).toContainEqual({ action: "subagent", resource: "*", effect: "deny" })
  202. expect(Permission.evaluate("subagent", "*", info.permissions).effect).toBe("deny")
  203. }),
  204. )
  205. }),
  206. )
  207. })