plugin.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import { describe, expect } from "bun:test"
  2. import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
  3. import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
  4. import { Config as ConfigSchema } from "@opencode-ai/schema/config"
  5. import { Plugin } from "@opencode-ai/schema/plugin"
  6. import { AgentV2 } from "@opencode-ai/core/agent"
  7. import { EventV2 } from "@opencode-ai/core/event"
  8. import { PluginV2 } from "@opencode-ai/core/plugin"
  9. import { PluginHost } from "@opencode-ai/core/plugin/host"
  10. import { SessionV2 } from "@opencode-ai/core/session"
  11. import { SessionMessage } from "@opencode-ai/core/session/message"
  12. import { Tool } from "@opencode-ai/core/tool/tool"
  13. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  14. import { testEffect } from "./lib/effect"
  15. import { testModel } from "./lib/tool"
  16. import { PluginTestLayer } from "./plugin/fixture"
  17. const it = testEffect(PluginTestLayer)
  18. class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
  19. describe("PluginV2", () => {
  20. it.live("exposes public events through the plugin context", () =>
  21. Effect.gen(function* () {
  22. const plugins = yield* PluginV2.Service
  23. const events = yield* EventV2.Service
  24. const host = yield* PluginHost.make(plugins)
  25. const received = yield* host.event.subscribe().pipe(
  26. Stream.filter((event) => event.type === "config.updated"),
  27. Stream.runHead,
  28. Effect.forkScoped({ startImmediately: true }),
  29. )
  30. yield* Effect.sleep("10 millis")
  31. yield* events.publish(ConfigSchema.Event.Updated, {})
  32. expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
  33. }),
  34. )
  35. it.effect("skips identical generations and replaces changed plugin versions", () =>
  36. Effect.gen(function* () {
  37. const plugins = yield* PluginV2.Service
  38. const agents = yield* AgentV2.Service
  39. const events = yield* EventV2.Service
  40. let description = "first"
  41. const updated = yield* events
  42. .subscribe(Plugin.Event.Updated)
  43. .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
  44. const managed = () =>
  45. EffectPlugin.define({
  46. id: "managed",
  47. effect: (ctx) =>
  48. ctx.agent
  49. .transform((agents) =>
  50. agents.update("configured", (agent) => {
  51. agent.description = description
  52. }),
  53. )
  54. .pipe(Effect.asVoid),
  55. })
  56. yield* plugins.activate([{ plugin: managed() }])
  57. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
  58. description = "second"
  59. yield* plugins.activate([{ plugin: managed() }])
  60. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
  61. yield* plugins.activate([{ plugin: managed(), version: "next" }])
  62. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
  63. expect(yield* Fiber.join(updated)).toHaveLength(2)
  64. yield* plugins.activate([])
  65. expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
  66. }),
  67. )
  68. it.effect("rejects duplicate IDs before replacing the active generation", () =>
  69. Effect.gen(function* () {
  70. const plugins = yield* PluginV2.Service
  71. const active = Plugin.ID.make("active")
  72. const duplicate = "duplicate"
  73. yield* plugins.activate([{ plugin: { id: active, effect: () => Effect.void } }])
  74. const result = yield* plugins
  75. .activate([
  76. { plugin: { id: duplicate, effect: () => Effect.void } },
  77. { plugin: { id: duplicate, effect: () => Effect.void } },
  78. ])
  79. .pipe(Effect.exit)
  80. expect(Exit.isFailure(result)).toBe(true)
  81. expect(yield* plugins.list()).toEqual([{ id: active }])
  82. }),
  83. )
  84. it.effect("skips failed plugins and loads the rest", () =>
  85. Effect.gen(function* () {
  86. const plugins = yield* PluginV2.Service
  87. const agents = yield* AgentV2.Service
  88. let fail = true
  89. const good = EffectPlugin.define({
  90. id: "good",
  91. effect: (ctx) =>
  92. ctx.agent
  93. .transform((agents) =>
  94. agents.update("configured", (agent) => {
  95. agent.description = "loaded"
  96. }),
  97. )
  98. .pipe(Effect.asVoid),
  99. })
  100. const bad = EffectPlugin.define({
  101. id: "bad",
  102. effect: () => {
  103. if (fail) return Effect.die(new Error("materialization failed"))
  104. return Effect.void
  105. },
  106. })
  107. yield* plugins.activate([{ plugin: good }, { plugin: bad }])
  108. expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
  109. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
  110. fail = false
  111. yield* plugins.activate([{ plugin: good }, { plugin: bad }])
  112. expect(yield* plugins.list()).toEqual([
  113. { id: Plugin.ID.make("good") },
  114. { id: Plugin.ID.make("bad") },
  115. ])
  116. }),
  117. )
  118. it.effect("closes the previous generation in reverse order", () =>
  119. Effect.gen(function* () {
  120. const plugins = yield* PluginV2.Service
  121. const closed: string[] = []
  122. yield* plugins.activate(
  123. ["first", "second"].map((id) => ({
  124. plugin: {
  125. id,
  126. effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
  127. },
  128. })),
  129. )
  130. yield* plugins.activate([])
  131. expect(closed).toEqual(["second", "first"])
  132. }),
  133. )
  134. it.effect("isolates plugins from ambient services", () =>
  135. Effect.gen(function* () {
  136. const plugins = yield* PluginV2.Service
  137. let visible = true
  138. const plugin = EffectPlugin.define({
  139. id: "isolated",
  140. effect: () =>
  141. Effect.serviceOption(Secret).pipe(
  142. Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
  143. Effect.asVoid,
  144. ),
  145. })
  146. yield* plugins.activate([{ plugin }]).pipe(Effect.provideService(Secret, "secret"))
  147. expect(visible).toBe(false)
  148. }),
  149. )
  150. it.effect("registers location tools through the plugin context", () =>
  151. Effect.gen(function* () {
  152. const plugins = yield* PluginV2.Service
  153. const registry = yield* ToolRegistry.Service
  154. const plugin = EffectPlugin.define({
  155. id: "tool-plugin",
  156. effect: (ctx) =>
  157. ctx.tool
  158. .transform((draft) =>
  159. draft.add(
  160. "plugin_tool",
  161. Tool.make({
  162. description: "Plugin tool",
  163. input: Schema.Struct({}),
  164. output: Schema.Struct({ ok: Schema.Boolean }),
  165. execute: () => Effect.succeed({ ok: true }),
  166. }),
  167. ),
  168. )
  169. .pipe(Effect.orDie),
  170. })
  171. yield* plugins.activate([{ plugin }])
  172. expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
  173. "plugin_tool",
  174. )
  175. yield* plugins.activate([])
  176. expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
  177. "plugin_tool",
  178. )
  179. }),
  180. )
  181. it.effect("groups tool names and defers registrations from direct exposure", () =>
  182. Effect.gen(function* () {
  183. const plugins = yield* PluginV2.Service
  184. const registry = yield* ToolRegistry.Service
  185. const tool = (description: string) =>
  186. Tool.make({
  187. description,
  188. input: Schema.Struct({}),
  189. output: Schema.Struct({ ok: Schema.Boolean }),
  190. execute: () => Effect.succeed({ ok: true }),
  191. })
  192. const plugin = EffectPlugin.define({
  193. id: "grouped-tools",
  194. effect: (ctx) =>
  195. ctx.tool
  196. .transform((draft) => {
  197. draft.add("plain", tool("Plain"))
  198. draft.add("look/up", tool("Lookup"), { group: "context 7" })
  199. draft.add("search", tool("Search"), { group: "context 7", deferred: true })
  200. })
  201. .pipe(Effect.orDie),
  202. })
  203. yield* plugins.activate([{ plugin }])
  204. expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
  205. "plain",
  206. "context_7_look_up",
  207. "execute",
  208. ])
  209. }),
  210. )
  211. it.effect("fires before/after tool hooks with mutable events around settlement", () =>
  212. Effect.gen(function* () {
  213. const plugins = yield* PluginV2.Service
  214. const registry = yield* ToolRegistry.Service
  215. const executed: unknown[] = []
  216. const seen: {
  217. before?: unknown
  218. after?: { input: unknown; result: unknown; output: unknown }
  219. } = {}
  220. const plugin = EffectPlugin.define({
  221. id: "tool-hooks",
  222. effect: (ctx) =>
  223. Effect.gen(function* () {
  224. yield* ctx.tool
  225. .transform((draft) =>
  226. draft.add(
  227. "echo",
  228. Tool.make({
  229. description: "Echo",
  230. input: Schema.Struct({ text: Schema.String }),
  231. output: Schema.Struct({ text: Schema.String }),
  232. execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
  233. }),
  234. ),
  235. )
  236. .pipe(Effect.orDie)
  237. yield* ctx.tool
  238. .hook("execute.before", (event) =>
  239. Effect.sync(() => {
  240. seen.before = event.input
  241. event.input = { text: "before-mutated" }
  242. }),
  243. )
  244. .pipe(Effect.asVoid)
  245. yield* ctx.tool
  246. .hook("execute.after", (event) =>
  247. Effect.sync(() => {
  248. seen.after = { input: event.input, result: event.result, output: event.output }
  249. event.result = { type: "text", value: "after-mutated" }
  250. event.output = { structured: { rewritten: true }, content: [] }
  251. }),
  252. )
  253. .pipe(Effect.asVoid)
  254. }),
  255. })
  256. yield* plugins.activate([{ plugin }])
  257. const materialized = yield* registry.materialize({ model: testModel })
  258. const settlement = yield* materialized.settle({
  259. sessionID: SessionV2.ID.make("ses_hooks"),
  260. agent: AgentV2.ID.make("build"),
  261. assistantMessageID: SessionMessage.ID.make("msg_hooks"),
  262. call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
  263. })
  264. expect(seen.before).toEqual({ text: "original" })
  265. expect(executed).toEqual([{ text: "before-mutated" }])
  266. expect(seen.after).toEqual({
  267. input: { text: "before-mutated" },
  268. result: { type: "json", value: { text: "before-mutated" } },
  269. output: { structured: { text: "before-mutated" }, content: [] },
  270. })
  271. expect(settlement.result).toEqual({ type: "text", value: "after-mutated" })
  272. expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
  273. }),
  274. )
  275. })