feat(core): manage configurable plugin generations

This commit is contained in:
Dax Raad 2026-07-05 15:51:15 -04:00
commit a9b7bd9e2f
83 changed files with 1116 additions and 819 deletions

View file

@ -1,252 +1,225 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/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 { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
)
describe("ConfigExternalPlugin", () => {
it.live("resolves and loads a configured Promise plugin with options", () =>
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
withLocation(
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
yield* ready()
expect(
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
).toEqual([PluginV2.ID.make("opencode.provider.openai")])
}),
),
)
it.live("loads configured Promise plugins with options", () =>
withLocation(
{
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
options: { description: "Loaded from config" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
}),
),
)
it.live("loads configured Effect plugins with options", () =>
withLocation(
{
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
options: { description: "Effect plugin from config" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("effect-configured"))).toMatchObject({
description: "Effect plugin from config",
mode: "subagent",
})
}),
),
)
it.live("ignores invalid packages and continues loading", () =>
withLocation(
{
plugins: [
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
options: { description: "Loaded after invalid plugins" },
},
],
},
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
}),
),
)
it.live("loads auto-discovered plugin files and packages", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({
description: "Loaded from plugin directory",
})
expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({
description: "Loaded from plugin folder",
})
}),
true,
),
)
it.live("applies explicit removals after auto-discovery", () =>
withLocation(
{ plugins: ["-*"] },
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined()
expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined()
}),
true,
),
)
it.live("loads user plugins before internal post plugins", () =>
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")
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
yield* withLocation(
{
plugins: [
path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
],
},
Effect.gen(function* () {
yield* ready()
const registry = yield* PluginV2.Service
const ids = (yield* registry.list()).map((plugin) => String(plugin.id))
expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order"))
expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin"))
expect(ids.indexOf("config-promise-plugin")).toBeLessThan(ids.indexOf("variant-source"))
expect(ids.indexOf("variant-source")).toBeLessThan(ids.indexOf("opencode.config.provider"))
expect(ids.indexOf("opencode.config.provider")).toBeLessThan(ids.indexOf("opencode.variant"))
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" },
},
],
}),
}),
]),
}),
),
const catalog = yield* Catalog.Service
expect(
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
])
}),
)
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)
it.live("allows variant generation to be disabled", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
},
Effect.gen(function* () {
yield* ready()
const registry = yield* PluginV2.Service
expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
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",
})
expect(yield* waitForAgent(agents, "folder")).toMatchObject({
description: "Loaded from plugin folder",
mode: "subagent",
})
}),
const catalog = yield* Catalog.Service
expect(
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
).toEqual([expect.objectContaining({ id: "high", headers: { custom: "true" } })])
}),
),
)
})
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}`)
const ready = Effect.fnUntraced(function* () {
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.ready
})
function withLocation<A, E, R>(config: unknown, effect: Effect.Effect<A, E, R>, fixtures = false) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.tap((tmp) =>
Effect.promise(async () => {
if (fixtures) {
const directory = path.join(tmp.path, ".opencode")
await fs.mkdir(directory, { recursive: true })
await Promise.all(
["plugin", "plugins"].map((name) =>
fs.symlink(path.join(import.meta.dir, "fixtures", name), path.join(directory, name), "dir"),
),
)
}
if (config !== undefined) {
const directory = fixtures ? path.join(tmp.path, ".opencode") : tmp.path
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config))
}
}),
),
Effect.flatMap((tmp) =>
effect.pipe(
Effect.scoped,
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))),
),
),
)
}

View file

@ -7,7 +7,6 @@ import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
@ -37,7 +36,7 @@ describe("config plugin reloads", () => {
const references = yield* Reference.Service
const skills = yield* SkillV2.Service
const host = yield* PluginHost.make(plugins)
let entries: Config.Entry[] = [config("first", "First plugin")]
let entries: Config.Entry[] = [config("first")]
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
effect.pipe(Effect.provideService(Config.Service, service))
@ -47,7 +46,6 @@ describe("config plugin reloads", () => {
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
yield* setup(ConfigExternalPlugin.Plugin.effect(host))
expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command")
@ -56,9 +54,8 @@ describe("config plugin reloads", () => {
).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined()
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
entries = [config("second", "Second plugin")]
entries = [config("second")]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
@ -80,12 +77,11 @@ describe("config plugin reloads", () => {
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
)
})
function config(name: string, pluginDescription?: string) {
function config(name: string) {
return new Config.Document({
type: "document",
path: document,
@ -95,15 +91,6 @@ function config(name: string, pluginDescription?: string) {
skills: [`/skills/${name}`],
references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
plugins:
pluginDescription === undefined
? []
: [
{
package: "../plugin/fixtures/config-promise-plugin.ts",
options: { description: pluginDescription },
},
],
}),
})
}