diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 529fa5d9cc..7c496bf3b0 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -61,12 +61,15 @@ export namespace PluginLoader { 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 + function errorMessage(error: unknown) { + if (!error || typeof error !== "object") return "" const message = "message" in error && typeof error.message === "string" ? error.message : "" - return /Cannot find (?:package|module)|Could not resolve/.test(message) + return message + } + + function isRetryableResolveError(stage: "install" | "entry" | "compatibility", error: unknown) { + if (stage !== "install") return false + return errorMessage(error).includes("missing package.json or index file") } // Normalize a config item into the loader's internal representation. @@ -171,20 +174,20 @@ export namespace PluginLoader { return { retry: false } } report?.error?.(candidate, retry, resolved.stage, resolved.error) - return { retry: filePlugin } + return { retry: filePlugin && isRetryableResolveError(resolved.stage, resolved.error) } } const loaded = await load(resolved.value) if (!loaded.ok) { report?.error?.(candidate, retry, "load", loaded.error, resolved.value) - return { retry: isRetryableLoadError(loaded.error) } + return { retry: false } } // 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 { value: loaded.value as R, retry: false } const value = await finish(loaded.value, candidate.origin, retry) - return { value, retry: filePlugin && value === undefined } + return { value, retry: false } } type Input = { @@ -198,9 +201,9 @@ export namespace PluginLoader { // Resolve and load all configured plugins in parallel. // - // If `wait` is provided, file-based plugins that initially failed are retried once after the - // caller finishes preparing dependencies. This supports local plugins that depend on an install - // step happening elsewhere before their entrypoint becomes loadable. + // If `wait` is provided, file-based plugins with retryable pre-import setup failures are retried + // once after the caller finishes preparing dependencies. Once dynamic import runs, failures are + // treated as permanent for this process because Bun caches failed module resolution. export async function loadExternal(input: Input): Promise { const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) const list: Array>> = [] @@ -215,8 +218,8 @@ export namespace PluginLoader { if (previous?.value !== undefined) continue if (previous?.retry !== true) continue - // 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. + // Only pre-import file plugin setup failures are retried. Bun caches failed dynamic imports, + // so dependency waiting cannot fix load/build/runtime/shape failures in this process. const candidate = candidates[i] if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue deps ??= input.wait() diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index e62d725eca..07bf22a75a 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -26,6 +26,87 @@ test("does not retry permanent file plugin load errors", async () => { }, }) + let waited = false + const calls: Array<["start" | "error", boolean, string?]> = [] + 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 + }, + report: { + start(_candidate, retry) { + calls.push(["start", retry]) + }, + error(_candidate, retry, stage) { + calls.push(["error", retry, stage]) + }, + }, + }) + + expect(plugins).toEqual([]) + expect(waited).toBe(false) + expect(calls).toEqual([ + ["start", false], + ["error", false, "load"], + ]) +}) + +test("does not retry file plugin load errors caused by missing modules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "missing-dependency-plugin.ts") + const dep = path.join(dir, "dep.ts") + await Bun.write( + file, + `import value from "./dep" +export default { id: "demo.retry.load", tui: async () => {}, value } +`, + ) + return { spec: pathToFileURL(file).href, dep } + }, + }) + + let waited = false + const calls: Array<["start" | "error", boolean, string?]> = [] + 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 + await Bun.write(tmp.extra.dep, `export default "ready"\n`) + }, + finish: async (loaded, _origin, retry) => ({ + retry, + value: (loaded.mod.default as { value: string }).value, + }), + report: { + start(_candidate, retry) { + calls.push(["start", retry]) + }, + error(_candidate, retry, stage) { + calls.push(["error", retry, stage]) + }, + }, + }) + + expect(waited).toBe(false) + expect(calls).toEqual([ + ["start", false], + ["error", false, "load"], + ]) + expect(plugins).toEqual([]) +}) + +test("does not retry top-level plugin errors that look like resolver messages", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "throwing-plugin.ts") + await Bun.write(file, `throw new Error("Cannot find package intentional")\n`) + 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") }], @@ -582,6 +663,71 @@ test("continues loading when a plugin is missing config metadata", async () => { } }) +test("does not wait on permanent tui plugin startup failures", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const binary = path.join(dir, "binary-plugin") + const invalidShape = path.join(dir, "invalid-shape-plugin.ts") + const missingID = path.join(dir, "missing-id-plugin.ts") + const good = path.join(dir, "good-plugin.ts") + const marker = path.join(dir, "good-called.txt") + + await Bun.write(binary, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01])) + await Bun.write(invalidShape, `export default { id: "demo.invalid.shape" }\n`) + await Bun.write(missingID, `export default { tui: async () => {} }\n`) + await Bun.write( + good, + `export default { + id: "demo.good.after-bad", + tui: async () => { + await Bun.write(${JSON.stringify(marker)}, "called") + }, +} +`, + ) + + return { + binarySpec: pathToFileURL(binary).href, + invalidShapeSpec: pathToFileURL(invalidShape).href, + missingIDSpec: pathToFileURL(missingID).href, + goodSpec: pathToFileURL(good).href, + marker, + } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi(), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.binarySpec, tmp.extra.invalidShapeSpec, tmp.extra.missingIDSpec, tmp.extra.goodSpec], + plugin_origins: [ + { spec: tmp.extra.binarySpec, scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: tmp.extra.invalidShapeSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: tmp.extra.missingIDSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, + { spec: tmp.extra.goodSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, + ], + }), + }) + + expect(wait).toHaveBeenCalledTimes(0) + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") + expect(TuiPluginRuntime.list().find((item) => item.id === "demo.good.after-bad")?.active).toBe(true) + expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.binarySpec)).toBe(false) + expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.invalidShapeSpec)).toBe(false) + expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.missingIDSpec)).toBe(false) + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + test("initializes external tui plugins in config order", async () => { const globalJson = path.join(Global.Path.config, "tui.json") const globalJsonc = path.join(Global.Path.config, "tui.jsonc") diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index c283488632..ad03d229f2 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1180,7 +1180,52 @@ export default { ), ) - it.live("retries file plugins when finish returns undefined", () => + it.live("does not retry permanent file plugin entry errors", () => + withTmp( + async (dir) => { + const mod = path.join(dir, "bad-entry") + const spec = pathToFileURL(mod).href + await fs.mkdir(mod, { recursive: true }) + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify({ exports: { "./tui": "../outside.js" } }, null, 2), + ) + return { spec } + }, + (tmp) => + Effect.gen(function* () { + let wait = 0 + const errors: Array<[string, boolean]> = [] + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: [ + { + spec: tmp.extra.spec, + scope: "local" as const, + source: tmp.path, + }, + ], + kind: "tui", + wait: async () => { + wait += 1 + }, + report: { + error(_candidate, retry, stage) { + errors.push([stage, retry]) + }, + }, + }), + ) + + expect(loaded).toEqual([]) + expect(wait).toBe(0) + expect(errors).toEqual([["entry", false]]) + }), + ), + ) + + it.live("does not retry file plugins when finish returns undefined", () => withTmp( async (dir) => { const file = path.join(dir, "plugin.ts") @@ -1206,20 +1251,15 @@ export default { wait: async () => { wait += 1 }, - finish: async (load, _item, retry) => { + finish: async () => { count += 1 - if (!retry) return - return { - retry, - spec: load.spec, - } }, }), ) - expect(wait).toBe(1) - expect(count).toBe(2) - expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }]) + expect(wait).toBe(0) + expect(count).toBe(1) + expect(loaded).toEqual([]) }), ), )