From 9b0c3abbca9014545ad09013c9d4eefb9c0128cb Mon Sep 17 00:00:00 2001 From: Masato Hoshino Date: Mon, 6 Jul 2026 17:22:42 +0900 Subject: [PATCH] improve(status): group disabled plugins by reason in /status plugins (#99598) --- src/status/status-plugin-health.test.ts | 63 +++++++++++++++++++++++++ src/status/status-plugin-health.ts | 36 +++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/status/status-plugin-health.test.ts b/src/status/status-plugin-health.test.ts index 522f99d799ad..9cf67ea92581 100644 --- a/src/status/status-plugin-health.test.ts +++ b/src/status/status-plugin-health.test.ts @@ -243,6 +243,7 @@ describe("plugin health status formatting", () => { ); expect(text).toContain("Loaded: 1 (ok-plugin)"); expect(text).toContain("Disabled: 1"); + expect(text).toContain("- disabled: 1 (disabled-plugin)"); expect(text).toContain("- bad-plugin [load]: module failed"); expect(text).toContain("- bad_tool owner=plugin:bad-tools: unsupported anyOf"); expect(text).toContain("- sms plugin=sms-plugin [setup]: setup failed"); @@ -251,6 +252,68 @@ describe("plugin health status formatting", () => { expect(text).toContain("Full inventory: /plugins list"); }); + it("groups disabled plugins by their recorded disable reason", () => { + const text = formatDetailedPluginHealth({ + plugins: [ + { id: "zeta", status: "disabled", enabled: false, error: "not in allowlist" }, + { + id: "alpha", + status: "disabled", + enabled: false, + error: "overridden by better-alpha plugin", + }, + { id: "beta", status: "disabled", enabled: false, error: "not in allowlist" }, + // No recorded reason (hand-built snapshot): falls back to a plain "disabled". + { id: "mid", status: "disabled", enabled: false }, + ], + diagnostics: [], + contextEngineQuarantines: [], + }); + + const lines = text.split("\n"); + const disabledAt = lines.indexOf("Disabled: 4"); + expect(disabledAt).toBeGreaterThan(-1); + // One line per distinct reason, right under the count, in deterministic + // reason order with alphabetical plugin ids. + expect(lines.slice(disabledAt + 1, disabledAt + 4)).toEqual([ + "- disabled: 1 (mid)", + "- not in allowlist: 2 (beta, zeta)", + "- overridden by better-alpha plugin: 1 (alpha)", + ]); + }); + + it("caps disabled reason lines and per-reason id lists", () => { + const distinctReasons = formatDetailedPluginHealth({ + plugins: Array.from({ length: 9 }, (_, index) => ({ + id: `plugin-${index}`, + status: "disabled" as const, + enabled: false, + error: `reason ${index}`, + })), + diagnostics: [], + contextEngineQuarantines: [], + }); + expect(distinctReasons).toContain("Disabled: 9"); + expect(distinctReasons).toContain("- reason 7: 1 (plugin-7)"); + expect(distinctReasons).not.toContain("reason 8"); + expect(distinctReasons).toContain("- +1 more reasons"); + + const sharedReason = formatDetailedPluginHealth({ + plugins: Array.from({ length: 10 }, (_, index) => ({ + id: `plugin-${index}`, + status: "disabled" as const, + enabled: false, + error: "not in allowlist", + })), + diagnostics: [], + contextEngineQuarantines: [], + }); + expect(sharedReason).toContain("Disabled: 10"); + expect(sharedReason).toContain( + "- not in allowlist: 10 (plugin-0, plugin-1, plugin-2, plugin-3, plugin-4, plugin-5, plugin-6, plugin-7, +2 more)", + ); + }); + it("separates runtime-loaded plugins from installed-but-not-active inventory", () => { const text = formatDetailedPluginHealth({ plugins: [ diff --git a/src/status/status-plugin-health.ts b/src/status/status-plugin-health.ts index e4193faef263..76c0e8766154 100644 --- a/src/status/status-plugin-health.ts +++ b/src/status/status-plugin-health.ts @@ -297,7 +297,9 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot) .filter((id) => !shouldRunNotLoadedSet.has(id)) .toSorted(byLocale) : []; - const disabled = snapshot.plugins.filter((plugin) => plugin.status === "disabled").length; + const disabledPlugins = snapshot.plugins + .filter((plugin) => plugin.status === "disabled") + .toSorted((left, right) => byLocale(left.id, right.id)); const errors = snapshot.plugins .filter((plugin) => plugin.status === "error") .toSorted((left, right) => byLocale(left.id, right.id)); @@ -327,9 +329,39 @@ export function formatDetailedPluginHealth(snapshot: StatusPluginHealthSnapshot) const lines = [ formatCompactPluginHealthLine(snapshot) ?? "🔌 Plugins: OK", `Loaded: ${loaded.length}${loaded.length > 0 ? ` (${formatPluginList(loaded, 8)})` : ""}`, - `Disabled: ${disabled}`, + `Disabled: ${disabledPlugins.length}`, ]; + if (disabledPlugins.length > 0) { + // Disable decisions record their reason on `error` (config off, allow/denylist, + // overridden-by/memory-slot arbitration). Group ids per distinct reason so the + // detailed view answers "why is this plugin off" without a /plugins round-trip, + // and a restrictive allowlist folds into one bounded line instead of dozens. + const disabledByReason = new Map(); + for (const plugin of disabledPlugins) { + const reason = plugin.error ?? "disabled"; + const ids = disabledByReason.get(reason); + if (ids) { + ids.push(plugin.id); + } else { + disabledByReason.set(reason, [plugin.id]); + } + } + const reasonEntries = [...disabledByReason.entries()].toSorted((left, right) => + byLocale(left[0], right[0]), + ); + lines.push( + ...reasonEntries + .slice(0, 8) + .map(([reason, ids]) => `- ${reason}: ${ids.length} (${formatPluginList(ids, 8)})`), + ); + if (reasonEntries.length > 8) { + // Unlike the per-plugin buckets, the count above tallies plugins, not + // reasons, so a reader cannot infer that reason lines were truncated. + lines.push(`- +${reasonEntries.length - 8} more reasons`); + } + } + if (installedNotActive.length > 0) { // Installed/discovered plugins not loaded in the runtime registry. Neutral // inventory, not an error: the gateway only starts the plugins its startup