agent.test.ts 6.3 KB

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