opencode/packages/core/test/plugin.test.ts
Kit Langton f40ecefdef
feat(core): disable plugins after transform failures (#47083)
Disable failed registration groups, rebuild healthy state, and report plugin failures with safe diagnostic references. Keep cleanup outside activation locks and preserve disabled revisions across unrelated reloads. Cover deferred hook and RPC cleanup with real-service regression tests.
2026-09-03 16:38:11 +00:00

552 lines
20 KiB
TypeScript

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<void>()
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<void>()
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<Credential.Value | undefined> = []
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])
}),
)