plugin.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  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/effect"
  4. import { Config as ConfigSchema } from "@opencode-ai/schema/config"
  5. import { Agent } from "@opencode-ai/core/agent"
  6. import { Bus } from "@opencode-ai/core/bus"
  7. import { Plugin } from "@opencode-ai/core/plugin"
  8. import { PluginHost } from "@opencode-ai/core/plugin/host"
  9. import { Session } from "@opencode-ai/core/session"
  10. import { SessionMessage } from "@opencode-ai/core/session/message"
  11. import { Tool } from "@opencode-ai/core/tool"
  12. import { testEffect } from "./lib/effect"
  13. import { PluginTestLayer } from "./plugin/fixture"
  14. const it = testEffect(PluginTestLayer)
  15. class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
  16. const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
  17. describe("Plugin", () => {
  18. it.live("exposes public events through the plugin context", () =>
  19. Effect.gen(function* () {
  20. const plugins = yield* Plugin.Service
  21. const bus = yield* Bus.Service
  22. const host = yield* PluginHost.make(plugins)
  23. const received = yield* host.event.subscribe().pipe(
  24. Stream.filter((event) => event.type === "config.updated"),
  25. Stream.runHead,
  26. Effect.forkScoped({ startImmediately: true }),
  27. )
  28. yield* Effect.sleep("10 millis")
  29. yield* bus.publish(ConfigSchema.Event.Updated, {})
  30. expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
  31. }),
  32. )
  33. it.effect("replaces plugins by ID and version", () =>
  34. Effect.gen(function* () {
  35. const plugins = yield* Plugin.Service
  36. const agents = yield* Agent.Service
  37. const bus = yield* Bus.Service
  38. let description = "first"
  39. let updates = 0
  40. const unsubscribe = yield* bus.listen((event) =>
  41. Effect.sync(() => {
  42. if (event.type === Plugin.Event.Updated.type) updates++
  43. }),
  44. )
  45. const managed = () =>
  46. EffectPlugin.define({
  47. id: "managed",
  48. effect: (ctx) =>
  49. ctx.agent
  50. .transform((agents) =>
  51. agents.update("configured", (agent) => {
  52. agent.description = description
  53. }),
  54. )
  55. .pipe(Effect.asVoid),
  56. })
  57. yield* plugins.activate([versioned(managed(), "1")])
  58. expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
  59. description = "second"
  60. yield* plugins.activate([versioned(managed(), "2")])
  61. expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
  62. description = "third"
  63. yield* plugins.activate([versioned(managed(), "2")])
  64. expect(updates).toBe(2)
  65. expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
  66. yield* plugins.activate([])
  67. expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
  68. expect(updates).toBe(3)
  69. yield* unsubscribe
  70. }),
  71. )
  72. it.effect("rejects duplicate IDs before replacing active plugins", () =>
  73. Effect.gen(function* () {
  74. const plugins = yield* Plugin.Service
  75. const active = Plugin.ID.make("active")
  76. const duplicate = "duplicate"
  77. yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
  78. const result = yield* plugins
  79. .activate([
  80. { id: duplicate, version: "1", effect: () => Effect.void },
  81. { id: duplicate, version: "1", effect: () => Effect.void },
  82. ])
  83. .pipe(Effect.exit)
  84. expect(Exit.isFailure(result)).toBe(true)
  85. expect(yield* plugins.list()).toEqual([{ id: active }])
  86. }),
  87. )
  88. it.effect("skips failed plugins and loads the rest", () =>
  89. Effect.gen(function* () {
  90. const plugins = yield* Plugin.Service
  91. const agents = yield* Agent.Service
  92. let fail = true
  93. const good = EffectPlugin.define({
  94. id: "good",
  95. effect: (ctx) =>
  96. ctx.agent
  97. .transform((agents) =>
  98. agents.update("configured", (agent) => {
  99. agent.description = "loaded"
  100. }),
  101. )
  102. .pipe(Effect.asVoid),
  103. })
  104. const bad = EffectPlugin.define({
  105. id: "bad",
  106. effect: () => {
  107. if (fail) return Effect.die(new Error("materialization failed"))
  108. return Effect.void
  109. },
  110. })
  111. yield* plugins.activate([versioned(good), versioned(bad)])
  112. expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
  113. expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
  114. fail = false
  115. yield* plugins.activate([versioned(good), versioned(bad, "2")])
  116. expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
  117. }),
  118. )
  119. it.effect("restores the previous plugin when its replacement fails", () =>
  120. Effect.gen(function* () {
  121. const plugins = yield* Plugin.Service
  122. const agents = yield* Agent.Service
  123. const previous = EffectPlugin.define({
  124. id: "managed",
  125. effect: (ctx) =>
  126. ctx.agent
  127. .transform((agents) =>
  128. agents.update("configured", (agent) => {
  129. agent.description = "previous"
  130. }),
  131. )
  132. .pipe(Effect.asVoid),
  133. })
  134. const replacement = EffectPlugin.define({
  135. id: "managed",
  136. effect: (ctx) =>
  137. Effect.gen(function* () {
  138. yield* ctx.agent.transform((agents) =>
  139. agents.update("configured", (agent) => {
  140. agent.description = "replacement"
  141. }),
  142. )
  143. return yield* Effect.die(new Error("replacement failed"))
  144. }),
  145. })
  146. yield* plugins.activate([versioned(previous)])
  147. yield* plugins.activate([versioned(replacement, "2")])
  148. expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
  149. expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
  150. }),
  151. )
  152. it.effect("deactivates a plugin when replacement and restoration fail", () =>
  153. Effect.gen(function* () {
  154. const plugins = yield* Plugin.Service
  155. const agents = yield* Agent.Service
  156. let loads = 0
  157. const previous = EffectPlugin.define({
  158. id: "managed",
  159. effect: (ctx) => {
  160. loads++
  161. if (loads > 1) return Effect.die(new Error("restoration failed"))
  162. return ctx.agent
  163. .transform((agents) =>
  164. agents.update("configured", (agent) => {
  165. agent.description = "previous"
  166. }),
  167. )
  168. .pipe(Effect.asVoid)
  169. },
  170. })
  171. const replacement = EffectPlugin.define({
  172. id: "managed",
  173. effect: () => Effect.die(new Error("replacement failed")),
  174. })
  175. yield* plugins.activate([versioned(previous)])
  176. yield* plugins.activate([versioned(replacement, "2")])
  177. expect(yield* plugins.list()).toEqual([])
  178. expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
  179. }),
  180. )
  181. it.effect("closes the previous generation in reverse order", () =>
  182. Effect.gen(function* () {
  183. const plugins = yield* Plugin.Service
  184. const closed: string[] = []
  185. yield* plugins.activate(
  186. ["first", "second"].map((id) => ({
  187. id,
  188. version: "1",
  189. effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
  190. })),
  191. )
  192. yield* plugins.activate([])
  193. expect(closed).toEqual(["second", "first"])
  194. }),
  195. )
  196. it.effect("isolates plugins from ambient services", () =>
  197. Effect.gen(function* () {
  198. const plugins = yield* Plugin.Service
  199. let visible = true
  200. const plugin = EffectPlugin.define({
  201. id: "isolated",
  202. effect: () =>
  203. Effect.serviceOption(Secret).pipe(
  204. Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
  205. Effect.asVoid,
  206. ),
  207. })
  208. yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
  209. expect(visible).toBe(false)
  210. }),
  211. )
  212. it.effect("registers location tools through the plugin context", () =>
  213. Effect.gen(function* () {
  214. const plugins = yield* Plugin.Service
  215. const registry = yield* Tool.Service
  216. const plugin = EffectPlugin.define({
  217. id: "tool-plugin",
  218. effect: (ctx) =>
  219. ctx.tool
  220. .transform((draft) =>
  221. draft.add(
  222. ({
  223. name: "plugin_tool",
  224. options: { codemode: false },
  225. description: "Plugin tool",
  226. input: Schema.Struct({}),
  227. output: Schema.Struct({ ok: Schema.Boolean }),
  228. execute: () => Effect.succeed({ output: { ok: true } }),
  229. }),
  230. ),
  231. )
  232. .pipe(Effect.orDie),
  233. })
  234. yield* plugins.activate([versioned(plugin)])
  235. expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
  236. yield* plugins.activate([])
  237. expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
  238. }),
  239. )
  240. it.effect("namespaces tool names and routes codemode registrations through execute", () =>
  241. Effect.gen(function* () {
  242. const plugins = yield* Plugin.Service
  243. const registry = yield* Tool.Service
  244. const tool = (name: string, description: string, options?: Tool.Options) =>
  245. ({
  246. name,
  247. options,
  248. description,
  249. input: Schema.Struct({}),
  250. output: Schema.Struct({ ok: Schema.Boolean }),
  251. execute: () => Effect.succeed({ output: { ok: true } }),
  252. })
  253. const plugin = EffectPlugin.define({
  254. id: "grouped-tools",
  255. effect: (ctx) =>
  256. ctx.tool
  257. .transform((draft) => {
  258. draft.add(tool("plain", "Plain", { codemode: false }))
  259. draft.add(tool("look/up", "Lookup", { namespace: "context7", codemode: false }))
  260. draft.add(tool("search", "Search", { namespace: "context7" }))
  261. })
  262. .pipe(Effect.orDie),
  263. })
  264. yield* plugins.activate([versioned(plugin)])
  265. expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
  266. "context7_look_up",
  267. "plain",
  268. "execute",
  269. ])
  270. }),
  271. )
  272. it.effect("fires before/after tool hooks with mutable events around execution", () =>
  273. Effect.gen(function* () {
  274. const plugins = yield* Plugin.Service
  275. const registry = yield* Tool.Service
  276. const executed: unknown[] = []
  277. const seen: {
  278. before?: unknown
  279. after?: { input: unknown; status: string; content: unknown; metadata: 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. ({
  289. name: "echo",
  290. options: { codemode: false },
  291. description: "Echo",
  292. input: Schema.Struct({ text: Schema.String }),
  293. output: Schema.Struct({ text: Schema.String }),
  294. execute: ({ text }) =>
  295. Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
  296. }),
  297. ),
  298. )
  299. .pipe(Effect.orDie)
  300. yield* ctx.tool
  301. .hook("execute.before", (event) =>
  302. Effect.sync(() => {
  303. seen.before = event.input
  304. event.input = { text: "before-mutated" }
  305. }),
  306. )
  307. .pipe(Effect.asVoid)
  308. yield* ctx.tool
  309. .hook("execute.after", (event) =>
  310. Effect.sync(() => {
  311. seen.after = {
  312. input: event.input,
  313. status: event.status,
  314. content: event.status === "completed" ? event.result.content : undefined,
  315. metadata: event.status === "completed" ? event.result.metadata : event.error.metadata,
  316. }
  317. if (event.status !== "completed") return
  318. event.result = {
  319. ...event.result,
  320. content: [{ type: "text", text: "after-mutated" }],
  321. metadata: { rewritten: true },
  322. }
  323. }),
  324. )
  325. .pipe(Effect.asVoid)
  326. yield* ctx.tool
  327. .hook("execute.after", (event) =>
  328. Effect.sync(() => {
  329. if (event.status === "completed" && Array.isArray(event.result.content)) event.result.content.splice(0)
  330. }),
  331. )
  332. .pipe(Effect.asVoid)
  333. }),
  334. })
  335. yield* plugins.activate([versioned(plugin)])
  336. const toolSet = yield* registry.snapshot()
  337. const execution = yield* toolSet.execute({
  338. sessionID: Session.ID.make("ses_hooks"),
  339. agent: Agent.ID.make("build"),
  340. messageID: SessionMessage.ID.make("msg_hooks"),
  341. call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
  342. })
  343. expect(seen.before).toEqual({ text: "original" })
  344. expect(executed).toEqual([{ text: "before-mutated" }])
  345. expect(seen.after).toEqual({
  346. input: { text: "before-mutated" },
  347. status: "completed",
  348. content: [{ type: "text", text: '{"text":"before-mutated"}' }],
  349. metadata: undefined,
  350. })
  351. expect(execution).toMatchObject({
  352. content: [{ type: "text", text: '{"text":"before-mutated"}' }],
  353. metadata: { rewritten: true },
  354. })
  355. }),
  356. )
  357. })