diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 1bf3feb772b..02e7fc948c6 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -62,6 +62,7 @@ describe("handleCommands /plugins", () => { expect(showResult.reply?.text).toContain('"id": "superpowers"'); expect(showResult.reply?.text).toContain('"bundleFormat": "claude"'); expect(showResult.reply?.text).toContain('"shape":'); + expect(showResult.reply?.text).toContain('"compatibilityWarnings": []'); const inspectAllParams = buildCommandTestParams( "/plugins inspect all", @@ -75,6 +76,7 @@ describe("handleCommands /plugins", () => { const inspectAllResult = await handleCommands(inspectAllParams); expect(inspectAllResult.reply?.text).toContain("```json"); expect(inspectAllResult.reply?.text).toContain('"plugin"'); + expect(inspectAllResult.reply?.text).toContain('"compatibilityWarnings"'); expect(inspectAllResult.reply?.text).toContain('"superpowers"'); }); }); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 1adbf57e717..3b5dcdb9b60 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -45,6 +45,11 @@ function buildPluginInspectJson(params: { } return { inspect, + compatibilityWarnings: inspect.compatibility.map((warning) => ({ + code: warning.code, + severity: warning.severity, + message: `${warning.pluginId} ${warning.message}`, + })), install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, }; } @@ -61,6 +66,11 @@ function buildAllPluginInspectJson(params: { report: params.report, }).map((inspect) => ({ inspect, + compatibilityWarnings: inspect.compatibility.map((warning) => ({ + code: warning.code, + severity: warning.severity, + message: `${warning.pluginId} ${warning.message}`, + })), install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, })); } diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 412e45a6639..ad52aa4559d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -22,6 +22,7 @@ import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; import { buildAllPluginInspectReports, + buildPluginCompatibilityNotices, buildPluginInspectReport, buildPluginStatusReport, } from "../plugins/status.js"; @@ -652,6 +653,12 @@ export function registerPluginsCli(program: Command) { : theme.error("error"), Shape: inspect.shape, Capabilities: formatCapabilityKinds(inspect.capabilities), + Compatibility: + inspect.compatibility.length > 0 + ? inspect.compatibility + .map((entry) => (entry.severity === "warn" ? `warn:${entry.code}` : entry.code)) + .join(", ") + : "none", Hooks: formatHookSummary({ usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart, typedHookCount: inspect.typedHooks.length, @@ -667,6 +674,7 @@ export function registerPluginsCli(program: Command) { { key: "Status", header: "Status", minWidth: 10 }, { key: "Shape", header: "Shape", minWidth: 18 }, { key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true }, + { key: "Compatibility", header: "Compatibility", minWidth: 24, flex: true }, { key: "Hooks", header: "Hooks", minWidth: 20, flex: true }, ], rows, @@ -751,6 +759,12 @@ export function registerPluginsCli(program: Command) { ), ), ); + lines.push( + ...formatInspectSection( + "Compatibility warnings", + inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`), + ), + ); lines.push( ...formatInspectSection( "Custom hooks", @@ -1058,8 +1072,9 @@ export function registerPluginsCli(program: Command) { const report = buildPluginStatusReport(); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((d) => d.level === "error"); + const compatibility = buildPluginCompatibilityNotices({ report }); - if (errors.length === 0 && diags.length === 0) { + if (errors.length === 0 && diags.length === 0 && compatibility.length === 0) { defaultRuntime.log("No plugin issues detected."); return; } @@ -1081,6 +1096,16 @@ export function registerPluginsCli(program: Command) { lines.push(`- ${target}${diag.message}`); } } + if (compatibility.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(theme.warn("Compatibility:")); + for (const notice of compatibility) { + const marker = notice.severity === "warn" ? theme.warn("warn") : theme.muted("info"); + lines.push(`- ${notice.pluginId} [${marker}]: ${notice.message}`); + } + } const docs = formatDocsLink("/plugin", "docs.openclaw.ai/plugin"); lines.push(""); lines.push(`${theme.muted("Docs:")} ${docs}`); diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts new file mode 100644 index 00000000000..ad64d600dff --- /dev/null +++ b/src/commands/doctor-workspace-status.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from "vitest"; +import * as noteModule from "../terminal/note.js"; + +const resolveAgentWorkspaceDirMock = vi.fn(); +const resolveDefaultAgentIdMock = vi.fn(); +const buildWorkspaceSkillStatusMock = vi.fn(); +const loadOpenClawPluginsMock = vi.fn(); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args), + resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args), +})); + +vi.mock("../agents/skills-status.js", () => ({ + buildWorkspaceSkillStatus: (...args: unknown[]) => buildWorkspaceSkillStatusMock(...args), +})); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), +})); + +describe("noteWorkspaceStatus", () => { + it("warns when plugins use legacy compatibility paths", async () => { + resolveDefaultAgentIdMock.mockReturnValue("default"); + resolveAgentWorkspaceDirMock.mockReturnValue("/workspace"); + buildWorkspaceSkillStatusMock.mockReturnValue({ + skills: [], + }); + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "legacy-plugin", + name: "Legacy Plugin", + source: "/tmp/legacy-plugin/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [ + { + pluginId: "legacy-plugin", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/legacy-plugin/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + }); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + const { noteWorkspaceStatus } = await import("./doctor-workspace-status.js"); + noteWorkspaceStatus({}); + + const compatibilityCalls = noteSpy.mock.calls.filter( + ([, title]) => title === "Plugin compatibility", + ); + expect(compatibilityCalls).toHaveLength(1); + expect(String(compatibilityCalls[0]?.[0])).toContain( + "legacy-plugin still relies on legacy before_agent_start", + ); + expect(String(compatibilityCalls[0]?.[0])).toContain( + "legacy-plugin is hook-only; this remains supported for compatibility", + ); + } finally { + noteSpy.mockRestore(); + } + }); + + it("omits plugin compatibility note when no legacy compatibility paths are present", async () => { + resolveDefaultAgentIdMock.mockReturnValue("default"); + resolveAgentWorkspaceDirMock.mockReturnValue("/workspace"); + buildWorkspaceSkillStatusMock.mockReturnValue({ + skills: [], + }); + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "modern-plugin", + name: "Modern Plugin", + source: "/tmp/modern-plugin/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["modern"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + }); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + const { noteWorkspaceStatus } = await import("./doctor-workspace-status.js"); + noteWorkspaceStatus({}); + + expect(noteSpy.mock.calls.some(([, title]) => title === "Plugin compatibility")).toBe(false); + } finally { + noteSpy.mockRestore(); + } + }); +}); diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index 34cffe18092..5e8132c0216 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { buildPluginCompatibilityWarnings } from "../plugins/status.js"; import { note } from "../terminal/note.js"; import { detectLegacyWorkspaceDirs, formatLegacyWorkspaceWarning } from "./doctor-workspace.js"; @@ -54,6 +55,17 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) { note(lines.join("\n"), "Plugins"); } + const compatibilityWarnings = buildPluginCompatibilityWarnings({ + config: cfg, + workspaceDir, + report: { + workspaceDir, + ...pluginRegistry, + }, + }); + if (compatibilityWarnings.length > 0) { + note(compatibilityWarnings.map((line) => `- ${line}`).join("\n"), "Plugin compatibility"); + } if (pluginRegistry.diagnostics.length > 0) { const lines = pluginRegistry.diagnostics.map((diag) => { const prefix = diag.level.toUpperCase(); diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 3ef91457a50..99a4e8bdc9e 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -25,6 +25,7 @@ import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { readTailscaleStatusJson } from "../infra/tailscale.js"; import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; import { checkUpdateStatus, formatGitInstallLabel } from "../infra/update-check.js"; +import { buildPluginCompatibilityNotices } from "../plugins/status.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { VERSION } from "../version.js"; @@ -238,6 +239,7 @@ export async function statusAllCommand( } })() : null; + const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true; const dashboard = controlUiEnabled @@ -360,6 +362,7 @@ export async function statusAllCommand( tailscale, tailscaleHttpsUrl, skillStatus, + pluginCompatibility, channelsStatus, channelIssues, gatewayReachable, diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 5b866413021..66ae5d02ecd 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -6,6 +6,7 @@ import { type RestartSentinelPayload, summarizeRestartSentinel, } from "../../infra/restart-sentinel.js"; +import type { PluginCompatibilityNotice } from "../../plugins/status.js"; import { formatTimeAgo, redactSecrets } from "./format.js"; import { readFileTailLines, summarizeLogTail } from "./gateway.js"; @@ -59,6 +60,7 @@ export async function appendStatusAllDiagnosis(params: { tailscale: TailscaleStatusLike; tailscaleHttpsUrl: string | null; skillStatus: SkillStatusLike | null; + pluginCompatibility: PluginCompatibilityNotice[]; channelsStatus: unknown; channelIssues: ChannelIssueLike[]; gatewayReachable: boolean; @@ -176,6 +178,18 @@ export async function appendStatusAllDiagnosis(params: { ); } + emitCheck( + `Plugin compatibility (${params.pluginCompatibility.length || "none"})`, + params.pluginCompatibility.length === 0 ? "ok" : "warn", + ); + for (const notice of params.pluginCompatibility.slice(0, 12)) { + const severity = notice.severity === "warn" ? "warn" : "info"; + lines.push(` - ${notice.pluginId} [${severity}] ${notice.message}`); + } + if (params.pluginCompatibility.length > 12) { + lines.push(` ${muted(`… +${params.pluginCompatibility.length - 12} more`)}`); + } + params.progress.setLabel("Reading logs…"); const logPaths = (() => { try { diff --git a/src/commands/status-all/report-lines.test.ts b/src/commands/status-all/report-lines.test.ts index 0a71665224c..70b9503d63f 100644 --- a/src/commands/status-all/report-lines.test.ts +++ b/src/commands/status-all/report-lines.test.ts @@ -60,6 +60,7 @@ describe("buildStatusAllReportLines", () => { }, tailscaleHttpsUrl: null, skillStatus: null, + pluginCompatibility: [], channelsStatus: null, channelIssues: [], gatewayReachable: false, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 9f17b1a9fee..18e4c53ebf7 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -137,6 +137,7 @@ export async function statusCommand( secretDiagnostics, memory, memoryPlugin, + pluginCompatibility, } = scan; const usage = opts.usage @@ -217,6 +218,10 @@ export async function statusCommand( agents: agentStatus, securityAudit, secretDiagnostics, + pluginCompatibility: { + count: pluginCompatibility.length, + warnings: pluginCompatibility, + }, ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), }, null, @@ -416,6 +421,12 @@ export async function statusCommand( const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); const channelLabel = channelInfo.label; const gitLabel = formatGitInstallLabel(update); + const pluginCompatibilityValue = + pluginCompatibility.length === 0 + ? ok("none") + : warn( + `${pluginCompatibility.length} notice${pluginCompatibility.length === 1 ? "" : "s"} · ${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size} plugin${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size === 1 ? "" : "s"}`, + ); const overviewRows = [ { Item: "Dashboard", Value: dashboard }, @@ -443,6 +454,7 @@ export async function statusCommand( { Item: "Node service", Value: nodeDaemonValue }, { Item: "Agents", Value: agentsValue }, { Item: "Memory", Value: memoryValue }, + { Item: "Plugin compatibility", Value: pluginCompatibilityValue }, { Item: "Probes", Value: probesValue }, { Item: "Events", Value: eventsValue }, { Item: "Heartbeat", Value: heartbeatValue }, @@ -467,6 +479,18 @@ export async function statusCommand( }).trimEnd(), ); + if (pluginCompatibility.length > 0) { + runtime.log(""); + runtime.log(theme.heading("Plugin compatibility")); + for (const notice of pluginCompatibility.slice(0, 8)) { + const label = notice.severity === "warn" ? theme.warn("WARN") : theme.muted("INFO"); + runtime.log(` ${label} ${notice.pluginId} ${notice.message}`); + } + if (pluginCompatibility.length > 8) { + runtime.log(theme.muted(` … +${pluginCompatibility.length - 8} more`)); + } + } + if (pairingRecovery) { runtime.log(""); runtime.log(theme.warn("Gateway pairing approval required.")); diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 899aea2b267..269b6dc8097 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), ensurePluginRegistryLoaded: vi.fn(), + buildPluginCompatibilityNotices: vi.fn(() => []), })); beforeEach(() => { @@ -91,6 +92,10 @@ vi.mock("../cli/plugin-registry.js", () => ({ ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, })); +vi.mock("../plugins/status.js", () => ({ + buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, +})); + import { scanStatus } from "./status.scan.js"; describe("scanStatus", () => { diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index e7d05542743..736c1a8b215 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -8,6 +8,10 @@ import { readBestEffortConfig } from "../config/config.js"; import { callGateway } from "../gateway/call.js"; import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; +import { + buildPluginCompatibilityNotices, + type PluginCompatibilityNotice, +} from "../plugins/status.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; @@ -107,6 +111,7 @@ export type StatusScanResult = { summary: Awaited>; memory: MemoryStatusSnapshot | null; memoryPlugin: MemoryPluginStatus; + pluginCompatibility: PluginCompatibilityNotice[]; }; async function resolveMemoryStatusSnapshot(params: { @@ -192,6 +197,7 @@ async function scanStatusJsonFast(opts: { const memoryPlugin = resolveMemoryPluginStatus(cfg); const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); const memory = await memoryPromise; + const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); return { cfg, @@ -216,6 +222,7 @@ async function scanStatusJsonFast(opts: { summary, memory, memoryPlugin, + pluginCompatibility, }; } @@ -233,7 +240,7 @@ export async function scanStatus( return await withProgress( { label: "Scanning status…", - total: 10, + total: 11, enabled: true, }, async (progress) => { @@ -325,6 +332,10 @@ export async function scanStatus( const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); progress.tick(); + progress.setLabel("Checking plugins…"); + const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg }); + progress.tick(); + progress.setLabel("Reading sessions…"); const summary = unwrapDeferredResult(await summaryPromise); progress.tick(); @@ -355,6 +366,7 @@ export async function scanStatus( summary, memory, memoryPlugin, + pluginCompatibility, }; }, ); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 3e68d55ced2..e4a6e66d976 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -205,6 +205,7 @@ const mocks = vi.hoisted(() => ({ }, ], }), + buildPluginCompatibilityNotices: vi.fn(() => []), })); vi.mock("../memory/manager.js", () => ({ @@ -385,6 +386,9 @@ vi.mock("../daemon/node-service.js", () => ({ vi.mock("../security/audit.js", () => ({ runSecurityAudit: mocks.runSecurityAudit, })); +vi.mock("../plugins/status.js", () => ({ + buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, +})); import { statusCommand } from "./status.js"; @@ -403,6 +407,15 @@ describe("statusCommand", () => { }); it("prints JSON when requested", async () => { + mocks.buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); expect(payload.linkChannel).toBeUndefined(); @@ -424,6 +437,18 @@ describe("statusCommand", () => { expect(payload.securityAudit.summary.warn).toBe(1); expect(payload.gatewayService.label).toBe("LaunchAgent"); expect(payload.nodeService.label).toBe("LaunchAgent"); + expect(payload.pluginCompatibility).toEqual({ + count: 1, + warnings: [ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ], + }); expect(mocks.runSecurityAudit).toHaveBeenCalledWith( expect.objectContaining({ includeFilesystem: true, @@ -452,6 +477,15 @@ describe("statusCommand", () => { }); it("prints formatted lines otherwise", async () => { + mocks.buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); const logs = await runStatusAndGetLogs(); for (const token of [ "OpenClaw status", @@ -462,6 +496,7 @@ describe("statusCommand", () => { "Dashboard", "macos 14.0 (arm64)", "Memory", + "Plugin compatibility", "Channels", "WhatsApp", "bootstrap files", @@ -476,6 +511,9 @@ describe("statusCommand", () => { ]) { expect(logs.some((line) => line.includes(token))).toBe(true); } + expect( + logs.some((line) => line.includes("legacy-plugin still relies on legacy before_agent_start")), + ).toBe(true); expect( logs.some( (line) => diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index d16db23da4b..7cbdffb4e04 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -5,6 +5,8 @@ const loadOpenClawPluginsMock = vi.fn(); let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport; let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport; let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports; +let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices; +let buildPluginCompatibilityWarnings: typeof import("./status.js").buildPluginCompatibilityWarnings; vi.mock("../config/config.js", () => ({ loadConfig: () => loadConfigMock(), @@ -48,8 +50,13 @@ describe("buildPluginStatusReport", () => { services: [], commands: [], }); - ({ buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport } = - await import("./status.js")); + ({ + buildAllPluginInspectReports, + buildPluginCompatibilityNotices, + buildPluginCompatibilityWarnings, + buildPluginInspectReport, + buildPluginStatusReport, + } = await import("./status.js")); }); it("forwards an explicit env to plugin loading", () => { @@ -148,6 +155,15 @@ describe("buildPluginStatusReport", () => { "web-search", ]); expect(inspect?.usesLegacyBeforeAgentStart).toBe(true); + expect(inspect?.compatibility).toEqual([ + { + pluginId: "google", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); expect(inspect?.policy).toEqual({ allowPromptInjection: false, allowModelOverride: true, @@ -257,4 +273,219 @@ describe("buildPluginStatusReport", () => { "web-search", ]); }); + + it("builds compatibility warnings for legacy compatibility paths", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "lca", + name: "LCA", + description: "Legacy hook plugin", + source: "/tmp/lca/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [ + { + pluginId: "lca", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/lca/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + expect(buildPluginCompatibilityWarnings()).toEqual([ + "lca still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + "lca is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + ]); + }); + + it("builds structured compatibility notices with deterministic ordering", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "hook-only", + name: "Hook Only", + description: "", + source: "/tmp/hook-only/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + { + id: "legacy-only", + name: "Legacy Only", + description: "", + source: "/tmp/legacy-only/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["legacy-only"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 1, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [ + { + pluginId: "hook-only", + events: ["message"], + entry: { + hook: { + name: "legacy", + handler: () => undefined, + }, + }, + }, + ], + typedHooks: [ + { + pluginId: "legacy-only", + hookName: "before_agent_start", + handler: () => undefined, + source: "/tmp/legacy-only/index.ts", + }, + ], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + expect(buildPluginCompatibilityNotices()).toEqual([ + { + pluginId: "hook-only", + code: "hook-only", + severity: "info", + message: + "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + }, + { + pluginId: "legacy-only", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); + }); + + it("returns no compatibility warnings for modern capability plugins", () => { + loadOpenClawPluginsMock.mockReturnValue({ + plugins: [ + { + id: "modern", + name: "Modern", + description: "", + source: "/tmp/modern/index.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: ["modern"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }, + ], + diagnostics: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + tools: [], + hooks: [], + typedHooks: [], + httpRoutes: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + commands: [], + }); + + expect(buildPluginCompatibilityNotices()).toEqual([]); + expect(buildPluginCompatibilityWarnings()).toEqual([]); + }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 5588d6f5874..47a7b7f845e 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -26,6 +26,13 @@ export type PluginInspectShape = | "hybrid-capability" | "non-capability"; +export type PluginCompatibilityNotice = { + pluginId: string; + code: "legacy-before-agent-start" | "hook-only"; + severity: "warn" | "info"; + message: string; +}; + export type PluginInspectReport = { workspaceDir?: string; plugin: PluginRegistry["plugins"][number]; @@ -61,8 +68,34 @@ export type PluginInspectReport = { hasAllowedModelsConfig: boolean; }; usesLegacyBeforeAgentStart: boolean; + compatibility: PluginCompatibilityNotice[]; }; +function buildCompatibilityNoticesForInspect( + inspect: Pick, +): PluginCompatibilityNotice[] { + const warnings: PluginCompatibilityNotice[] = []; + if (inspect.usesLegacyBeforeAgentStart) { + warnings.push({ + pluginId: inspect.plugin.id, + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }); + } + if (inspect.shape === "hook-only") { + warnings.push({ + pluginId: inspect.plugin.id, + code: "hook-only", + severity: "info", + message: + "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + }); + } + return warnings; +} + const log = createSubsystemLogger("plugins"); export function buildPluginStatusReport(params?: { @@ -176,21 +209,30 @@ export function buildPluginInspectReport(params: { const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id); const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id]; const capabilityCount = capabilities.length; + const shape = deriveInspectShape({ + capabilityCount, + typedHookCount: typedHooks.length, + customHookCount: customHooks.length, + toolCount: tools.length, + commandCount: plugin.commands.length, + cliCount: plugin.cliCommands.length, + serviceCount: plugin.services.length, + gatewayMethodCount: plugin.gatewayMethods.length, + httpRouteCount: plugin.httpRoutes, + }); + const usesLegacyBeforeAgentStart = typedHooks.some( + (entry) => entry.name === "before_agent_start", + ); + const compatibility = buildCompatibilityNoticesForInspect({ + plugin, + shape, + usesLegacyBeforeAgentStart, + }); return { workspaceDir: report.workspaceDir, plugin, - shape: deriveInspectShape({ - capabilityCount, - typedHookCount: typedHooks.length, - customHookCount: customHooks.length, - toolCount: tools.length, - commandCount: plugin.commands.length, - cliCount: plugin.cliCommands.length, - serviceCount: plugin.services.length, - gatewayMethodCount: plugin.gatewayMethods.length, - httpRouteCount: plugin.httpRoutes, - }), + shape, capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", capabilityCount, capabilities, @@ -209,7 +251,8 @@ export function buildPluginInspectReport(params: { allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])], hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true, }, - usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"), + usesLegacyBeforeAgentStart, + compatibility, }; } @@ -238,3 +281,23 @@ export function buildAllPluginInspectReports(params?: { ) .filter((entry): entry is PluginInspectReport => entry !== null); } + +export function buildPluginCompatibilityWarnings(params?: { + config?: ReturnType; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + report?: PluginStatusReport; +}): string[] { + return buildAllPluginInspectReports(params).flatMap((inspect) => + inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`), + ); +} + +export function buildPluginCompatibilityNotices(params?: { + config?: ReturnType; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + report?: PluginStatusReport; +}): PluginCompatibilityNotice[] { + return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility); +} diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 0f280244231..ff157287902 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -88,6 +88,7 @@ const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: tru const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); +const buildPluginCompatibilityNotices = vi.hoisted(() => vi.fn(() => [])); vi.mock("../commands/onboard-channels.js", () => ({ setupChannels, @@ -172,6 +173,10 @@ vi.mock("../infra/control-ui-assets.js", () => ({ ensureControlUiAssetsBuilt, })); +vi.mock("../plugins/status.js", () => ({ + buildPluginCompatibilityNotices, +})); + vi.mock("../channels/plugins/index.js", () => ({ listChannelPlugins, })); @@ -398,6 +403,62 @@ describe("runSetupWizard", () => { } }); + it("shows plugin compatibility notices for an existing valid config", async () => { + buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); + readConfigFileSnapshot.mockResolvedValueOnce({ + path: "/tmp/.openclaw/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + resolved: {}, + valid: true, + config: { + gateway: {}, + }, + issues: [], + warnings: [], + legacyIssues: [], + }); + + const note: WizardPrompter["note"] = vi.fn(async () => {}); + const select = vi.fn(async (opts: WizardSelectParams) => { + if (opts.message === "Config handling") { + return "keep"; + } + return "quickstart"; + }) as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ note, select }); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "skip", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true); + expect(calls.some((call) => String(call?.[0] ?? "").includes("legacy-plugin"))).toBe(true); + }); + it("resolves gateway.auth.password SecretRef for local setup probe", async () => { const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-ref-password"; // pragma: allowlist secret diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 6ffa4d9a2d4..92abd51a20e 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -13,6 +13,7 @@ import { writeConfigFile, } from "../config/config.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; +import { buildPluginCompatibilityNotices } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; @@ -102,6 +103,27 @@ export async function runSetupWizard( return; } + const compatibilityNotices = snapshot.valid + ? buildPluginCompatibilityNotices({ config: baseConfig }) + : []; + if (compatibilityNotices.length > 0) { + await prompter.note( + [ + `Detected ${compatibilityNotices.length} plugin compatibility notice${compatibilityNotices.length === 1 ? "" : "s"} in the current config.`, + ...compatibilityNotices + .slice(0, 4) + .map((notice) => `- ${notice.pluginId}: ${notice.message}`), + ...(compatibilityNotices.length > 4 + ? [`- ... +${compatibilityNotices.length - 4} more`] + : []), + "", + `Review: ${formatCliCommand("openclaw doctor")}`, + `Inspect: ${formatCliCommand("openclaw plugins inspect --all")}`, + ].join("\n"), + "Plugin compatibility", + ); + } + const quickstartHint = `Configure details later via ${formatCliCommand("openclaw configure")}.`; const manualHint = "Configure port, network, Tailscale, and auth options."; const explicitFlowRaw = opts.flow?.trim();