diff --git a/src/commands/health-format.test.ts b/src/commands/health-format.test.ts index 69f1beee62a5..9a895978f964 100644 --- a/src/commands/health-format.test.ts +++ b/src/commands/health-format.test.ts @@ -257,6 +257,54 @@ describe("formatHealthChannelLines", () => { ]); }); + it("surfaces activated plugin failures without promoting inactive load errors", () => { + const summary = createHealthSummary({ channels: {}, channelOrder: [], channelLabels: {} }); + summary.plugins = { + loaded: ["calendar"], + errors: [ + { + id: "calendar", + origin: "workspace", + activated: true, + failurePhase: "service", + error: "service scheduler: address already in use", + }, + { + id: "inactive", + origin: "workspace", + activated: false, + failurePhase: "load", + error: "inactive plugin load failed", + }, + ], + }; + + expect(formatHealthChannelLines(summary)).toStrictEqual([ + "Plugin calendar: failed - service scheduler: address already in use; run openclaw doctor", + ]); + }); + + it("bounds activated plugin failure details and summarizes omitted failures", () => { + const summary = createHealthSummary({ channels: {}, channelOrder: [], channelLabels: {} }); + summary.plugins = { + loaded: [], + errors: Array.from({ length: 22 }, (_, index) => ({ + id: `plugin-${index}`, + origin: "workspace", + activated: true, + error: "x".repeat(600), + })), + }; + + const lines = formatHealthChannelLines(summary); + + expect(lines).toHaveLength(21); + expect(lines[0]).toBe(`Plugin plugin-0: failed - ${"x".repeat(500)}; run openclaw doctor`); + expect(lines.at(-1)).toBe( + "Plugins: failed - 2 additional activated failures; run openclaw doctor", + ); + }); + it("formats iMessage probe failures as failed health lines", () => { const summary = createHealthSummary({ channels: { diff --git a/src/commands/health-format.ts b/src/commands/health-format.ts index afbf77139e94..d9bb28d491e0 100644 --- a/src/commands/health-format.ts +++ b/src/commands/health-format.ts @@ -139,7 +139,7 @@ const isProbeFailure = (summary: ChannelAccountHealthSummary): boolean => { return ok === false; }; -/** Formats one terse health line per channel, optionally including every account. */ +/** Formats terse channel and activated-plugin health lines for shared CLI surfaces. */ export const formatHealthChannelLines = ( summary: HealthSummary, opts: { @@ -269,5 +269,16 @@ export const formatHealthChannelLines = ( : "unknown"; lines.push(`${label}: ${passiveState}`); } + const failedPlugins = (summary.plugins?.errors ?? []).filter((plugin) => plugin.activated); + for (const plugin of failedPlugins.slice(0, 20)) { + const id = sanitizeTerminalText(plugin.id).slice(0, 120); + const error = sanitizeTerminalText(plugin.error).slice(0, 500); + lines.push(`Plugin ${id}: failed - ${error}; run openclaw doctor`); + } + if (failedPlugins.length > 20) { + lines.push( + `Plugins: failed - ${failedPlugins.length - 20} additional activated failures; run openclaw doctor`, + ); + } return lines; }; diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 7abcb341da51..639e49811a28 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -170,7 +170,7 @@ describe("healthCommand", () => { probeGatewayStatusMock.mockReset(); }); - it("renders the gateway session path identically in JSON and text", async () => { + it("preserves plugin health in JSON while surfacing activated failures in text", async () => { const agentSessions = { path: "/tmp/sessions.json", count: 1, @@ -194,6 +194,24 @@ describe("healthCommand", () => { }, sessions: agentSessions, }); + snapshot.plugins = { + loaded: ["calendar"], + errors: [ + { + id: "calendar", + origin: "workspace", + activated: true, + failurePhase: "service", + error: "service scheduler: address already in use", + }, + { + id: "inactive", + origin: "workspace", + activated: false, + error: "inactive plugin load failed", + }, + ], + }; callGatewayMock.mockResolvedValueOnce(snapshot); await healthCommand({ json: true, timeoutMs: 5000, config: {} }, runtime as never); @@ -204,6 +222,7 @@ describe("healthCommand", () => { expect(parsed.channels.whatsapp?.linked).toBe(true); expect(parsed.channels.telegram?.configured).toBe(true); expect(parsed.sessions.count).toBe(1); + expect(parsed.plugins).toEqual(snapshot.plugins); runtime.log.mockClear(); callGatewayMock.mockResolvedValueOnce(snapshot); @@ -211,6 +230,10 @@ describe("healthCommand", () => { const output = stripAnsi(runtime.log.mock.calls.map((call) => String(call[0])).join("\n")); expect(output).toContain(`Session store (main): ${parsed.sessions.path}`); + expect(output).toContain( + "Plugin calendar: failed - service scheduler: address already in use; run openclaw doctor", + ); + expect(output).not.toContain("inactive plugin load failed"); }); it("prints the gateway probe duration in text output", async () => { diff --git a/src/commands/status.command-sections.test.ts b/src/commands/status.command-sections.test.ts index a881b3e14fd9..1d7c4e9541a4 100644 --- a/src/commands/status.command-sections.test.ts +++ b/src/commands/status.command-sections.test.ts @@ -1,5 +1,6 @@ // Status command section tests cover footer, health, and report section rendering. import { describe, expect, it } from "vitest"; +import { formatHealthChannelLines } from "./health-format.js"; import type { HealthSummary } from "./health.js"; import { buildStatusFooterLines, @@ -291,6 +292,46 @@ describe("status.command-sections", () => { ]); }); + it("marks activated plugin service failures as warnings in deep health rows", () => { + const health: HealthSummary = { + ok: true, + ts: 0, + durationMs: 42, + heartbeatSeconds: 60, + defaultAgentId: "main", + agents: [], + sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, + channels: {}, + channelOrder: [], + channelLabels: {}, + plugins: { + loaded: ["calendar"], + errors: [ + { + id: "calendar", + origin: "workspace", + activated: true, + failurePhase: "service", + error: "service scheduler: address already in use", + }, + ], + }, + }; + const rows = buildStatusHealthRows({ + health, + formatHealthChannelLines, + ok: (value) => `ok(${value})`, + warn: (value) => `warn(${value})`, + muted: (value) => `muted(${value})`, + }); + + expect(rows).toContainEqual({ + Item: "Plugin calendar", + Status: "warn(WARN)", + Detail: "failed - service scheduler: address already in use; run openclaw doctor", + }); + }); + it("adds degraded event-loop health to status rows", () => { const rows = buildStatusHealthRows({ health: {