mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 14:36:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07db2b925a |
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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")
|
||||
@@ -16772,6 +16772,9 @@
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "error"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
Reference in New Issue
Block a user