From 9e4c29acd93b2fa3881871b1133092fa8523948f Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 13 May 2026 16:32:44 +0200 Subject: [PATCH] handle permanent file plugin errors --- packages/opencode/src/plugin/loader.ts | 41 +++++++++++++------ .../test/cli/tui/plugin-loader.test.ts | 23 +++++++++++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index f8da9d6a95..c712c63923 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -56,6 +56,19 @@ export namespace PluginLoader { ) => void } + type AttemptResult = { + value?: R + retry: boolean + } + + function isRetryableLoadError(error: unknown) { + if (!error || typeof error !== "object") return false + const name = "name" in error && typeof error.name === "string" ? error.name : "" + if (name === "ResolveMessage") return true + const message = "message" in error && typeof error.message === "string" ? error.message : "" + return /Cannot find (?:package|module)|Could not resolve/.test(message) + } + // Normalize a config item into the loader's internal representation. function plan(item: ConfigPlugin.Spec): Plan { const spec = ConfigPlugin.pluginSpecifier(item) @@ -136,11 +149,11 @@ export namespace PluginLoader { finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, report: Report | undefined, - ): Promise { + ): Promise> { const plan = candidate.plan // Deprecated plugin packages are silently ignored because they are now built in. - if (plan.deprecated) return + if (plan.deprecated) return { retry: false } report?.start?.(candidate, retry) @@ -151,25 +164,25 @@ export namespace PluginLoader { // for example to load theme files from a tui plugin package that has no code entrypoint. if (missing) { const value = await missing(resolved.value, candidate.origin, retry) - if (value !== undefined) return value + if (value !== undefined) return { value, retry: false } } report?.missing?.(candidate, retry, resolved.value.message, resolved.value) - return + return { retry: false } } report?.error?.(candidate, retry, resolved.stage, resolved.error) - return + return { retry: false } } const loaded = await load(resolved.value) if (!loaded.ok) { report?.error?.(candidate, retry, "load", loaded.error, resolved.value) - return + return { retry: isRetryableLoadError(loaded.error) } } // The default behavior is to return the successfully loaded plugin as-is, but callers can // provide a finisher to adapt the result into a more specific runtime shape. - if (!finish) return loaded.value as R - return finish(loaded.value, candidate.origin, retry) + if (!finish) return { value: loaded.value as R, retry: false } + return { value: await finish(loaded.value, candidate.origin, retry), retry: false } } type Input = { @@ -188,7 +201,7 @@ export namespace PluginLoader { // step happening elsewhere before their entrypoint becomes loadable. export async function loadExternal(input: Input): Promise { const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) - const list: Array> = [] + const list: Array>> = [] for (const candidate of candidates) { list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report)) } @@ -196,10 +209,12 @@ export namespace PluginLoader { if (input.wait) { let deps: Promise | undefined for (let i = 0; i < candidates.length; i++) { - if (out[i] !== undefined) continue + const previous = out[i] + if (previous?.value !== undefined) continue + if (previous?.retry !== true) continue - // Only local file plugins are retried. npm plugins already attempted installation during - // the first pass, while file plugins may need the caller's dependency preparation to finish. + // Only local file plugins with module-resolution failures are retried. Syntax/build errors + // cannot be fixed by dependency installation and should not block TUI startup. const candidate = candidates[i] if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue deps ??= input.wait() @@ -210,7 +225,7 @@ export namespace PluginLoader { // Drop skipped/failed entries while preserving the successful result order. const ready: R[] = [] - for (const item of out) if (item !== undefined) ready.push(item) + for (const item of out) if (item.value !== undefined) ready.push(item.value) return ready } } diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index ce62550e12..e62d725eca 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -10,12 +10,35 @@ import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runti import { Global } from "@opencode-ai/core/global" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Filesystem } from "@/util/filesystem" +import { PluginLoader } from "../../../src/plugin/loader" const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") type Row = Record +test("does not retry permanent file plugin load errors", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "binary-plugin") + await Bun.write(file, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01])) + return { spec: pathToFileURL(file).href } + }, + }) + + let waited = false + const plugins = await PluginLoader.loadExternal({ + items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + kind: "tui", + wait: async () => { + waited = true + }, + }) + + expect(plugins).toEqual([]) + expect(waited).toBe(false) +}) + type Data = { local: Row global: Row