feat(core): manage configurable plugin generations
This commit is contained in:
parent
f9d1d3b259
commit
a9b7bd9e2f
83 changed files with 1116 additions and 819 deletions
|
|
@ -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) }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue