import { expect } from "bun:test" import path from "path" import { Clock, Deferred, Effect } from "effect" import { TestClock } from "effect/testing" import { Command } from "@opencode-ai/core/command" import { Bus } from "@opencode-ai/core/bus" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { Plugin } from "@opencode-ai/core/plugin" import { PluginModule } from "@opencode-ai/core/plugin/module" import { fromPromise } from "@opencode-ai/plugin/promise/adapter" import { Session } from "@opencode-ai/schema/session" import { testEffect } from "./lib/effect" import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) for (const scenario of [ { name: "starts only the appended plugin", before: ["a", "b"], after: ["a", "b", "c"], expected: ["start:c:1"], }, { name: "restarts the suffix after an insertion", before: ["a", "b", "c"], after: ["a", "x", "b", "c"], expected: ["stop:c:1", "stop:b:1", "start:x:1", "start:b:1", "start:c:1"], }, { name: "restarts the suffix after a revision changes", before: ["a", "b", "c"], after: ["a", "b", "c"], updated: "b", expected: ["stop:c:1", "stop:b:1", "start:b:2", "start:c:1"], }, { name: "stops only the removed trailing plugin", before: ["a", "b", "c"], after: ["a", "b"], expected: ["stop:c:1"], }, ]) { it.effect(scenario.name, () => Effect.gen(function* () { const plugins = yield* Plugin.Service const events: string[] = [] const plugin = (id: string, revision = "1"): Plugin.Generation => ({ id, revision, effect: () => Effect.gen(function* () { events.push(`start:${id}:${revision}`) yield* Effect.addFinalizer(() => Effect.sync(() => events.push(`stop:${id}:${revision}`))) }), }) yield* plugins.activate(scenario.before.map((id) => plugin(id))) events.length = 0 yield* plugins.activate(scenario.after.map((id) => plugin(id, id === scenario.updated ? "2" : "1"))) expect(events).toEqual(scenario.expected) expect((yield* plugins.list()).map((plugin) => plugin.id)).toEqual(scenario.after.map((id) => Plugin.ID.make(id))) }), ) } it.effect("updates inventory metadata without restarting an unchanged generation", () => Effect.gen(function* () { const plugins = yield* Plugin.Service let loads = 0 const plugin = { id: "metadata", revision: "1", source: { type: "package" as const, target: "fixture" }, effect: () => Effect.sync(() => loads++), } yield* plugins.activate([plugin]) yield* plugins.activate([{ ...plugin, source: { ...plugin.source, outdated: true } }]) expect(loads).toBe(1) expect((yield* plugins.list())[0]?.source).toEqual({ type: "package", target: "fixture", outdated: true }) }), ) it.live("loads a local plugin with its configured options", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service yield* plugins.awaitActivation const definition = yield* PluginModule.load({ type: "add", target: path.join(import.meta.dir, "plugin/fixtures/greeting.ts"), options: { description: "Configured greeting" }, }) if ("pending" in definition) return yield* Effect.die("Local plugin was not loaded") yield* plugins.activate([definition]) expect(yield* commands.get("greet")).toMatchObject({ description: "Configured greeting" }) }), ) it.effect("unloading a plugin removes its commands and runs cleanup", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service let cleaned = false yield* plugins.activate([ { id: "greeting", revision: "1", effect: (ctx) => Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => { cleaned = true }), ) yield* ctx.command.transform((editor) => editor.add({ name: "greet", execute: () => Effect.void })) }), }, ]) expect(yield* commands.get("greet")).toBeDefined() expect(cleaned).toBe(false) yield* plugins.activate([]) expect(yield* commands.get("greet")).toBeUndefined() expect(cleaned).toBe(true) }), ) it.effect("reports a failed plugin without blocking a healthy plugin", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service yield* plugins.activate([ { id: "broken", revision: "1", effect: () => Effect.die(new Error("Setup failed")) }, { id: "greeting", revision: "1", effect: (ctx) => ctx.command .transform((editor) => editor.add({ name: "greet", execute: () => Effect.void })) .pipe(Effect.asVoid), }, ]) expect((yield* plugins.list()).find((plugin) => plugin.id === "broken")?.state).toMatchObject({ status: "failed", error: expect.stringContaining("Setup failed"), }) expect(yield* commands.get("greet")).toBeDefined() }), ) it.effect("disables a plugin whose transform fails after setup without publishing its partial edits", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service const integrations = yield* Integration.Service let cleaned = false yield* plugins.activate([ { id: "before", revision: "1", effect: (ctx) => ctx.command .transform((editor) => editor.add({ name: "shared", description: "original", execute: () => Effect.void })) .pipe(Effect.asVoid), }, { id: "broken", revision: "1", effect: (ctx) => Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => void (cleaned = true))) yield* ctx.integration.transform((editor) => editor.update("broken", (entry) => (entry.name = "Broken"))) yield* ctx.command.transform((editor) => { editor.add({ name: "shared", description: "partial", execute: () => Effect.void }) throw new Error("replay failed") }) }), }, { id: "after", revision: "1", effect: (ctx) => ctx.command .transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void })) .pipe(Effect.asVoid), }, ]) yield* plugins.awaitActivation expect(cleaned).toBe(true) expect((yield* plugins.list()).find((plugin) => plugin.id === "broken")?.state).toMatchObject({ status: "failed", error: expect.stringContaining("command.transform failed"), ref: expect.stringMatching(/^err_/), }) expect(yield* commands.get("shared")).toMatchObject({ description: "original" }) expect(yield* commands.get("healthy")).toBeDefined() expect(yield* integrations.get(Integration.ID.make("broken"))).toBeUndefined() }), ) it.effect("keeps the suffix after a failed plugin alive across identical activations", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const setups = { good1: 0, broken: 0, good2: 0 } const good = (id: "good1" | "good2"): Plugin.Generation => ({ id, revision: "1", effect: () => Effect.sync(() => void setups[id]++), }) const broken = (revision: string): Plugin.Generation => ({ id: "broken", revision, effect: () => Effect.suspend(() => { setups.broken++ return Effect.die(new Error("Setup failed")) }), }) const failed = () => plugins.list().pipe(Effect.map((inventory) => inventory.find((plugin) => plugin.id === "broken")?.state)) yield* plugins.activate([good("good1"), broken("1"), good("good2")]) expect(setups).toEqual({ good1: 1, broken: 1, good2: 1 }) expect(yield* failed()).toMatchObject({ status: "failed", error: expect.stringContaining("Setup failed") }) // Identical definitions: nothing restarts and the failed revision is not retried. yield* plugins.activate([good("good1"), broken("1"), good("good2")]) expect(setups).toEqual({ good1: 1, broken: 1, good2: 1 }) expect(yield* failed()).toMatchObject({ status: "failed", error: expect.stringContaining("Setup failed") }) expect((yield* plugins.list()).map((plugin) => `${plugin.id}:${plugin.state.status}`)).toEqual([ "good1:active", "broken:failed", "good2:active", ]) // A new revision of the failed plugin is retried once, restarting only the suffix behind it. yield* plugins.activate([good("good1"), broken("2"), good("good2")]) expect(setups).toEqual({ good1: 1, broken: 2, good2: 2 }) expect(yield* failed()).toMatchObject({ status: "failed" }) }), ) it.effect("attributes replay failure to the broken plugin rather than a later plugin reading the registry", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service yield* plugins.activate([ { id: "broken-plugin", revision: "1", effect: (ctx) => ctx.command .transform(() => { throw new Error("plugin failed") }) .pipe(Effect.asVoid), }, { id: "reader", revision: "1", effect: (ctx) => Effect.gen(function* () { yield* ctx.command.list().pipe(Effect.orDie) yield* ctx.command.transform((editor) => editor.add({ name: "reader", execute: () => Effect.void })) }), }, ]) yield* plugins.awaitActivation expect((yield* plugins.list()).map((entry) => `${entry.id}:${entry.state.status}`)).toEqual([ "broken-plugin:failed", "reader:active", ]) expect(yield* commands.get("reader")).toBeDefined() }), ) it.effect("disables plugins after runtime reload failures without retrying an unchanged generation", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service const bus = yield* Bus.Service const reported: string[] = [] yield* Effect.acquireRelease( bus.listen((event) => event.type === Plugin.Event.Updated.type ? plugins.list().pipe( Effect.tap((items) => Effect.sync(() => void reported.push(items[0]?.state.status ?? "empty"))), Effect.asVoid, ) : Effect.void, ), (unsubscribe) => unsubscribe, ) let fail = false let loads = 0 let reload = () => Effect.void const generation = (revision: string): Plugin.Generation => ({ id: "runtime", revision, effect: (ctx) => Effect.gen(function* () { loads++ reload = ctx.command.reload yield* ctx.command.transform((editor) => { editor.add({ name: "runtime", execute: () => Effect.void }) if (fail) throw new Error("private failure detail") }) }), }) const discovery = { source: { type: "local" as const, path: "/missing" }, state: { status: "failed" as const, error: "Import failed" }, features: { server: true }, } satisfies Plugin.Info yield* plugins.activate([generation("1")], [discovery]) expect(yield* commands.get("runtime")).toBeDefined() fail = true yield* reload() yield* plugins.awaitActivation const inventory = yield* plugins.list() expect(inventory[0]?.state).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) }) expect(JSON.stringify(inventory[0]?.state)).not.toContain("private failure detail") expect(reported.at(-1)).toBe("failed") expect(inventory[1]).toEqual(discovery) expect(yield* commands.get("runtime")).toBeUndefined() fail = false yield* plugins.activate([generation("1")], [discovery]) expect(loads).toBe(1) expect(yield* commands.get("runtime")).toBeUndefined() yield* plugins.activate([generation("2")], [discovery]) expect(loads).toBe(2) expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" }) expect(yield* commands.get("runtime")).toBeDefined() }), ) it.effect("disables plugins after replay failures discovered during setup without restoring the old generation", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service const cleaned = yield* Deferred.make() const loads: string[] = [] const generation = (revision: string): Plugin.Generation => ({ id: "replacement", revision, effect: (ctx) => Effect.gen(function* () { loads.push(revision) if (revision === "2") yield* Effect.addFinalizer(() => plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))), ) yield* ctx.command.transform((editor) => { editor.add({ name: "replacement", execute: () => Effect.void }) if (revision === "2") throw new Error("replay failure") }) if (revision === "2") { yield* ctx.command.list().pipe(Effect.orDie) yield* Effect.die("subsequent setup failure") } }), }) yield* plugins.activate([generation("1")]) yield* plugins.activate([generation("2")]) yield* plugins.awaitActivation yield* Deferred.await(cleaned) expect(loads).toEqual(["1", "2"]) expect((yield* plugins.list())[0]?.state).toMatchObject({ status: "failed", error: expect.stringContaining("command.transform"), }) expect(yield* commands.get("replacement")).toBeUndefined() }), ) it.effect("does not let asynchronous plugin cleanup block recovered registry readiness", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service const cleaned = yield* Deferred.make() yield* plugins.activate([ { id: "async-cleanup", revision: "1", effect: (ctx) => Effect.gen(function* () { yield* ctx.command.transform(() => { throw new Error("failed") }) yield* Effect.addFinalizer(() => plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))), ) }), }, { id: "healthy", revision: "1", effect: (ctx) => ctx.command .transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void })) .pipe(Effect.asVoid), }, ]) yield* plugins.awaitActivation yield* Deferred.await(cleaned) expect(yield* commands.get("healthy")).toBeDefined() expect((yield* plugins.list())[0]?.state.status).toBe("failed") }), ) it.live("retains Promise plugin groups for later registrations and ignores a disabled group's attempts", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service let register = async () => {} const definition = fromPromise({ id: "promise-plugin", setup(ctx) { register = async () => { await ctx.command.transform((editor) => { editor.add({ name: "late", execute: async () => {} }) throw new Error("late Promise failure") }) } }, }) yield* plugins.activate([{ ...definition, revision: "1" }]) yield* Effect.promise(register) yield* plugins.awaitActivation expect((yield* plugins.list())[0]?.state).toMatchObject({ status: "failed", error: expect.stringContaining("command.transform"), }) expect(yield* commands.get("late")).toBeUndefined() yield* Effect.promise(register) expect(yield* commands.get("late")).toBeUndefined() }), ) it.effect("reloading a plugin replaces its command implementation", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service const output: string[] = [] const load = (revision: string, text: string) => plugins.activate([ { id: "greeting", revision, effect: (ctx) => ctx.command .transform((editor) => editor.add({ name: "greet", execute: () => Effect.sync(() => { output.push(text) }), }), ) .pipe(Effect.asVoid), }, ]) const request = { name: "greet", invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const }, } yield* load("1", "before") yield* commands.execute(request) expect(output).toEqual(["before"]) yield* load("2", "after") yield* commands.execute(request) expect(output).toEqual(["before", "after"]) }), ) it.effect("refreshes expired OAuth credentials through the context during activation", () => Effect.gen(function* () { const plugins = yield* Plugin.Service const credentials = yield* Credential.Service const integrations = yield* Integration.Service const clock = yield* Clock.Clock const integrationID = Integration.ID.make("refresh-fixture") const methodID = Integration.MethodID.make("oauth") const expired = Credential.OAuth.make({ type: "oauth", methodID, access: "expired-access", refresh: "fixture-refresh", expires: (yield* Clock.currentTimeMillis) + 60_000, }) const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired }) yield* TestClock.adjust("2 minutes") const refreshed = Credential.OAuth.make({ ...expired, access: "fresh-access", refresh: "rotated-refresh", expires: (yield* Clock.currentTimeMillis) + 3_600_000, }) const refreshes: Credential.OAuth[] = [] const resolved: Array = [] yield* plugins.activate([ { id: "oauth-refresh", revision: "1", effect: (ctx) => Effect.gen(function* () { yield* ctx.integration.transform((editor) => editor.method.update({ integrationID, method: { id: methodID, type: "oauth", label: "Fixture" }, authorize: () => Effect.die("unexpected authorization"), refresh: (value) => Effect.sync(() => { refreshes.push(value) return refreshed }), }), ) // The method registered above must be readable before the activation batch ends. const connection = yield* ctx.integration.connection.active(integrationID) if (!connection) return yield* Effect.die("fixture connection not found") resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie)) }).pipe( // Plugin activation isolates ambient services, including the test clock. Effect.provideService(Clock.Clock, clock), ), }, ]) expect(yield* plugins.list()).toMatchObject([{ id: "oauth-refresh", state: { status: "active" } }]) expect(resolved).toEqual([refreshed]) expect((yield* credentials.get(stored.id))?.value).toEqual(refreshed) expect(yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: stored.label })).toEqual( refreshed, ) expect(refreshes).toEqual([expired]) }), )