plugin.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  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 { PluginTestLayer } from "./plugin/fixture"
  16. const it = testEffect(PluginTestLayer)
  17. class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
  18. const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
  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("replaces plugins by ID and version", () =>
  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. let updates = 0
  42. const unsubscribe = yield* events.listen((event) =>
  43. Effect.sync(() => {
  44. if (event.type === Plugin.Event.Updated.type) updates++
  45. }),
  46. )
  47. const managed = () =>
  48. EffectPlugin.define({
  49. id: "managed",
  50. effect: (ctx) =>
  51. ctx.agent
  52. .transform((agents) =>
  53. agents.update("configured", (agent) => {
  54. agent.description = description
  55. }),
  56. )
  57. .pipe(Effect.asVoid),
  58. })
  59. yield* plugins.activate([versioned(managed(), "1")])
  60. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
  61. description = "second"
  62. yield* plugins.activate([versioned(managed(), "2")])
  63. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
  64. description = "third"
  65. yield* plugins.activate([versioned(managed(), "2")])
  66. expect(updates).toBe(2)
  67. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
  68. yield* plugins.activate([])
  69. expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
  70. expect(updates).toBe(3)
  71. yield* unsubscribe
  72. }),
  73. )
  74. it.effect("rejects duplicate IDs before replacing active plugins", () =>
  75. Effect.gen(function* () {
  76. const plugins = yield* PluginV2.Service
  77. const active = Plugin.ID.make("active")
  78. const duplicate = "duplicate"
  79. yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
  80. const result = yield* plugins
  81. .activate([
  82. { id: duplicate, version: "1", effect: () => Effect.void },
  83. { id: duplicate, version: "1", effect: () => Effect.void },
  84. ])
  85. .pipe(Effect.exit)
  86. expect(Exit.isFailure(result)).toBe(true)
  87. expect(yield* plugins.list()).toEqual([{ id: active }])
  88. }),
  89. )
  90. it.effect("skips failed plugins and loads the rest", () =>
  91. Effect.gen(function* () {
  92. const plugins = yield* PluginV2.Service
  93. const agents = yield* AgentV2.Service
  94. let fail = true
  95. const good = EffectPlugin.define({
  96. id: "good",
  97. effect: (ctx) =>
  98. ctx.agent
  99. .transform((agents) =>
  100. agents.update("configured", (agent) => {
  101. agent.description = "loaded"
  102. }),
  103. )
  104. .pipe(Effect.asVoid),
  105. })
  106. const bad = EffectPlugin.define({
  107. id: "bad",
  108. effect: () => {
  109. if (fail) return Effect.die(new Error("materialization failed"))
  110. return Effect.void
  111. },
  112. })
  113. yield* plugins.activate([versioned(good), versioned(bad)])
  114. expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
  115. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
  116. fail = false
  117. yield* plugins.activate([versioned(good), versioned(bad, "2")])
  118. expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
  119. }),
  120. )
  121. it.effect("restores the previous plugin when its replacement fails", () =>
  122. Effect.gen(function* () {
  123. const plugins = yield* PluginV2.Service
  124. const agents = yield* AgentV2.Service
  125. const previous = EffectPlugin.define({
  126. id: "managed",
  127. effect: (ctx) =>
  128. ctx.agent
  129. .transform((agents) =>
  130. agents.update("configured", (agent) => {
  131. agent.description = "previous"
  132. }),
  133. )
  134. .pipe(Effect.asVoid),
  135. })
  136. const replacement = EffectPlugin.define({
  137. id: "managed",
  138. effect: (ctx) =>
  139. Effect.gen(function* () {
  140. yield* ctx.agent.transform((agents) =>
  141. agents.update("configured", (agent) => {
  142. agent.description = "replacement"
  143. }),
  144. )
  145. return yield* Effect.die(new Error("replacement failed"))
  146. }),
  147. })
  148. yield* plugins.activate([versioned(previous)])
  149. yield* plugins.activate([versioned(replacement, "2")])
  150. expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
  151. expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("previous")
  152. }),
  153. )
  154. it.effect("deactivates a plugin when replacement and restoration fail", () =>
  155. Effect.gen(function* () {
  156. const plugins = yield* PluginV2.Service
  157. const agents = yield* AgentV2.Service
  158. let loads = 0
  159. const previous = EffectPlugin.define({
  160. id: "managed",
  161. effect: (ctx) => {
  162. loads++
  163. if (loads > 1) return Effect.die(new Error("restoration failed"))
  164. return ctx.agent
  165. .transform((agents) =>
  166. agents.update("configured", (agent) => {
  167. agent.description = "previous"
  168. }),
  169. )
  170. .pipe(Effect.asVoid)
  171. },
  172. })
  173. const replacement = EffectPlugin.define({
  174. id: "managed",
  175. effect: () => Effect.die(new Error("replacement failed")),
  176. })
  177. yield* plugins.activate([versioned(previous)])
  178. yield* plugins.activate([versioned(replacement, "2")])
  179. expect(yield* plugins.list()).toEqual([])
  180. expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
  181. }),
  182. )
  183. it.effect("closes the previous generation in reverse order", () =>
  184. Effect.gen(function* () {
  185. const plugins = yield* PluginV2.Service
  186. const closed: string[] = []
  187. yield* plugins.activate(
  188. ["first", "second"].map((id) => ({
  189. id,
  190. version: "1",
  191. effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
  192. })),
  193. )
  194. yield* plugins.activate([])
  195. expect(closed).toEqual(["second", "first"])
  196. }),
  197. )
  198. it.effect("isolates plugins from ambient services", () =>
  199. Effect.gen(function* () {
  200. const plugins = yield* PluginV2.Service
  201. let visible = true
  202. const plugin = EffectPlugin.define({
  203. id: "isolated",
  204. effect: () =>
  205. Effect.serviceOption(Secret).pipe(
  206. Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
  207. Effect.asVoid,
  208. ),
  209. })
  210. yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
  211. expect(visible).toBe(false)
  212. }),
  213. )
  214. it.effect("registers location tools through the plugin context", () =>
  215. Effect.gen(function* () {
  216. const plugins = yield* PluginV2.Service
  217. const registry = yield* ToolRegistry.Service
  218. const plugin = EffectPlugin.define({
  219. id: "tool-plugin",
  220. effect: (ctx) =>
  221. ctx.tool
  222. .transform((draft) =>
  223. draft.add(
  224. "plugin_tool",
  225. Tool.make({
  226. description: "Plugin tool",
  227. input: Schema.Struct({}),
  228. output: Schema.Struct({ ok: Schema.Boolean }),
  229. execute: () => Effect.succeed({ ok: true }),
  230. }),
  231. { codemode: false },
  232. ),
  233. )
  234. .pipe(Effect.orDie),
  235. })
  236. yield* plugins.activate([versioned(plugin)])
  237. expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
  238. yield* plugins.activate([])
  239. expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
  240. }),
  241. )
  242. it.effect("namespaces tool names and routes codemode registrations through execute", () =>
  243. Effect.gen(function* () {
  244. const plugins = yield* PluginV2.Service
  245. const registry = yield* ToolRegistry.Service
  246. const tool = (description: string) =>
  247. Tool.make({
  248. description,
  249. input: Schema.Struct({}),
  250. output: Schema.Struct({ ok: Schema.Boolean }),
  251. execute: () => Effect.succeed({ ok: true }),
  252. })
  253. const plugin = EffectPlugin.define({
  254. id: "grouped-tools",
  255. effect: (ctx) =>
  256. ctx.tool
  257. .transform((draft) => {
  258. draft.add("plain", tool("Plain"), { codemode: false })
  259. draft.add("look/up", tool("Lookup"), { namespace: "context7", codemode: false })
  260. draft.add("search", tool("Search"), { namespace: "context7" })
  261. })
  262. .pipe(Effect.orDie),
  263. })
  264. yield* plugins.activate([versioned(plugin)])
  265. expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
  266. "plain",
  267. "context7_look_up",
  268. "execute",
  269. ])
  270. }),
  271. )
  272. it.effect("fires before/after tool hooks with mutable events around settlement", () =>
  273. Effect.gen(function* () {
  274. const plugins = yield* PluginV2.Service
  275. const registry = yield* ToolRegistry.Service
  276. const executed: unknown[] = []
  277. const seen: {
  278. before?: unknown
  279. after?: { input: unknown; result: unknown; output: unknown }
  280. } = {}
  281. const plugin = EffectPlugin.define({
  282. id: "tool-hooks",
  283. effect: (ctx) =>
  284. Effect.gen(function* () {
  285. yield* ctx.tool
  286. .transform((draft) =>
  287. draft.add(
  288. "echo",
  289. Tool.make({
  290. description: "Echo",
  291. input: Schema.Struct({ text: Schema.String }),
  292. output: Schema.Struct({ text: Schema.String }),
  293. execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
  294. }),
  295. { codemode: false },
  296. ),
  297. )
  298. .pipe(Effect.orDie)
  299. yield* ctx.tool
  300. .hook("execute.before", (event) =>
  301. Effect.sync(() => {
  302. seen.before = event.input
  303. event.input = { text: "before-mutated" }
  304. }),
  305. )
  306. .pipe(Effect.asVoid)
  307. yield* ctx.tool
  308. .hook("execute.after", (event) =>
  309. Effect.sync(() => {
  310. seen.after = { input: event.input, result: event.result, output: event.output }
  311. event.result = { type: "text", value: "after-mutated" }
  312. event.output = { structured: { rewritten: true }, content: [] }
  313. }),
  314. )
  315. .pipe(Effect.asVoid)
  316. }),
  317. })
  318. yield* plugins.activate([versioned(plugin)])
  319. const materialized = yield* registry.materialize()
  320. const settlement = yield* materialized.settle({
  321. sessionID: SessionV2.ID.make("ses_hooks"),
  322. agent: AgentV2.ID.make("build"),
  323. messageID: SessionMessage.ID.make("msg_hooks"),
  324. call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
  325. })
  326. expect(seen.before).toEqual({ text: "original" })
  327. expect(executed).toEqual([{ text: "before-mutated" }])
  328. expect(seen.after).toEqual({
  329. input: { text: "before-mutated" },
  330. result: { type: "json", value: { text: "before-mutated" } },
  331. output: { structured: { text: "before-mutated" }, content: [] },
  332. })
  333. expect(settlement.result).toEqual({ type: "text", value: "after-mutated" })
  334. expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
  335. }),
  336. )
  337. })