plugin.test.ts 15 KB

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