Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 07db2b925a fix(plugin): make load failures easier to diagnose 2026-09-01 18:57:29 +05:30
13 changed files with 293 additions and 65 deletions
@@ -18,7 +18,7 @@ export type PluginSource =
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
export type PluginState = { status: "active" } | { status: "failed"; error: string }
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
+19 -6
View File
@@ -26,6 +26,11 @@ const Definition = Schema.Struct({
]),
})
export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.LoadError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
@@ -39,13 +44,21 @@ export const load = Effect.fn("PluginModule.load")(function* (
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod).pipe(
Effect.mapError(
(cause) =>
new LoadError({
message: "Plugin must export a default definition with an id and an effect or setup function.",
cause,
}),
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
@@ -77,11 +90,11 @@ function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name))
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
)
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) =>
names.has(`${name}.${extension}`),
)
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
+9 -9
View File
@@ -55,11 +55,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
}
const plugin = yield* PluginModule.load(operation, { install }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
Effect.catchCause((cause) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
const error = Cause.squash(cause)
return Effect.logWarning("failed to load plugin", { target: operation.target, ref, cause }).pipe(
Effect.as({ error: error instanceof PluginModule.LoadError ? error.message : "Plugin failed to load", ref }),
)
}),
)
if ("pending" in plugin) {
pending.add(operation.target)
@@ -68,7 +70,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
state: { status: "failed", error: plugin.error },
state: { status: "failed", error: plugin.error, ref: plugin.ref },
features: { server: true },
})
continue
@@ -127,9 +129,7 @@ export const layer = Layer.effect(
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const source = (source: Plugin.Source) =>
source.type === "package" && outdated.has(source.target)
? { ...source, outdated: true as const }
: source
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
const apply = (resolved: typeof immediate) =>
registry.activate(
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
+30 -15
View File
@@ -20,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Cause, Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -217,12 +217,15 @@ describe("PluginSupervisor config", () => {
)
it.live("logs invalid packages and continues loading", () => {
const output: string[] = []
const output: Array<{ target: string; ref: string; diagnostic: string }> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
const details = entry.message[1]
if (typeof details !== "object" || details === null || !("target" in details)) return
if (typeof details.target === "string") output.push(details.target)
if (typeof details !== "object" || details === null) return
if (!("target" in details) || typeof details.target !== "string") return
if (!("ref" in details) || typeof details.ref !== "string") return
if (!("cause" in details) || !Cause.isCause(details.cause)) return
output.push({ target: details.target, ref: details.ref, diagnostic: Cause.pretty(details.cause) })
})
return withLocation(
{
@@ -230,6 +233,7 @@ describe("PluginSupervisor config", () => {
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid"),
path.join(import.meta.dir, "../plugin/fixtures/load-failure"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
options: { description: "Loaded after invalid plugins" },
@@ -243,16 +247,30 @@ describe("PluginSupervisor config", () => {
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
expect(output).toEqual([
expect(output.map((entry) => entry.target)).toEqual([
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"),
path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.state.status === "failed").map((plugin) => plugin.source),
).toEqual([
const failed = (yield* plugins.list()).filter((plugin) => plugin.state.status === "failed")
expect(failed.map((plugin) => plugin.source)).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts") },
])
expect(failed.map((plugin) => plugin.state)).toEqual([
{ status: "failed", error: "Plugin failed to load", ref: output[0]?.ref },
{
status: "failed",
error: "Plugin must export a default definition with an id and an effect or setup function.",
ref: output[1]?.ref,
},
{ status: "failed", error: "Plugin failed to load", ref: output[2]?.ref },
])
output.forEach((entry) => expect(entry.ref).toMatch(/^err_[0-9a-f]{8}$/))
expect(new Set(output.map((entry) => entry.ref)).size).toBe(3)
expect(output[2]?.diagnostic).toContain("private plugin loader details")
expect(JSON.stringify(failed)).not.toContain("private plugin loader details")
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -537,7 +555,6 @@ describe("PluginSupervisor config", () => {
}),
),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -610,12 +627,10 @@ function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
async function writeDiscoveredPackage(
directory: string,
name: string,
files: Record<string, string>,
) {
async function writeDiscoveredPackage(directory: string, name: string, files: Record<string, string>) {
const plugin = path.join(directory, ".opencode", "plugins", name)
await fs.mkdir(plugin, { recursive: true })
await Promise.all(Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))))
await Promise.all(
Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
)
}
@@ -0,0 +1 @@
throw new Error("private plugin loader details")
+3
View File
@@ -16772,6 +16772,9 @@
},
"error": {
"type": "string"
},
"ref": {
"type": "string"
}
},
"required": ["status", "error"],
+1 -1
View File
@@ -29,7 +29,7 @@ export type Features = typeof Features.Type
export const State = Schema.Union([
Schema.Struct({ status: Schema.Literal("active") }),
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }),
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String, ref: Schema.String.pipe(optional) }),
]).annotate({ identifier: "Plugin.State" })
export type State = typeof State.Type
+14
View File
@@ -23,3 +23,17 @@ test("embeds plugin state with a status discriminator", () => {
outdated: true,
})
})
test("plugin failure references round trip and absent references stay omitted", () => {
const codec = Schema.fromJsonString(Plugin.State)
const failed = { status: "failed", error: "Plugin failed to load", ref: "err_a1b2c3d4" } as const
expect(Schema.decodeUnknownSync(codec)(Schema.encodeSync(codec)(failed))).toEqual(failed)
expect(Schema.encodeSync(Plugin.State)({ ...failed, ref: undefined })).toEqual({
status: "failed",
error: failed.error,
})
expect(Schema.decodeUnknownSync(Plugin.State)({ status: "failed", error: failed.error })).toMatchObject({
status: "failed",
error: failed.error,
})
})
@@ -1,6 +1,6 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -9,17 +9,25 @@ import { useRoute } from "../context/route"
import { getScrollAcceleration } from "../util/scroll"
import { useTheme } from "../context/theme"
import { emptyPrompt } from "../prompt/history"
import { useDialog } from "../ui/dialog"
import { dialogWidth, useDialog } from "../ui/dialog"
import { FilePath } from "../ui/file-path"
import { useToast } from "../ui/toast"
import { errorDetails } from "../util/error-details"
export function DialogErrorDetails(props: { title: string; error: string; context?: string; onBack: () => void }) {
export function DialogErrorDetails(props: {
title: string
source?: string
error: string
context?: string
diagnosticRef?: string
onBack: () => void
}) {
const clipboard = useClipboard()
const dialog = useDialog()
const location = useLocation()
const route = useRoute()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
@@ -51,7 +59,7 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
const copy = () => {
void clipboard
.write(props.error)
.write(errorDetails(props).text)
.then(() => setCopied(true))
.catch(toast.error)
}
@@ -62,7 +70,7 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
location: location.ref,
prompt: {
...emptyPrompt(),
text: `Investigate why this OpenCode component failed in the current project.\n\n${props.title}${props.context ? `\n${props.context}` : ""}\nError: ${props.error}\n\nInspect the relevant project and global OpenCode configuration, startup or loading behavior, required environment variables or credentials, dependencies, and logs. Identify the root cause and recommend a fix.`,
text: errorDetails(props).prompt,
},
})
dialog.clear()
@@ -88,40 +96,49 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
})
return (
<box paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc
</text>
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box>
<box flexDirection="row" gap={2}>
<text
attributes={TextAttributes.BOLD}
fg={theme.text.default}
flexGrow={1}
minWidth={0}
wrapMode="none"
truncate
>
{props.title}
</text>
<text fg={theme.text.subdued} flexShrink={0} onMouseUp={props.onBack}>
esc
</text>
</box>
<Show when={props.source}>
{(source) => (
<FilePath
value={source()}
maxWidth={Math.min(dialogWidth(dialog.size), dimensions().width - 2) - 4}
fg={theme.text.subdued}
/>
)}
</Show>
</box>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
{props.error}
</text>
</scrollbox>
<Show when={props.diagnosticRef}>
<text fg={theme.text.subdued}>Reference: {props.diagnosticRef}</text>
</Show>
</box>
<box flexDirection="row" gap={3} paddingLeft={2} paddingRight={2}>
<text flexGrow={1}>
<span style={{ fg: theme.text.default }}>
<b>{scrollable() ? "↑/↓" : ""}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{scrollable() ? " scroll" : ""}</span>
</text>
<box flexDirection="row" gap={3} flexWrap="wrap">
<text onMouseUp={investigate}>
<span style={{ fg: theme.text.default }}>
<b>i</b>
@@ -134,6 +151,9 @@ export function DialogErrorDetails(props: { title: string; error: string; contex
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
<Show when={scrollable()}>
<text fg={theme.text.subdued}>/ scroll</text>
</Show>
</box>
</box>
)
@@ -186,9 +186,11 @@ export function PluginsDialog(props: {
>
{(entry) => (
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin error`}
source={pluginSource(entry(), props.context)}
error={pluginError(entry()) ?? "Unknown plugin error"}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
diagnosticRef={pluginErrorRef(entry())}
context={`Plugin: ${label(entry(), props.context)}\nStatus: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -241,11 +243,14 @@ function displayVersion(version: string) {
}
function pluginError(entry: Entry | undefined) {
if (entry?.runtime === "server")
return entry.plugin.state.status === "failed" ? entry.plugin.state.error : undefined
if (entry?.runtime === "server") return entry.plugin.state.status === "failed" ? entry.plugin.state.error : undefined
return entry?.error
}
function pluginErrorRef(entry: Entry) {
if (entry.runtime === "server" && entry.plugin.state.status === "failed") return entry.plugin.state.ref
}
function Commands(props: { context: Plugin.Context }) {
const plugins = usePlugin()
props.context.keymap.layer(() => ({
+21
View File
@@ -0,0 +1,21 @@
export function errorDetails(input: { title: string; error: string; context?: string; diagnosticRef?: string }) {
const text = [
input.title,
...(input.context ? [input.context] : []),
`Error: ${input.error}`,
...(input.diagnosticRef ? [`Reference: ${input.diagnosticRef}`] : []),
].join("\n")
return {
text,
prompt: [
"Investigate why this OpenCode component failed in the current project.",
text,
...(input.diagnosticRef
? [
`Find the server log entry matching reference ${input.diagnosticRef} and inspect its original cause. If the server logs are not accessible from this session, say so and ask for the matching log entry; do not infer the cause from the reference alone.`,
]
: []),
"Inspect the relevant project and global OpenCode configuration, startup or loading behavior, required environment variables or credentials, dependencies, and logs. Identify the root cause and recommend a fix. Do not expose credentials.",
].join("\n\n"),
}
}
@@ -0,0 +1,110 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { DialogErrorDetails } from "../../src/component/dialog-error-details"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { Keymap } from "../../src/context/keymap"
import { LocationProvider } from "../../src/context/location"
import { RouteProvider, useRoute } from "../../src/context/route"
import { ThemeProvider } from "../../src/context/theme"
import { DialogProvider, useDialog } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
for (const width of [40, 100]) {
test(`error details stay compact at ${width} columns and investigate only prepares a draft`, async () => {
await using temporary = await tmpdir()
const source = "/private/var/folders/very-long-temporary-directory/opencode/project/plugins/broken/index.ts"
const copied: string[] = []
const submitted: string[] = []
let route: ReturnType<typeof useRoute> | undefined
const api = createApi(
createFetch((url, request) => {
if (request.method === "POST") submitted.push(url.pathname)
return undefined
}, createEventStream()).fetch,
)
function OpenDialog() {
route = useRoute()
const dialog = useDialog()
onMount(() =>
dialog.replace(
<DialogErrorDetails
title="Server plugin error"
source={source}
error="Plugin failed to load"
context={`Plugin: broken\nRuntime: server\nSource: ${source}`}
diagnosticRef="err_a1b2c3d4"
onBack={() => dialog.clear()}
/>,
),
)
return null
}
const app = await testRender(
() => (
<TestTuiContexts
directory={temporary.path}
paths={{ state: temporary.path }}
clipboard={{ read: async () => undefined, write: async (text) => void copied.push(text) }}
>
<ConfigProvider config={createTuiResolvedConfig()}>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={api}>
<DataProvider directory={temporary.path}>
<LocationProvider>
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<OpenDialog />
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 24, kittyKeyboard: true },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Reference: err_a1b2c3d4"))
const lines = app.captureCharFrame().split("\n")
const heading = lines.find((line) => line.includes("Server plugin error"))
expect(heading).toContain("esc")
expect(lines.some((line) => line.includes("broken/index.ts"))).toBe(true)
expect(lines.join("\n")).not.toContain("very-long-temporary-directory")
const message = lines.find((line) => line.includes("Plugin failed to load"))
const reference = lines.find((line) => line.includes("Reference:"))
expect(message?.indexOf("Plugin")).toBe(reference?.indexOf("Reference:"))
expect(lines.filter((line) => line.trim()).length).toBeLessThanOrEqual(6)
app.mockInput.pressKey("c")
await app.waitFor(() => copied.length === 1)
expect(copied[0]).toContain(source)
expect(copied[0]).toContain("Reference: err_a1b2c3d4")
app.mockInput.pressKey("i")
await app.waitFor(() => route?.data.type === "home" && !!route.data.prompt)
const current = route?.data
expect(current?.type === "home" && current.prompt?.text).toContain(source)
expect(current?.type === "home" && current.prompt?.text).toContain("matching reference err_a1b2c3d4")
expect(submitted).toEqual([])
} finally {
app.renderer.destroy()
}
})
}
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test"
import { errorDetails } from "../../src/util/error-details"
test("copy and investigation include the plugin context and matching log reference", () => {
const result = errorDetails({
title: "Server plugin: example",
error: "Plugin failed to load",
context: "Status: failed\nRuntime: server\nSource: /project/plugin.ts",
diagnosticRef: "err_a1b2c3d4",
})
expect(result.text).toBe(
"Server plugin: example\nStatus: failed\nRuntime: server\nSource: /project/plugin.ts\nError: Plugin failed to load\nReference: err_a1b2c3d4",
)
expect(result.prompt).toContain(result.text)
expect(result.prompt).toContain("Find the server log entry matching reference err_a1b2c3d4")
expect(result.prompt).toContain("If the server logs are not accessible")
expect(result.prompt).toContain("Do not expose credentials.")
})
test("errors without a reference still have useful copy and investigation text", () => {
const result = errorDetails({ title: "MCP server: example", error: "Connection refused" })
expect(result.text).toBe("MCP server: example\nError: Connection refused")
expect(result.prompt).toContain(result.text)
expect(result.prompt).not.toContain("matching reference")
expect(result.text).not.toContain("undefined")
})