plugin.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { Effect, Schema } from "effect"
  4. import { AgentV2 } from "@opencode-ai/core/agent"
  5. import { Config } from "@opencode-ai/core/config"
  6. import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
  7. import { FSUtil } from "@opencode-ai/core/fs-util"
  8. import { Location } from "@opencode-ai/core/location"
  9. import { Npm } from "@opencode-ai/core/npm"
  10. import { PluginV2 } from "@opencode-ai/core/plugin"
  11. import { PluginHost } from "@opencode-ai/core/plugin/host"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { testEffect } from "../lib/effect"
  14. import { PluginTestLayer } from "../plugin/fixture"
  15. const it = testEffect(PluginTestLayer)
  16. const decode = Schema.decodeUnknownSync(Config.Info)
  17. describe("ConfigExternalPlugin", () => {
  18. it.live("resolves and loads a configured Promise plugin with options", () =>
  19. Effect.gen(function* () {
  20. const plugins = yield* PluginV2.Service
  21. const agents = yield* AgentV2.Service
  22. const fs = yield* FSUtil.Service
  23. const location = yield* Location.Service
  24. const npm = yield* Npm.Service
  25. const host = yield* PluginHost.make(plugins)
  26. const document = path.join(import.meta.dir, "opencode.json")
  27. yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
  28. Effect.provideService(PluginV2.Service, plugins),
  29. Effect.provideService(FSUtil.Service, fs),
  30. Effect.provideService(Location.Service, location),
  31. Effect.provideService(Npm.Service, npm),
  32. Effect.provideService(
  33. Config.Service,
  34. Config.Service.of({
  35. entries: () =>
  36. Effect.succeed([
  37. new Config.Document({
  38. type: "document",
  39. path: document,
  40. info: decode({
  41. plugins: [
  42. {
  43. package: "../plugin/fixtures/config-promise-plugin.ts",
  44. options: { description: "Loaded from config" },
  45. },
  46. ],
  47. }),
  48. }),
  49. ]),
  50. }),
  51. ),
  52. )
  53. expect(yield* waitForAgent(agents, "configured")).toMatchObject({
  54. description: "Loaded from config",
  55. mode: "subagent",
  56. })
  57. }),
  58. )
  59. it.live("loads a configured Effect plugin with options", () =>
  60. Effect.gen(function* () {
  61. const plugins = yield* PluginV2.Service
  62. const agents = yield* AgentV2.Service
  63. const fs = yield* FSUtil.Service
  64. const location = yield* Location.Service
  65. const npm = yield* Npm.Service
  66. const host = yield* PluginHost.make(plugins)
  67. yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
  68. Effect.provideService(PluginV2.Service, plugins),
  69. Effect.provideService(FSUtil.Service, fs),
  70. Effect.provideService(Location.Service, location),
  71. Effect.provideService(Npm.Service, npm),
  72. Effect.provideService(
  73. Config.Service,
  74. Config.Service.of({
  75. entries: () =>
  76. Effect.succeed([
  77. new Config.Document({
  78. type: "document",
  79. path: path.join(import.meta.dir, "opencode.json"),
  80. info: decode({
  81. plugins: [
  82. {
  83. package: "../plugin/fixtures/config-effect-plugin.ts",
  84. options: { description: "Effect plugin from config" },
  85. },
  86. ],
  87. }),
  88. }),
  89. ]),
  90. }),
  91. ),
  92. )
  93. expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({
  94. description: "Effect plugin from config",
  95. mode: "subagent",
  96. })
  97. }),
  98. )
  99. it.live("ignores invalid plugins and continues loading", () =>
  100. Effect.gen(function* () {
  101. const plugins = yield* PluginV2.Service
  102. const agents = yield* AgentV2.Service
  103. const fs = yield* FSUtil.Service
  104. const location = yield* Location.Service
  105. const npm = yield* Npm.Service
  106. const host = yield* PluginHost.make(plugins)
  107. yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
  108. Effect.provideService(PluginV2.Service, plugins),
  109. Effect.provideService(FSUtil.Service, fs),
  110. Effect.provideService(Location.Service, location),
  111. Effect.provideService(Npm.Service, npm),
  112. Effect.provideService(
  113. Config.Service,
  114. Config.Service.of({
  115. entries: () =>
  116. Effect.succeed([
  117. new Config.Document({
  118. type: "document",
  119. path: path.join(import.meta.dir, "opencode.json"),
  120. info: decode({
  121. plugins: [
  122. "../plugin/fixtures/missing-plugin.ts",
  123. "../plugin/fixtures/invalid-plugin.ts",
  124. {
  125. package: "../plugin/fixtures/config-promise-plugin.ts",
  126. options: { description: "Loaded after invalid plugins" },
  127. },
  128. ],
  129. }),
  130. }),
  131. ]),
  132. }),
  133. ),
  134. )
  135. expect(yield* waitForAgent(agents, "configured")).toMatchObject({
  136. description: "Loaded after invalid plugins",
  137. })
  138. }),
  139. )
  140. it.live("installs and resolves npm plugin packages", () =>
  141. Effect.gen(function* () {
  142. const plugins = yield* PluginV2.Service
  143. const agents = yield* AgentV2.Service
  144. const fs = yield* FSUtil.Service
  145. const location = yield* Location.Service
  146. const host = yield* PluginHost.make(plugins)
  147. let installed: string | undefined
  148. const npm = Npm.Service.of({
  149. add: (spec) =>
  150. Effect.sync(() => {
  151. installed = spec
  152. return {
  153. directory: import.meta.dir,
  154. entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
  155. }
  156. }),
  157. install: () => Effect.void,
  158. which: () => Effect.succeed(undefined),
  159. })
  160. yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
  161. Effect.provideService(PluginV2.Service, plugins),
  162. Effect.provideService(FSUtil.Service, fs),
  163. Effect.provideService(Location.Service, location),
  164. Effect.provideService(Npm.Service, npm),
  165. Effect.provideService(
  166. Config.Service,
  167. Config.Service.of({
  168. entries: () =>
  169. Effect.succeed([
  170. new Config.Document({
  171. type: "document",
  172. info: decode({
  173. plugins: [
  174. {
  175. package: "example-plugin@1.0.0",
  176. options: { description: "Installed from npm" },
  177. },
  178. ],
  179. }),
  180. }),
  181. ]),
  182. }),
  183. ),
  184. )
  185. expect(yield* waitForAgent(agents, "configured")).toMatchObject({
  186. description: "Installed from npm",
  187. })
  188. expect(installed).toBe("example-plugin@1.0.0")
  189. }),
  190. )
  191. it.live("loads plugin files from config directories", () =>
  192. Effect.gen(function* () {
  193. const plugins = yield* PluginV2.Service
  194. const agents = yield* AgentV2.Service
  195. const fs = yield* FSUtil.Service
  196. const location = yield* Location.Service
  197. const npm = yield* Npm.Service
  198. const host = yield* PluginHost.make(plugins)
  199. yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
  200. Effect.provideService(PluginV2.Service, plugins),
  201. Effect.provideService(FSUtil.Service, fs),
  202. Effect.provideService(Location.Service, location),
  203. Effect.provideService(Npm.Service, npm),
  204. Effect.provideService(
  205. Config.Service,
  206. Config.Service.of({
  207. entries: () =>
  208. Effect.succeed([
  209. new Config.Directory({
  210. type: "directory",
  211. path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")),
  212. }),
  213. ]),
  214. }),
  215. ),
  216. )
  217. expect(yield* waitForAgent(agents, "directory")).toMatchObject({
  218. description: "Loaded from plugin directory",
  219. mode: "subagent",
  220. })
  221. }),
  222. )
  223. })
  224. const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
  225. for (let attempt = 0; attempt < 100; attempt++) {
  226. const agent = yield* agents.get(AgentV2.ID.make(id))
  227. if (agent) return agent
  228. yield* Effect.sleep("10 millis")
  229. }
  230. return yield* Effect.die(`Timed out waiting for agent ${id}`)
  231. })