| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402 |
- import { describe, expect } from "bun:test"
- import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
- import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
- import { Config as ConfigSchema } from "@opencode-ai/schema/config"
- import { Agent } from "@opencode-ai/core/agent"
- import { Bus } from "@opencode-ai/core/bus"
- import { Plugin } from "@opencode-ai/core/plugin"
- import { PluginHost } from "@opencode-ai/core/plugin/host"
- import { Session } from "@opencode-ai/core/session"
- import { SessionMessage } from "@opencode-ai/core/session/message"
- import { Tool } from "@opencode-ai/core/tool"
- import { testEffect } from "./lib/effect"
- import { PluginTestLayer } from "./plugin/fixture"
- const it = testEffect(PluginTestLayer)
- class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
- const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
- describe("Plugin", () => {
- it.live("exposes public events through the plugin context", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const bus = yield* Bus.Service
- const host = yield* PluginHost.make(plugins)
- const received = yield* host.event.subscribe().pipe(
- Stream.filter((event) => event.type === "config.updated"),
- Stream.runHead,
- Effect.forkScoped({ startImmediately: true }),
- )
- yield* Effect.sleep("10 millis")
- yield* bus.publish(ConfigSchema.Event.Updated, {})
- expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
- }),
- )
- it.effect("replaces plugins by ID and version", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const agents = yield* Agent.Service
- const bus = yield* Bus.Service
- let description = "first"
- let updates = 0
- const unsubscribe = yield* bus.listen((event) =>
- Effect.sync(() => {
- if (event.type === Plugin.Event.Updated.type) updates++
- }),
- )
- const managed = () =>
- EffectPlugin.define({
- id: "managed",
- effect: (ctx) =>
- ctx.agent
- .transform((agents) =>
- agents.update("configured", (agent) => {
- agent.description = description
- }),
- )
- .pipe(Effect.asVoid),
- })
- yield* plugins.activate([versioned(managed(), "1")])
- expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
- description = "second"
- yield* plugins.activate([versioned(managed(), "2")])
- expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
- description = "third"
- yield* plugins.activate([versioned(managed(), "2")])
- expect(updates).toBe(2)
- expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
- yield* plugins.activate([])
- expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
- expect(updates).toBe(3)
- yield* unsubscribe
- }),
- )
- it.effect("rejects duplicate IDs before replacing active plugins", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const active = Plugin.ID.make("active")
- const duplicate = "duplicate"
- yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
- const result = yield* plugins
- .activate([
- { id: duplicate, version: "1", effect: () => Effect.void },
- { id: duplicate, version: "1", effect: () => Effect.void },
- ])
- .pipe(Effect.exit)
- expect(Exit.isFailure(result)).toBe(true)
- expect(yield* plugins.list()).toEqual([{ id: active }])
- }),
- )
- it.effect("skips failed plugins and loads the rest", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const agents = yield* Agent.Service
- let fail = true
- const good = EffectPlugin.define({
- id: "good",
- effect: (ctx) =>
- ctx.agent
- .transform((agents) =>
- agents.update("configured", (agent) => {
- agent.description = "loaded"
- }),
- )
- .pipe(Effect.asVoid),
- })
- const bad = EffectPlugin.define({
- id: "bad",
- effect: () => {
- if (fail) return Effect.die(new Error("materialization failed"))
- return Effect.void
- },
- })
- yield* plugins.activate([versioned(good), versioned(bad)])
- expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
- expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
- fail = false
- yield* plugins.activate([versioned(good), versioned(bad, "2")])
- expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
- }),
- )
- it.effect("restores the previous plugin when its replacement fails", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const agents = yield* Agent.Service
- const previous = EffectPlugin.define({
- id: "managed",
- effect: (ctx) =>
- ctx.agent
- .transform((agents) =>
- agents.update("configured", (agent) => {
- agent.description = "previous"
- }),
- )
- .pipe(Effect.asVoid),
- })
- const replacement = EffectPlugin.define({
- id: "managed",
- effect: (ctx) =>
- Effect.gen(function* () {
- yield* ctx.agent.transform((agents) =>
- agents.update("configured", (agent) => {
- agent.description = "replacement"
- }),
- )
- return yield* Effect.die(new Error("replacement failed"))
- }),
- })
- yield* plugins.activate([versioned(previous)])
- yield* plugins.activate([versioned(replacement, "2")])
- expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
- expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
- }),
- )
- it.effect("deactivates a plugin when replacement and restoration fail", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const agents = yield* Agent.Service
- let loads = 0
- const previous = EffectPlugin.define({
- id: "managed",
- effect: (ctx) => {
- loads++
- if (loads > 1) return Effect.die(new Error("restoration failed"))
- return ctx.agent
- .transform((agents) =>
- agents.update("configured", (agent) => {
- agent.description = "previous"
- }),
- )
- .pipe(Effect.asVoid)
- },
- })
- const replacement = EffectPlugin.define({
- id: "managed",
- effect: () => Effect.die(new Error("replacement failed")),
- })
- yield* plugins.activate([versioned(previous)])
- yield* plugins.activate([versioned(replacement, "2")])
- expect(yield* plugins.list()).toEqual([])
- expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
- }),
- )
- it.effect("closes the previous generation in reverse order", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const closed: string[] = []
- yield* plugins.activate(
- ["first", "second"].map((id) => ({
- id,
- version: "1",
- 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* Plugin.Service
- let visible = true
- const plugin = EffectPlugin.define({
- id: "isolated",
- effect: () =>
- Effect.serviceOption(Secret).pipe(
- Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
- Effect.asVoid,
- ),
- })
- yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
- expect(visible).toBe(false)
- }),
- )
- it.effect("registers location tools through the plugin context", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const registry = yield* Tool.Service
- const plugin = EffectPlugin.define({
- id: "tool-plugin",
- effect: (ctx) =>
- ctx.tool
- .transform((draft) =>
- draft.add(
- ({
- name: "plugin_tool",
- options: { codemode: false },
- description: "Plugin tool",
- input: Schema.Struct({}),
- output: Schema.Struct({ ok: Schema.Boolean }),
- execute: () => Effect.succeed({ output: { ok: true } }),
- }),
- ),
- )
- .pipe(Effect.orDie),
- })
- yield* plugins.activate([versioned(plugin)])
- expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
- yield* plugins.activate([])
- expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
- }),
- )
- it.effect("namespaces tool names and routes codemode registrations through execute", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const registry = yield* Tool.Service
- const tool = (name: string, description: string, options?: Tool.Options) =>
- ({
- name,
- options,
- description,
- input: Schema.Struct({}),
- output: Schema.Struct({ ok: Schema.Boolean }),
- execute: () => Effect.succeed({ output: { ok: true } }),
- })
- const plugin = EffectPlugin.define({
- id: "grouped-tools",
- effect: (ctx) =>
- ctx.tool
- .transform((draft) => {
- draft.add(tool("plain", "Plain", { codemode: false }))
- draft.add(tool("look/up", "Lookup", { namespace: "context7", codemode: false }))
- draft.add(tool("search", "Search", { namespace: "context7" }))
- })
- .pipe(Effect.orDie),
- })
- yield* plugins.activate([versioned(plugin)])
- expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
- "context7_look_up",
- "plain",
- "execute",
- ])
- }),
- )
- it.effect("fires before/after tool hooks with mutable events around execution", () =>
- Effect.gen(function* () {
- const plugins = yield* Plugin.Service
- const registry = yield* Tool.Service
- const executed: unknown[] = []
- const seen: {
- before?: unknown
- after?: { input: unknown; status: string; content: unknown; metadata: unknown }
- } = {}
- const plugin = EffectPlugin.define({
- id: "tool-hooks",
- effect: (ctx) =>
- Effect.gen(function* () {
- yield* ctx.tool
- .transform((draft) =>
- draft.add(
- ({
- name: "echo",
- options: { codemode: false },
- description: "Echo",
- input: Schema.Struct({ text: Schema.String }),
- output: Schema.Struct({ text: Schema.String }),
- execute: ({ text }) =>
- Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
- }),
- ),
- )
- .pipe(Effect.orDie)
- yield* ctx.tool
- .hook("execute.before", (event) =>
- Effect.sync(() => {
- seen.before = event.input
- event.input = { text: "before-mutated" }
- }),
- )
- .pipe(Effect.asVoid)
- yield* ctx.tool
- .hook("execute.after", (event) =>
- Effect.sync(() => {
- seen.after = {
- input: event.input,
- status: event.status,
- content: event.status === "completed" ? event.result.content : undefined,
- metadata: event.status === "completed" ? event.result.metadata : event.error.metadata,
- }
- if (event.status !== "completed") return
- event.result = {
- ...event.result,
- content: [{ type: "text", text: "after-mutated" }],
- metadata: { rewritten: true },
- }
- }),
- )
- .pipe(Effect.asVoid)
- yield* ctx.tool
- .hook("execute.after", (event) =>
- Effect.sync(() => {
- if (event.status === "completed" && Array.isArray(event.result.content)) event.result.content.splice(0)
- }),
- )
- .pipe(Effect.asVoid)
- }),
- })
- yield* plugins.activate([versioned(plugin)])
- const toolSet = yield* registry.snapshot()
- const execution = yield* toolSet.execute({
- sessionID: Session.ID.make("ses_hooks"),
- agent: Agent.ID.make("build"),
- messageID: SessionMessage.ID.make("msg_hooks"),
- call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
- })
- expect(seen.before).toEqual({ text: "original" })
- expect(executed).toEqual([{ text: "before-mutated" }])
- expect(seen.after).toEqual({
- input: { text: "before-mutated" },
- status: "completed",
- content: [{ type: "text", text: '{"text":"before-mutated"}' }],
- metadata: undefined,
- })
- expect(execution).toMatchObject({
- content: [{ type: "text", text: '{"text":"before-mutated"}' }],
- metadata: { rewritten: true },
- })
- }),
- )
- })
|