plugin.test.ts 13 KB

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