agent.test.ts 6.2 KB

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