plugin.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { pathToFileURL } from "url"
  4. import { describe, expect } from "bun:test"
  5. import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
  6. import { Agent } from "@opencode-ai/core/agent"
  7. import { Catalog } from "@opencode-ai/core/catalog"
  8. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  9. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  10. import { Bus } from "@opencode-ai/core/bus"
  11. import { Location } from "@opencode-ai/core/location"
  12. import { LocationServiceMap } from "@opencode-ai/core/location-services"
  13. import { Plugin } from "@opencode-ai/core/plugin"
  14. import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
  15. import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
  16. import { Model } from "@opencode-ai/core/model"
  17. import { Provider } from "@opencode-ai/core/provider"
  18. import { AbsolutePath } from "@opencode-ai/core/schema"
  19. import { Effect, Fiber, Logger, Stream } from "effect"
  20. import { Database } from "../../src/database/database"
  21. import { tmpdir } from "../fixture/tmpdir"
  22. import { testEffect } from "../lib/effect"
  23. const it = testEffect(
  24. AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
  25. )
  26. describe("PluginSupervisor config", () => {
  27. it.live("applies selectors in order", () =>
  28. withLocation(
  29. { plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
  30. Effect.gen(function* () {
  31. const plugins = yield* Plugin.Service
  32. yield* ready()
  33. expect(
  34. (yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
  35. ).toEqual([Plugin.ID.make("opencode.provider.openai")])
  36. }),
  37. ),
  38. )
  39. it.live("loads configured Promise plugins with options", () =>
  40. withLocation(
  41. {
  42. plugins: [
  43. "-*",
  44. {
  45. package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
  46. options: { description: "Loaded from config" },
  47. },
  48. ],
  49. },
  50. Effect.gen(function* () {
  51. yield* ready()
  52. const agents = yield* Agent.Service
  53. expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
  54. description: "Loaded from config",
  55. mode: "subagent",
  56. })
  57. }),
  58. ),
  59. )
  60. it.live("disables configured plugins by exported ID", () => {
  61. const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
  62. return withLocation(
  63. { plugins: [plugin, "-config-promise-plugin"] },
  64. Effect.gen(function* () {
  65. yield* ready()
  66. const plugins = yield* Plugin.Service
  67. const agents = yield* Agent.Service
  68. expect((yield* plugins.list()).map((item) => String(item.id))).not.toContain("config-promise-plugin")
  69. expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
  70. }),
  71. )
  72. })
  73. it.live("does not disable configured plugins by package target", () => {
  74. const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
  75. return withLocation(
  76. { plugins: [plugin, `-${plugin}`] },
  77. Effect.gen(function* () {
  78. yield* ready()
  79. const plugins = yield* Plugin.Service
  80. expect((yield* plugins.list()).map((item) => String(item.id))).toContain("config-promise-plugin")
  81. }),
  82. )
  83. })
  84. it.live("loads configured Effect plugins with options", () =>
  85. withLocation(
  86. {
  87. plugins: [
  88. "-*",
  89. {
  90. package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
  91. options: { description: "Effect plugin from config" },
  92. },
  93. ],
  94. },
  95. Effect.gen(function* () {
  96. yield* ready()
  97. const agents = yield* Agent.Service
  98. expect(yield* agents.get(Agent.ID.make("effect-configured"))).toMatchObject({
  99. description: "Effect plugin from config",
  100. mode: "subagent",
  101. })
  102. }),
  103. ),
  104. )
  105. it.live("logs invalid packages and continues loading", () => {
  106. const output: string[] = []
  107. const logger = Logger.map(Logger.formatStructured, (entry) => {
  108. if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
  109. const details = entry.message[1]
  110. if (typeof details !== "object" || details === null || !("target" in details)) return
  111. if (typeof details.target === "string") output.push(details.target)
  112. })
  113. return withLocation(
  114. {
  115. plugins: [
  116. "-*",
  117. path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
  118. path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
  119. {
  120. package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
  121. options: { description: "Loaded after invalid plugins" },
  122. },
  123. ],
  124. },
  125. Effect.gen(function* () {
  126. yield* ready()
  127. const agents = yield* Agent.Service
  128. expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
  129. description: "Loaded after invalid plugins",
  130. })
  131. expect(output).toEqual([
  132. path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
  133. path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
  134. ])
  135. }),
  136. ).pipe(Effect.provide(Logger.layer([logger])))
  137. })
  138. it.live("loads auto-discovered plugin files", () =>
  139. withLocation(
  140. undefined,
  141. Effect.gen(function* () {
  142. yield* ready()
  143. const agents = yield* Agent.Service
  144. expect(yield* agents.get(Agent.ID.make("directory"))).toMatchObject({
  145. description: "Loaded from plugin directory",
  146. })
  147. }),
  148. true,
  149. ),
  150. )
  151. it.live("reloads an auto-discovered plugin when its file changes", () =>
  152. withLocation(
  153. undefined,
  154. Effect.gen(function* () {
  155. yield* ready()
  156. const agents = yield* Agent.Service
  157. const bus = yield* Bus.Service
  158. const location = yield* Location.Service
  159. const plugins = yield* Plugin.Service
  160. const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts")
  161. const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
  162. expect(first).toBeDefined()
  163. expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
  164. const changed = yield* bus
  165. .subscribe(Plugin.Event.Updated)
  166. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  167. yield* Effect.promise(async () => {
  168. await fs.writeFile(file, mutablePlugin("second"))
  169. const modified = new Date(Date.now() + 5_000)
  170. await fs.utimes(file, modified, modified)
  171. })
  172. yield* Fiber.join(changed).pipe(Effect.timeout("5 seconds"))
  173. const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
  174. expect(current).toBe(first)
  175. expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second")
  176. }),
  177. false,
  178. async (directory) => {
  179. const plugin = path.join(directory, ".opencode", "plugin")
  180. await fs.mkdir(plugin, { recursive: true })
  181. await fs.writeFile(path.join(plugin, "mutable.ts"), mutablePlugin("first"))
  182. },
  183. ),
  184. )
  185. it.live("reloads a configured plugin when its source file changes", () =>
  186. withLocation(
  187. { plugins: ["-*", "./external/mutable.ts"] },
  188. Effect.gen(function* () {
  189. yield* ready()
  190. const agents = yield* Agent.Service
  191. const bus = yield* Bus.Service
  192. const location = yield* Location.Service
  193. const file = path.join(location.directory, "external", "mutable.ts")
  194. expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
  195. const changed = yield* bus
  196. .subscribe(Plugin.Event.Updated)
  197. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  198. yield* Effect.promise(async () => {
  199. await fs.writeFile(file, mutablePlugin("second"))
  200. const modified = new Date(Date.now() + 5_000)
  201. await fs.utimes(file, modified, modified)
  202. })
  203. yield* Fiber.join(changed).pipe(Effect.timeout("5 seconds"))
  204. expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second")
  205. }),
  206. false,
  207. async (directory) => {
  208. // Outside any {plugin,plugins} config-source directory, so only the
  209. // configured-entrypoint watch can observe the edit.
  210. const external = path.join(directory, "external")
  211. await fs.mkdir(external, { recursive: true })
  212. await fs.writeFile(path.join(external, "mutable.ts"), mutablePlugin("first"))
  213. },
  214. ),
  215. )
  216. it.live("applies explicit removals after auto-discovery", () =>
  217. withLocation(
  218. { plugins: ["-*"] },
  219. Effect.gen(function* () {
  220. yield* ready()
  221. const agents = yield* Agent.Service
  222. expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
  223. }),
  224. true,
  225. ),
  226. )
  227. it.live("loads user plugins before internal post plugins", () =>
  228. Effect.gen(function* () {
  229. const sdk = yield* SdkPlugins.Service
  230. yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
  231. yield* withLocation(
  232. {
  233. plugins: [
  234. path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
  235. path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
  236. ],
  237. },
  238. Effect.gen(function* () {
  239. yield* ready()
  240. const registry = yield* Plugin.Service
  241. const ids = (yield* registry.list()).map((plugin) => String(plugin.id))
  242. expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order"))
  243. expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin"))
  244. expect(ids.indexOf("config-promise-plugin")).toBeLessThan(ids.indexOf("variant-source"))
  245. expect(ids.indexOf("variant-source")).toBeLessThan(ids.indexOf("opencode.config.provider"))
  246. expect(ids.indexOf("opencode.config.provider")).toBeLessThan(ids.indexOf("opencode.variant"))
  247. const catalog = yield* Catalog.Service
  248. expect(
  249. (yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants,
  250. ).toEqual([
  251. expect.objectContaining({ id: "high", headers: { custom: "true" } }),
  252. expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
  253. ])
  254. }),
  255. )
  256. }),
  257. )
  258. it.live("allows variant generation to be disabled", () =>
  259. withLocation(
  260. {
  261. plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
  262. },
  263. Effect.gen(function* () {
  264. yield* ready()
  265. const registry = yield* Plugin.Service
  266. expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
  267. const catalog = yield* Catalog.Service
  268. expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual([
  269. expect.objectContaining({ id: "high", headers: { custom: "true" } }),
  270. ])
  271. }),
  272. ),
  273. )
  274. })
  275. const ready = Effect.fnUntraced(function* () {
  276. const supervisor = yield* PluginSupervisor.Service
  277. yield* supervisor.flush
  278. })
  279. function withLocation<A, E, R>(
  280. config: unknown,
  281. effect: Effect.Effect<A, E, R>,
  282. fixtures = false,
  283. prepare?: (directory: string) => Promise<void>,
  284. ) {
  285. return Effect.acquireRelease(
  286. Effect.promise(() => tmpdir()),
  287. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  288. ).pipe(
  289. Effect.tap((tmp) =>
  290. Effect.promise(async () => {
  291. await prepare?.(tmp.path)
  292. if (fixtures) {
  293. const directory = path.join(tmp.path, ".opencode")
  294. await fs.mkdir(directory, { recursive: true })
  295. await Promise.all(
  296. ["plugin", "plugins"].map((name) =>
  297. fs.symlink(path.join(import.meta.dir, "fixtures", name), path.join(directory, name), "dir"),
  298. ),
  299. )
  300. }
  301. if (config !== undefined) {
  302. const directory = fixtures ? path.join(tmp.path, ".opencode") : tmp.path
  303. await fs.mkdir(directory, { recursive: true })
  304. await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config))
  305. }
  306. }),
  307. ),
  308. Effect.flatMap((tmp) =>
  309. effect.pipe(
  310. Effect.scoped,
  311. Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))),
  312. ),
  313. ),
  314. )
  315. }
  316. function mutablePlugin(description: string) {
  317. const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/promise/index.ts")).href
  318. return `
  319. import { Plugin } from ${JSON.stringify(plugin)}
  320. export default Plugin.define({
  321. id: "mutable-plugin",
  322. setup: async (ctx) => {
  323. await ctx.agent.transform((agents) => {
  324. agents.update("mutable", (agent) => {
  325. agent.description = ${JSON.stringify(description)}
  326. agent.mode = "subagent"
  327. })
  328. })
  329. },
  330. })
  331. `
  332. }