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 },
},
],
}),
})
}

View file

@ -1,7 +1,8 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Schema } from "effect"
import { Config } from "@opencode-ai/schema/config"
import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@ -10,6 +11,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -27,6 +29,124 @@ import { ToolRegistry } from "../src/tool/registry"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
describe("LocationServiceMap", () => {
it.live("applies ordered plugin config operations during boot", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
)
const plugins = yield* Effect.gen(function* () {
const plugins = yield* PluginV2.Service
yield* (yield* PluginSupervisor.Service).ready
return yield* plugins.list()
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
),
)
expect(plugins.map((plugin) => plugin.id)).toEqual([PluginV2.ID.make("opencode.agent")])
}),
),
),
)
it.live("reloads the plugin generation after config updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const file = path.join(dir.path, "opencode.json")
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
yield* Effect.gen(function* () {
const registry = yield* PluginV2.Service
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.ready
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break
yield* Effect.sleep("20 millis")
}
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"])
yield* Effect.promise(() =>
fs.writeFile(
file,
JSON.stringify({
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
}),
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).length === 0) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break
yield* Effect.sleep("20 millis")
}
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
),
)
}),
),
),
)
it.live("routes located events only to their location", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([first, second]) =>
Effect.scoped(
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.Service
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
const firstContext = yield* locations.contextEffect(firstRef)
const secondContext = yield* locations.contextEffect(secondRef)
const received = { first: 0, second: 0 }
yield* events.subscribe(Config.Event.Updated).pipe(
Stream.runForEach(() => Effect.sync(() => received.first++)),
Effect.provideContext(firstContext),
Effect.forkScoped({ startImmediately: true }),
)
yield* events.subscribe(Config.Event.Updated).pipe(
Stream.runForEach(() => Effect.sync(() => received.second++)),
Effect.provideContext(secondContext),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 millis")
yield* events.publish(Config.Event.Updated, {}, { location: firstRef })
yield* Effect.sleep("10 millis")
expect(received).toEqual({ first: 1, second: 0 })
}),
),
),
),
)
it.live("reuses cached services for constructed and decoded location refs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@ -64,7 +184,7 @@ describe("LocationServiceMap", () => {
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const registry = yield* ToolRegistry.Service
// Tool plugins register during the forked PluginInternal boot; wait for
// Tool plugins register during the forked PluginSupervisor boot; wait for
// every expected tool rather than relying on batch ordering.
yield* Effect.forEach(
[
@ -257,7 +377,7 @@ describe("LocationServiceMap", () => {
})
.pipe(Effect.asVoid),
})
yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect)
yield* plugins.activate([{ id: PluginV2.ID.make(reviewer.id), effect: reviewer.effect }])
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { AgentV2 } from "@opencode-ai/core/agent"
@ -16,6 +16,8 @@ import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
describe("PluginV2", () => {
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
@ -35,43 +37,15 @@ describe("PluginV2", () => {
}),
)
it.effect("waits for a plugin and returns immediately once active", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const id = PluginV2.ID.make("waited")
const waiting = yield* plugins.wait(id).pipe(Effect.forkChild)
yield* plugins.add(id, () => Effect.void)
yield* Fiber.join(waiting)
yield* plugins.wait(id)
}),
)
it.effect("propagates plugin activation defects to waiters", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const id = PluginV2.ID.make("failed")
const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild)
const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit)
const pending = yield* Fiber.join(waiting)
const later = yield* plugins.wait(id).pipe(Effect.exit)
expect(Exit.isFailure(added)).toBe(true)
expect(Exit.isFailure(pending)).toBe(true)
expect(Exit.isFailure(later)).toBe(true)
}),
)
it.effect("adds, replaces, and removes plugins", () =>
it.effect("skips identical generations and replaces changed plugin IDs", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
let description = "first"
const managed = () =>
const managed = (id: string) =>
define({
id: "managed",
id,
effect: (ctx) =>
ctx.agent
.transform((agents) =>
@ -82,19 +56,101 @@ describe("PluginV2", () => {
.pipe(Effect.asVoid),
})
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
yield* plugins.activate([managed("managed")])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
yield* plugins.activate([managed("managed")])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
yield* plugins.activate([managed("managed-next")])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
yield* plugins.remove(PluginV2.ID.make("managed"))
yield* plugins.activate([])
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
}),
)
it.effect("rejects duplicate IDs before replacing the active generation", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const active = PluginV2.ID.make("active")
const duplicate = PluginV2.ID.make("duplicate")
yield* plugins.activate([{ id: active, effect: () => Effect.void }])
const result = yield* plugins
.activate([
{ id: duplicate, effect: () => Effect.void },
{ id: duplicate, effect: () => Effect.void },
])
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([{ id: active }])
}),
)
it.effect("retries the same generation after materialization fails", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
let fail = true
const plugin = define({
id: "retry",
effect: (ctx) =>
ctx.agent
.transform(() => {
if (fail) throw new Error("materialization failed")
})
.pipe(Effect.asVoid),
})
expect(Exit.isFailure(yield* plugins.activate([plugin]).pipe(Effect.exit))).toBe(true)
fail = false
yield* plugins.activate([plugin])
expect(yield* plugins.list()).toEqual([{ id: PluginV2.ID.make("retry") }])
}),
)
it.effect("closes the previous generation in reverse order", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const closed: string[] = []
yield* plugins.activate(
["first", "second"].map((id) => ({
id: PluginV2.ID.make(id),
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
})),
)
yield* plugins.activate([])
expect(closed).toEqual(["second", "first"])
}),
)
it.effect("isolates plugins from ambient services", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
let visible = true
const plugin = define({
id: "isolated",
effect: () =>
Effect.serviceOption(Secret).pipe(
Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
Effect.asVoid,
),
})
yield* plugins
.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }])
.pipe(Effect.provideService(Secret, "secret"))
expect(visible).toBe(false)
}),
)
it.effect("registers location tools through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
@ -114,12 +170,12 @@ describe("PluginV2", () => {
.pipe(Effect.orDie),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
"plugin_tool",
)
yield* plugins.remove(PluginV2.ID.make(plugin.id))
yield* plugins.activate([])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
"plugin_tool",
)
@ -149,7 +205,7 @@ describe("PluginV2", () => {
}),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
"plain",
@ -201,7 +257,7 @@ describe("PluginV2", () => {
}),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }])
const materialized = yield* registry.materialize({ model: testModel })
const settlement = yield* materialized.settle({

View file

@ -0,0 +1,7 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default define({
id: "failing-plugin",
effect: () => Effect.die("plugin failed"),
})

View file

@ -0,0 +1,29 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default define({
id: "variant-source",
effect: (ctx) =>
ctx.catalog
.transform((catalog) => {
catalog.provider.update("configured", (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
})
catalog.model.update("configured", "glm-5.2", (model) => {
model.api = {
id: "glm-5.2",
type: "aisdk",
package: "@ai-sdk/openai-compatible",
}
model.variants = [
{
id: "high",
settings: {},
headers: { custom: "true" },
body: {},
},
]
})
})
.pipe(Effect.asVoid),
})

View file

@ -59,8 +59,6 @@ export function host(overrides: Overrides = {}): PluginContext {
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
add: () => Effect.die("unused plugin.add"),
remove: () => Effect.die("unused plugin.remove"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),

View file

@ -19,7 +19,9 @@ const addPlugin = Effect.fn(function* () {
describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))),
Effect.sync(() =>
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.kilo")),
),
)
it.effect("applies legacy referer headers only to kilo", () =>

View file

@ -21,7 +21,9 @@ const addPlugin = Effect.fn(function* () {
describe("LLMGatewayPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))),
Effect.sync(() =>
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.llmgateway")),
),
)
it.effect("applies legacy referer headers only to enabled llmgateway", () =>

View file

@ -19,7 +19,9 @@ const addPlugin = Effect.fn(function* () {
describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))),
Effect.sync(() =>
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.nvidia")),
),
)
it.effect("applies NVIDIA tracking headers only to nvidia", () =>

View file

@ -22,7 +22,9 @@ const addPlugin = Effect.fn(function* () {
describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))),
Effect.sync(() =>
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.openrouter")),
),
)
it.effect("applies legacy referer headers only to openrouter", () =>

View file

@ -43,9 +43,11 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.snowflake-cortex"))
const ids = ProviderPlugins.map((p) => p.id)
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
ids.indexOf("opencode.provider.openai-compatible"),
)
}),
)

View file

@ -24,7 +24,9 @@ function required<T>(value: T | undefined): T {
describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))),
Effect.sync(() =>
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.zenmux")),
),
)
it.effect("applies the exact legacy Zenmux headers", () =>