Plugins: surface compatibility notices

This commit is contained in:
Vincent Koc
2026-03-17 19:58:40 -07:00
parent 6b9b32a160
commit 27d4fdf3bb
16 changed files with 701 additions and 16 deletions

View File

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

View File

@@ -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<PluginInspectReport, "plugin" | "shape" | "usesLegacyBeforeAgentStart">,
): 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<typeof loadConfig>;
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<typeof loadConfig>;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
report?: PluginStatusReport;
}): PluginCompatibilityNotice[] {
return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility);
}