improve(status): group disabled plugins by reason in /status plugins (#99598)

This commit is contained in:
Masato Hoshino
2026-07-06 17:22:42 +09:00
committed by GitHub
parent 1a07bd760c
commit 9b0c3abbca
2 changed files with 97 additions and 2 deletions

View File

@@ -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: [

View File

@@ -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<string, string[]>();
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