| import path from "path" |
| import { describe, expect } from "bun:test" |
| import { Effect, Schema } from "effect" |
| import { AgentV2 } from "@opencode-ai/core/agent" |
| import { Config } from "@opencode-ai/core/config" |
| import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" |
| import { FSUtil } from "@opencode-ai/core/fs-util" |
| import { Location } from "@opencode-ai/core/location" |
| import { Npm } from "@opencode-ai/core/npm" |
| import { PluginV2 } from "@opencode-ai/core/plugin" |
| import { PluginHost } from "@opencode-ai/core/plugin/host" |
| import { AbsolutePath } from "@opencode-ai/core/schema" |
| import { testEffect } from "../lib/effect" |
| import { PluginTestLayer } from "../plugin/fixture" |
|
|
| const it = testEffect(PluginTestLayer) |
| const decode = Schema.decodeUnknownSync(Config.Info) |
|
|
| describe("ConfigExternalPlugin", () => { |
| it.live("resolves and loads a configured Promise plugin with options", () => |
| Effect.gen(function* () { |
| const plugins = yield* PluginV2.Service |
| const agents = yield* AgentV2.Service |
| const fs = yield* FSUtil.Service |
| const location = yield* Location.Service |
| const npm = yield* Npm.Service |
| const host = yield* PluginHost.make(plugins) |
| const document = path.join(import.meta.dir, "opencode.json") |
|
|
| yield* ConfigExternalPlugin.Plugin.effect(host).pipe( |
| Effect.provideService(PluginV2.Service, plugins), |
| Effect.provideService(FSUtil.Service, fs), |
| Effect.provideService(Location.Service, location), |
| Effect.provideService(Npm.Service, npm), |
| Effect.provideService( |
| Config.Service, |
| Config.Service.of({ |
| entries: () => |
| Effect.succeed([ |
| new Config.Document({ |
| type: "document", |
| path: document, |
| info: decode({ |
| plugins: [ |
| { |
| package: "../plugin/fixtures/config-promise-plugin.ts", |
| options: { description: "Loaded from config" }, |
| }, |
| ], |
| }), |
| }), |
| ]), |
| }), |
| ), |
| ) |
|
|
| expect(yield* waitForAgent(agents, "configured")).toMatchObject({ |
| description: "Loaded from config", |
| mode: "subagent", |
| }) |
| }), |
| ) |
|
|
| it.live("loads a configured Effect plugin with options", () => |
| Effect.gen(function* () { |
| const plugins = yield* PluginV2.Service |
| const agents = yield* AgentV2.Service |
| const fs = yield* FSUtil.Service |
| const location = yield* Location.Service |
| const npm = yield* Npm.Service |
| const host = yield* PluginHost.make(plugins) |
|
|
| yield* ConfigExternalPlugin.Plugin.effect(host).pipe( |
| Effect.provideService(PluginV2.Service, plugins), |
| Effect.provideService(FSUtil.Service, fs), |
| Effect.provideService(Location.Service, location), |
| Effect.provideService(Npm.Service, npm), |
| Effect.provideService( |
| Config.Service, |
| Config.Service.of({ |
| entries: () => |
| Effect.succeed([ |
| new Config.Document({ |
| type: "document", |
| path: path.join(import.meta.dir, "opencode.json"), |
| info: decode({ |
| plugins: [ |
| { |
| package: "../plugin/fixtures/config-effect-plugin.ts", |
| options: { description: "Effect plugin from config" }, |
| }, |
| ], |
| }), |
| }), |
| ]), |
| }), |
| ), |
| ) |
|
|
| expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({ |
| description: "Effect plugin from config", |
| mode: "subagent", |
| }) |
| }), |
| ) |
|
|
| it.live("ignores invalid plugins and continues loading", () => |
| Effect.gen(function* () { |
| const plugins = yield* PluginV2.Service |
| const agents = yield* AgentV2.Service |
| const fs = yield* FSUtil.Service |
| const location = yield* Location.Service |
| const npm = yield* Npm.Service |
| const host = yield* PluginHost.make(plugins) |
|
|
| yield* ConfigExternalPlugin.Plugin.effect(host).pipe( |
| Effect.provideService(PluginV2.Service, plugins), |
| Effect.provideService(FSUtil.Service, fs), |
| Effect.provideService(Location.Service, location), |
| Effect.provideService(Npm.Service, npm), |
| Effect.provideService( |
| Config.Service, |
| Config.Service.of({ |
| entries: () => |
| Effect.succeed([ |
| new Config.Document({ |
| type: "document", |
| path: path.join(import.meta.dir, "opencode.json"), |
| info: decode({ |
| plugins: [ |
| "../plugin/fixtures/missing-plugin.ts", |
| "../plugin/fixtures/invalid-plugin.ts", |
| { |
| package: "../plugin/fixtures/config-promise-plugin.ts", |
| options: { description: "Loaded after invalid plugins" }, |
| }, |
| ], |
| }), |
| }), |
| ]), |
| }), |
| ), |
| ) |
|
|
| expect(yield* waitForAgent(agents, "configured")).toMatchObject({ |
| description: "Loaded after invalid plugins", |
| }) |
| }), |
| ) |
|
|
| it.live("installs and resolves npm plugin packages", () => |
| Effect.gen(function* () { |
| const plugins = yield* PluginV2.Service |
| const agents = yield* AgentV2.Service |
| const fs = yield* FSUtil.Service |
| const location = yield* Location.Service |
| const host = yield* PluginHost.make(plugins) |
| let installed: string | undefined |
| const npm = Npm.Service.of({ |
| add: (spec) => |
| Effect.sync(() => { |
| installed = spec |
| return { |
| directory: import.meta.dir, |
| entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), |
| } |
| }), |
| install: () => Effect.void, |
| which: () => Effect.succeed(undefined), |
| }) |
|
|
| yield* ConfigExternalPlugin.Plugin.effect(host).pipe( |
| Effect.provideService(PluginV2.Service, plugins), |
| Effect.provideService(FSUtil.Service, fs), |
| Effect.provideService(Location.Service, location), |
| Effect.provideService(Npm.Service, npm), |
| Effect.provideService( |
| Config.Service, |
| Config.Service.of({ |
| entries: () => |
| Effect.succeed([ |
| new Config.Document({ |
| type: "document", |
| info: decode({ |
| plugins: [ |
| { |
| package: "example-plugin@1.0.0", |
| options: { description: "Installed from npm" }, |
| }, |
| ], |
| }), |
| }), |
| ]), |
| }), |
| ), |
| ) |
|
|
| expect(yield* waitForAgent(agents, "configured")).toMatchObject({ |
| description: "Installed from npm", |
| }) |
| expect(installed).toBe("example-plugin@1.0.0") |
| }), |
| ) |
|
|
| it.live("loads plugin files from config directories", () => |
| Effect.gen(function* () { |
| const plugins = yield* PluginV2.Service |
| const agents = yield* AgentV2.Service |
| const fs = yield* FSUtil.Service |
| const location = yield* Location.Service |
| const npm = yield* Npm.Service |
| const host = yield* PluginHost.make(plugins) |
|
|
| yield* ConfigExternalPlugin.Plugin.effect(host).pipe( |
| Effect.provideService(PluginV2.Service, plugins), |
| Effect.provideService(FSUtil.Service, fs), |
| Effect.provideService(Location.Service, location), |
| Effect.provideService(Npm.Service, npm), |
| Effect.provideService( |
| Config.Service, |
| Config.Service.of({ |
| entries: () => |
| Effect.succeed([ |
| new Config.Directory({ |
| type: "directory", |
| path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")), |
| }), |
| ]), |
| }), |
| ), |
| ) |
|
|
| expect(yield* waitForAgent(agents, "directory")).toMatchObject({ |
| description: "Loaded from plugin directory", |
| mode: "subagent", |
| }) |
| }), |
| ) |
| }) |
|
|
| const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { |
| for (let attempt = 0; attempt < 100; attempt++) { |
| const agent = yield* agents.get(AgentV2.ID.make(id)) |
| if (agent) return agent |
| yield* Effect.sleep("10 millis") |
| } |
| return yield* Effect.die(`Timed out waiting for agent ${id}`) |
| }) |
|
|