feat(tui): announce server plugin failures (#47103)

Show grouped server plugin failure notices with an Open plugins action, retain the home failure count, and reveal failed built-ins in the plugin dialog. Keep unchanged failures quiet across inventory refreshes and reconnects.
This commit is contained in:
Kit Langton
2026-09-03 13:17:10 -04:00
committed by GitHub
Unverified
parent a222401f19
commit 2dcfc89fab
4 changed files with 164 additions and 28 deletions
@@ -52,7 +52,11 @@ function Plugins(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const plugins = usePlugin()
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
const failed = createMemo(
() =>
plugins.list().filter((item) => item.status === "failed").length +
plugins.server().filter((item) => item.state.status === "failed").length,
)
return (
<Show when={failed()}>
@@ -79,7 +79,9 @@ export function PluginsDialog(props: {
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
]
})
const visibleEntries = createMemo(() => entries().filter((entry) => showInternal() || !entry.internal))
const visibleEntries = createMemo(() =>
entries().filter((entry) => showInternal() || !entry.internal || status(entry) === "failed"),
)
createEffect(() => {
if (visibleEntries().some((entry) => entry.key === focused())) return
const first = visibleEntries().find((entry) => entry.runtime === "tui") ?? visibleEntries()[0]
@@ -286,9 +288,7 @@ function source(plugin: PluginInfo, context: Plugin.Context) {
function isLocal(entry: Entry) {
if (entry.runtime === "server") return entry.plugin.source.type === "local"
const target = entry.target
return (
target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
)
return target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
}
function status(entry: Entry) {
+44 -23
View File
@@ -53,6 +53,7 @@ type RegisteredPlugin = {
type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly server: () => readonly PluginInfo[]
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slots: {
@@ -90,13 +91,15 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
const lifecycle = useTuiLifecycle()
const client = useClient()
const data = useData()
const [serverPlugins, setServerPlugins] = createSignal<
ReadonlyArray<
PluginInfo & { readonly state: { readonly status: "active" } } & {
readonly source: { readonly type: "package" } | { readonly type: "local" }
}
>
>([])
const [serverPlugins, setServerPlugins] = createSignal<readonly PluginInfo[]>([])
const serverTuiPlugins = createMemo(() =>
serverPlugins().filter(
(plugin): plugin is PluginInfo & { readonly source: { readonly type: "package" } | { readonly type: "local" } } =>
plugin.state.status === "active" &&
plugin.features.tui === true &&
(plugin.source.type === "package" || plugin.source.type === "local"),
),
)
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@@ -257,7 +260,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
install: true,
optional: true,
})),
...serverPlugins().map((plugin) => ({
...serverTuiPlugins().map((plugin) => ({
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
install: false,
optional: true,
@@ -473,7 +476,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
createEffect(
on(
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
() => JSON.stringify([serverTuiPlugins(), config.data.plugins ?? []]),
() => {
npmFailures.clear()
void enqueue(reconcile).then(
@@ -486,20 +489,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
const syncServerPlugins = () =>
client.api.plugin
.list({ location: data.location.default() })
.then((response) =>
setServerPlugins(
response.data.filter(
(
plugin,
): plugin is PluginInfo & { readonly state: { readonly status: "active" } } & {
readonly source: { readonly type: "package" } | { readonly type: "local" }
} =>
plugin.state.status === "active" &&
plugin.features.tui === true &&
(plugin.source.type === "package" || plugin.source.type === "local"),
),
),
)
.then((response) => {
const failed = response.data.filter(
(plugin) =>
plugin.state.status === "failed" &&
!serverPlugins().some(
(previous) =>
serverPluginName(previous) === serverPluginName(plugin) && isDeepEqual(previous.state, plugin.state),
),
)
setServerPlugins(response.data)
const first = failed[0]
if (!first) return
host.toast.show({
variant: "error",
title: failed.length === 1 ? `Plugin failed: ${serverPluginName(first)}` : `${failed.length} plugins failed`,
message:
(failed.length > 1 ? `${failed.map(serverPluginName).join(", ")}\n` : "") + "Run /plugins to view details.",
action: { label: "Open plugins", run: () => host.keymap.dispatch("plugins.list") },
})
})
.catch(() => undefined)
createEffect(
on(
@@ -538,6 +547,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
value={{
ready: () => store.ready,
list: () => store.states,
server: serverPlugins,
registered: () =>
Object.entries(store.registrations).map(([id, plugin]) => ({
id,
@@ -558,6 +568,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
)
}
function serverPluginName(plugin: PluginInfo) {
return (
plugin.id ??
(plugin.source.type === "package"
? plugin.source.target
: plugin.source.type === "local"
? plugin.source.path
: plugin.source.type)
)
}
async function disposeAll(cleanups: Dispose[]) {
const failures: unknown[] = []
for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error))
+111
View File
@@ -9,6 +9,7 @@ import { createEventStream, createFetch, directory, json, type FetchHandler } fr
import { tmpdir } from "./fixture/fixture"
import type { TuiInput } from "../src/app"
import type { Config } from "../src/config"
import type { PluginInfo } from "@opencode-ai/client"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
await using state = await tmpdir()
@@ -1420,6 +1421,116 @@ test.each([100, 44])(
},
)
test.each([
[100, true],
[44, true],
[100, false],
[44, false],
] as const)("server plugin failures are visible at width %s (already failed: %s)", async (width, initial) => {
await using state = await tmpdir()
const failure: PluginInfo["state"] = {
status: "failed",
error: "Plugin disabled after command.transform failed. Check server logs for details.",
ref: "err_fixture",
}
let inventory: PluginInfo[] = [
{
id: "broken",
source: { type: "builtin" },
features: { server: true },
state: initial ? failure : { status: "active" },
},
{ id: "healthy", source: { type: "builtin" }, features: { server: true }, state: { status: "active" } },
]
let requests = 0
await using setup = await createAppFixture({
width,
state: state.path,
fetch: (url) => {
if (url.pathname !== "/api/plugin") return undefined
requests++
return json({
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
data: inventory,
})
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("commands"))
if (!initial) {
expect(setup.captureCharFrame()).not.toContain("Plugin failed")
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
setup.events.emit({ id: "evt_failure", created: 1, type: "plugin.updated", data: {} })
}
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
expect(setup.captureCharFrame()).toContain("/plugins")
expect(setup.captureCharFrame()).toContain("1 plugin failed")
const lines = setup.captureCharFrame().split("\n")
const row = lines.findIndex((line) => line.includes("Open plugins"))
expect(row).toBeGreaterThanOrEqual(0)
const line = lines[row]
if (!line) throw new Error("Open plugins action is missing")
await setup.mockMouse.click(line.indexOf("Open plugins"), row)
await setup.waitForFrame((frame) => frame.includes("ctrl+a") && frame.includes("broken"))
expect(setup.captureCharFrame()).toContain("broken")
expect(setup.captureCharFrame()).not.toContain("healthy")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Server plugin error") && frame.includes("transform failed"))
expect(setup.captureCharFrame()).toContain("Plugin disabled")
expect(setup.captureCharFrame()).toContain("transform failed")
expect(setup.captureCharFrame()).toContain("err_fixture")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => frame.includes("ctrl+a"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("ctrl+a"))
expect(setup.captureCharFrame()).toContain("1 plugin failed")
const seen = requests
setup.events.emit({ id: "evt_repeat", created: 2, type: "plugin.updated", data: {} })
setup.events.emit({ id: "evt_reconnect", type: "server.connected", data: {} })
await setup.waitFor(() => requests >= seen + 2)
await setup.flush()
expect(setup.captureCharFrame()).not.toContain("Plugin failed:")
inventory = inventory.map((plugin) => ({ ...plugin, state: { status: "active" } }))
setup.events.emit({ id: "evt_recovered", created: 3, type: "plugin.updated", data: {} })
await setup.waitForFrame((frame) => !frame.includes("1 plugin failed"))
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
setup.events.emit({ id: "evt_failed_again", created: 4, type: "plugin.updated", data: {} })
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
})
test("server plugin failures share one notice and use source names before an ID is known", async () => {
await using state = await tmpdir()
await using setup = await createAppFixture({
state: state.path,
fetch: (url) =>
url.pathname === "/api/plugin"
? json({
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
data: [
{
source: { type: "package", target: "missing-package" },
features: {},
state: { status: "failed", error: "Package missing" },
},
{
source: { type: "local", path: "/fixture/broken.ts" },
features: {},
state: { status: "failed", error: "Invalid plugin" },
},
],
})
: undefined,
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("2 plugins failed"))
expect(setup.captureCharFrame()).toContain("missing-package")
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
expect(setup.captureCharFrame()).toContain("Open plugins")
})
async function createAppFixture(
input: {
width?: number