plugin.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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 { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
  9. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  10. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  11. import { Global } from "@opencode-ai/util/global"
  12. import { Bus } from "@opencode-ai/core/bus"
  13. import { Location } from "@opencode-ai/core/location"
  14. import { LocationServiceMap } from "@opencode-ai/core/location-services"
  15. import { Plugin } from "@opencode-ai/core/plugin"
  16. import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
  17. import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
  18. import { Model } from "@opencode-ai/core/model"
  19. import { Provider } from "@opencode-ai/core/provider"
  20. import { AbsolutePath } from "@opencode-ai/core/schema"
  21. import { Effect, Fiber, Logger, Stream } from "effect"
  22. import { Database } from "../../src/database/database"
  23. import { tmpdir } from "../fixture/tmpdir"
  24. import { tempGlobalLayer } from "../fixture/global"
  25. import { testEffect } from "../lib/effect"
  26. const it = testEffect(
  27. AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
  28. [Global.node, tempGlobalLayer],
  29. ]),
  30. )
  31. const staticIt = testEffect(
  32. AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
  33. [ConfigPluginSource.node, ConfigPluginSource.empty],
  34. [Global.node, tempGlobalLayer],
  35. ]),
  36. )
  37. describe("PluginSupervisor config", () => {
  38. it.live("applies selectors in order", () =>
  39. withLocation(
  40. { plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
  41. Effect.gen(function* () {
  42. const plugins = yield* Plugin.Service
  43. yield* ready()
  44. expect(
  45. (yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
  46. ).toEqual([Plugin.ID.make("opencode.provider.openai")])
  47. }),
  48. ),
  49. )
  50. it.live("loads configured Promise plugins with options", () =>
  51. withLocation(
  52. {
  53. plugins: [
  54. "-*",
  55. {
  56. package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
  57. options: { description: "Loaded from config" },
  58. },
  59. ],
  60. },
  61. Effect.gen(function* () {
  62. yield* ready()
  63. const agents = yield* Agent.Service
  64. expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
  65. description: "Loaded from config",
  66. mode: "subagent",
  67. })
  68. }),
  69. ),
  70. )
  71. it.live("disables configured plugins by exported ID", () => {
  72. const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
  73. return withLocation(
  74. { plugins: [plugin, "-config-promise-plugin"] },
  75. Effect.gen(function* () {
  76. yield* ready()
  77. const plugins = yield* Plugin.Service
  78. const agents = yield* Agent.Service
  79. expect((yield* plugins.list()).map((item) => String(item.id))).not.toContain("config-promise-plugin")
  80. expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
  81. }),
  82. )
  83. })
  84. it.live("does not disable configured plugins by package target", () => {
  85. const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
  86. return withLocation(
  87. { plugins: [plugin, `-${plugin}`] },
  88. Effect.gen(function* () {
  89. yield* ready()
  90. const plugins = yield* Plugin.Service
  91. expect((yield* plugins.list()).map((item) => String(item.id))).toContain("config-promise-plugin")
  92. }),
  93. )
  94. })
  95. it.live("loads configured Effect plugins with options", () =>
  96. withLocation(
  97. {
  98. plugins: [
  99. "-*",
  100. {
  101. package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
  102. options: { description: "Effect plugin from config" },
  103. },
  104. ],
  105. },
  106. Effect.gen(function* () {
  107. yield* ready()
  108. const agents = yield* Agent.Service
  109. expect(yield* agents.get(Agent.ID.make("effect-configured"))).toMatchObject({
  110. description: "Effect plugin from config",
  111. mode: "subagent",
  112. })
  113. }),
  114. ),
  115. )
  116. it.live("logs invalid packages and continues loading", () => {
  117. const output: string[] = []
  118. const logger = Logger.map(Logger.formatStructured, (entry) => {
  119. if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
  120. const details = entry.message[1]
  121. if (typeof details !== "object" || details === null || !("target" in details)) return
  122. if (typeof details.target === "string") output.push(details.target)
  123. })
  124. return withLocation(
  125. {
  126. plugins: [
  127. "-*",
  128. path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
  129. path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
  130. {
  131. package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
  132. options: { description: "Loaded after invalid plugins" },
  133. },
  134. ],
  135. },
  136. Effect.gen(function* () {
  137. yield* ready()
  138. const agents = yield* Agent.Service
  139. expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
  140. description: "Loaded after invalid plugins",
  141. })
  142. expect(output).toEqual([
  143. path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
  144. path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
  145. ])
  146. }),
  147. ).pipe(Effect.provide(Logger.layer([logger])))
  148. })
  149. it.live("loads auto-discovered plugin files", () =>
  150. withLocation(
  151. undefined,
  152. Effect.gen(function* () {
  153. yield* ready()
  154. const agents = yield* Agent.Service
  155. expect(yield* agents.get(Agent.ID.make("directory"))).toMatchObject({
  156. description: "Loaded from plugin directory",
  157. })
  158. }),
  159. true,
  160. ),
  161. )
  162. it.live("loads auto-discovered plugin package entrypoints in order", () =>
  163. withLocation(
  164. undefined,
  165. Effect.gen(function* () {
  166. yield* ready()
  167. const plugins = yield* Plugin.Service
  168. const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
  169. expect(ids).toContain("package-exports")
  170. expect(ids).toContain("package-module")
  171. expect(ids).toContain("package-main")
  172. expect(ids).toContain("package-index")
  173. }),
  174. false,
  175. async (directory) => {
  176. await Promise.all([
  177. writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }),
  178. writeDiscoveredPackage(
  179. directory,
  180. "module",
  181. { exports: "./missing.js", module: "./entry.js" },
  182. { "entry.js": "package-module" },
  183. ),
  184. writeDiscoveredPackage(
  185. directory,
  186. "main",
  187. { exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" },
  188. { "entry.js": "package-main" },
  189. ),
  190. writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }),
  191. ])
  192. },
  193. ),
  194. )
  195. it.live("keeps auto-discovered package entrypoints inside the package directory", () =>
  196. withLocation(
  197. undefined,
  198. Effect.gen(function* () {
  199. yield* ready()
  200. const plugins = yield* Plugin.Service
  201. const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
  202. expect(ids).toContain("contained-fallback")
  203. expect(ids).toContain("symlink-fallback")
  204. expect(ids).not.toContain("escaped-entrypoint")
  205. }),
  206. false,
  207. async (directory) => {
  208. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  209. await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
  210. await writeDiscoveredPackage(
  211. directory,
  212. "contained",
  213. { exports: "../../escape.js" },
  214. { "index.js": "contained-fallback" },
  215. )
  216. await writeDiscoveredPackage(
  217. directory,
  218. "symlink",
  219. { exports: "./entry.js" },
  220. { "index.js": "symlink-fallback" },
  221. )
  222. await fs.symlink(
  223. path.join(directory, ".opencode", "escape.js"),
  224. path.join(directory, ".opencode", "plugins", "symlink", "entry.js"),
  225. )
  226. },
  227. ),
  228. )
  229. staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
  230. Effect.gen(function* () {
  231. const sdk = yield* SdkPlugins.Service
  232. yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
  233. yield* withLocation(
  234. { plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
  235. Effect.gen(function* () {
  236. yield* ready()
  237. const plugins = yield* Plugin.Service
  238. const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
  239. expect(ids).toContain("opencode.agent")
  240. expect(ids).toContain("static-sdk")
  241. expect(ids).not.toContain("config-promise-plugin")
  242. const agents = yield* Agent.Service
  243. expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
  244. expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
  245. }),
  246. true,
  247. )
  248. }),
  249. )
  250. it.live("reloads an auto-discovered plugin when its file changes", () =>
  251. withLocation(
  252. undefined,
  253. Effect.gen(function* () {
  254. yield* ready()
  255. const agents = yield* Agent.Service
  256. const bus = yield* Bus.Service
  257. const location = yield* Location.Service
  258. const plugins = yield* Plugin.Service
  259. const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts")
  260. const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
  261. expect(first).toBeDefined()
  262. expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
  263. const changed = yield* bus
  264. .subscribe(Plugin.Event.Updated)
  265. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  266. yield* Effect.promise(async () => {
  267. await fs.writeFile(file, mutablePlugin("second"))
  268. const modified = new Date(Date.now() + 5_000)
  269. await fs.utimes(file, modified, modified)
  270. })
  271. yield* Fiber.join(changed).pipe(Effect.timeout("5 seconds"))
  272. const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
  273. expect(current).toBe(first)
  274. expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second")
  275. }),
  276. false,
  277. async (directory) => {
  278. const plugin = path.join(directory, ".opencode", "plugin")
  279. await fs.mkdir(plugin, { recursive: true })
  280. await fs.writeFile(path.join(plugin, "mutable.ts"), mutablePlugin("first"))
  281. },
  282. ),
  283. )
  284. it.live("reloads a configured plugin when its source file changes", () =>
  285. withLocation(
  286. { plugins: ["-*", "./external/mutable.ts"] },
  287. Effect.gen(function* () {
  288. yield* ready()
  289. const agents = yield* Agent.Service
  290. const bus = yield* Bus.Service
  291. const location = yield* Location.Service
  292. const file = path.join(location.directory, "external", "mutable.ts")
  293. expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
  294. const changed = yield* bus
  295. .subscribe(Plugin.Event.Updated)
  296. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  297. yield* Effect.promise(async () => {
  298. await fs.writeFile(file, mutablePlugin("second"))
  299. const modified = new Date(Date.now() + 5_000)
  300. await fs.utimes(file, modified, modified)
  301. })
  302. yield* Fiber.join(changed).pipe(Effect.timeout("5 seconds"))
  303. expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second")
  304. }),
  305. false,
  306. async (directory) => {
  307. // Outside any {plugin,plugins} config-source directory, so only the
  308. // configured-entrypoint watch can observe the edit.
  309. const external = path.join(directory, "external")
  310. await fs.mkdir(external, { recursive: true })
  311. await fs.writeFile(path.join(external, "mutable.ts"), mutablePlugin("first"))
  312. },
  313. ),
  314. )
  315. it.live("applies explicit removals after auto-discovery", () =>
  316. withLocation(
  317. { plugins: ["-*"] },
  318. Effect.gen(function* () {
  319. yield* ready()
  320. const agents = yield* Agent.Service
  321. expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
  322. }),
  323. true,
  324. ),
  325. )
  326. it.live("loads user plugins before internal post plugins", () =>
  327. Effect.gen(function* () {
  328. const sdk = yield* SdkPlugins.Service
  329. yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
  330. yield* withLocation(
  331. {
  332. plugins: [
  333. path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
  334. path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
  335. ],
  336. },
  337. Effect.gen(function* () {
  338. yield* ready()
  339. const registry = yield* Plugin.Service
  340. const ids = (yield* registry.list()).map((plugin) => String(plugin.id))
  341. expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order"))
  342. expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin"))
  343. expect(ids.indexOf("config-promise-plugin")).toBeLessThan(ids.indexOf("variant-source"))
  344. expect(ids.indexOf("variant-source")).toBeLessThan(ids.indexOf("opencode.config.provider"))
  345. expect(ids.indexOf("opencode.config.provider")).toBeLessThan(ids.indexOf("opencode.variant"))
  346. const catalog = yield* Catalog.Service
  347. expect(
  348. (yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants,
  349. ).toEqual([
  350. expect.objectContaining({ id: "high", headers: { custom: "true" } }),
  351. expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
  352. ])
  353. }),
  354. )
  355. }),
  356. )
  357. it.live("allows variant generation to be disabled", () =>
  358. withLocation(
  359. {
  360. plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
  361. },
  362. Effect.gen(function* () {
  363. yield* ready()
  364. const registry = yield* Plugin.Service
  365. expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
  366. const catalog = yield* Catalog.Service
  367. expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual([
  368. expect.objectContaining({ id: "high", headers: { custom: "true" } }),
  369. ])
  370. }),
  371. ),
  372. )
  373. })
  374. const ready = Effect.fnUntraced(function* () {
  375. const supervisor = yield* PluginSupervisor.Service
  376. yield* supervisor.flush
  377. })
  378. function withLocation<A, E, R>(
  379. config: unknown,
  380. effect: Effect.Effect<A, E, R>,
  381. fixtures = false,
  382. prepare?: (directory: string) => Promise<void>,
  383. ) {
  384. return Effect.acquireRelease(
  385. Effect.promise(() => tmpdir()),
  386. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  387. ).pipe(
  388. Effect.tap((tmp) =>
  389. Effect.promise(async () => {
  390. await prepare?.(tmp.path)
  391. if (fixtures) {
  392. const directory = path.join(tmp.path, ".opencode")
  393. await fs.mkdir(directory, { recursive: true })
  394. await Promise.all(
  395. ["plugin", "plugins"].map((name) =>
  396. fs.symlink(path.join(import.meta.dir, "fixtures", name), path.join(directory, name), "dir"),
  397. ),
  398. )
  399. }
  400. if (config !== undefined) {
  401. const directory = fixtures ? path.join(tmp.path, ".opencode") : tmp.path
  402. await fs.mkdir(directory, { recursive: true })
  403. await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config))
  404. }
  405. }),
  406. ),
  407. Effect.flatMap((tmp) =>
  408. effect.pipe(
  409. Effect.scoped,
  410. Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))),
  411. ),
  412. ),
  413. )
  414. }
  415. function mutablePlugin(description: string) {
  416. const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/promise/index.ts")).href
  417. return `
  418. import { Plugin } from ${JSON.stringify(plugin)}
  419. export default Plugin.define({
  420. id: "mutable-plugin",
  421. setup: async (ctx) => {
  422. await ctx.agent.transform((agents) => {
  423. agents.update("mutable", (agent) => {
  424. agent.description = ${JSON.stringify(description)}
  425. agent.mode = "subagent"
  426. })
  427. })
  428. },
  429. })
  430. `
  431. }
  432. function discoveredPlugin(id: string) {
  433. return `export default { id: ${JSON.stringify(id)}, setup() {} }`
  434. }
  435. async function writeDiscoveredPackage(
  436. directory: string,
  437. name: string,
  438. manifest: Record<string, unknown> | undefined,
  439. files: Record<string, string>,
  440. ) {
  441. const plugin = path.join(directory, ".opencode", "plugins", name)
  442. await fs.mkdir(plugin, { recursive: true })
  443. await Promise.all([
  444. ...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []),
  445. ...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
  446. ])
  447. }