refactor(plugins): track activation compat hints

This commit is contained in:
Vincent Koc
2026-04-23 20:59:07 -07:00
parent 76a4c167f7
commit b1d0c14d38
8 changed files with 374 additions and 106 deletions

View File

@@ -9,10 +9,14 @@ vi.mock("./manifest-registry.js", () => ({
}));
let resolveManifestActivationPluginIds: typeof import("./activation-planner.js").resolveManifestActivationPluginIds;
let resolveManifestActivationPlan: typeof import("./activation-planner.js").resolveManifestActivationPlan;
let PLUGIN_COMPAT_REASON: typeof import("./compat-reasons.js").PLUGIN_COMPAT_REASON;
describe("resolveManifestActivationPluginIds", () => {
beforeAll(async () => {
({ resolveManifestActivationPluginIds } = await import("./activation-planner.js"));
({ resolveManifestActivationPluginIds, resolveManifestActivationPlan } =
await import("./activation-planner.js"));
({ PLUGIN_COMPAT_REASON } = await import("./compat-reasons.js"));
});
beforeEach(() => {
@@ -70,6 +74,20 @@ describe("resolveManifestActivationPluginIds", () => {
},
origin: "workspace",
},
{
id: "legacy-activation-only",
providers: [],
activation: {
onProviders: ["legacy-provider"],
onChannels: ["legacy-channel"],
onCapabilities: ["tool"],
},
channels: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "workspace",
},
],
diagnostics: [],
});
@@ -168,7 +186,7 @@ describe("resolveManifestActivationPluginIds", () => {
capability: "tool",
},
}),
).toEqual(["demo-channel"]);
).toEqual(["demo-channel", "legacy-activation-only"]);
expect(
resolveManifestActivationPluginIds({
@@ -191,4 +209,96 @@ describe("resolveManifestActivationPluginIds", () => {
}),
).toEqual([]);
});
it("reports legacy activation field compat reasons without changing plugin-id resolution", () => {
expect(
resolveManifestActivationPlan({
trigger: {
kind: "command",
command: "demo-tools",
},
}),
).toEqual({
pluginIds: ["demo-channel"],
entries: [
{
pluginId: "demo-channel",
reasons: ["command:demo-tools"],
compatReasons: [PLUGIN_COMPAT_REASON.legacyActivationField],
},
],
compatReasons: {
"demo-channel": [PLUGIN_COMPAT_REASON.legacyActivationField],
},
});
expect(
resolveManifestActivationPlan({
trigger: {
kind: "provider",
provider: "legacy-provider",
},
}).compatReasons,
).toEqual({
"legacy-activation-only": [PLUGIN_COMPAT_REASON.legacyActivationField],
});
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "provider",
provider: "legacy-provider",
},
}),
).toEqual(["legacy-activation-only"]);
});
it("does not report compat reasons for stable ownership metadata", () => {
expect(
resolveManifestActivationPlan({
trigger: {
kind: "provider",
provider: "openai",
},
}),
).toEqual({
pluginIds: ["openai"],
entries: [
{
pluginId: "openai",
reasons: ["provider:openai"],
compatReasons: [],
},
],
compatReasons: {},
});
});
it("reports legacy activation capability hints separately from stable capabilities", () => {
expect(
resolveManifestActivationPlan({
trigger: {
kind: "capability",
capability: "tool",
},
}),
).toEqual({
pluginIds: ["demo-channel", "legacy-activation-only"],
entries: [
{
pluginId: "demo-channel",
reasons: ["capability:tool"],
compatReasons: [],
},
{
pluginId: "legacy-activation-only",
reasons: ["capability:tool"],
compatReasons: [PLUGIN_COMPAT_REASON.legacyActivationField],
},
],
compatReasons: {
"legacy-activation-only": [PLUGIN_COMPAT_REASON.legacyActivationField],
},
});
});
});

View File

@@ -1,6 +1,7 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { PLUGIN_COMPAT_REASON, type PluginCompatReason } from "./compat-reasons.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import type { PluginManifestActivationCapability } from "./manifest.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
@@ -14,6 +15,18 @@ export type PluginActivationPlannerTrigger =
| { kind: "route"; route: string }
| { kind: "capability"; capability: PluginManifestActivationCapability };
export type PluginActivationPlanEntry = {
pluginId: string;
reasons: string[];
compatReasons: PluginCompatReason[];
};
export type PluginActivationPlan = {
pluginIds: string[];
entries: PluginActivationPlanEntry[];
compatReasons: Record<string, PluginCompatReason[]>;
};
export function resolveManifestActivationPluginIds(params: {
trigger: PluginActivationPlannerTrigger;
config?: OpenClawConfig;
@@ -23,101 +36,218 @@ export function resolveManifestActivationPluginIds(params: {
origin?: PluginOrigin;
onlyPluginIds?: readonly string[];
}): string[] {
const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds));
return [
...new Set(
loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
})
.plugins.filter(
(plugin) =>
(!params.origin || plugin.origin === params.origin) &&
(!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) &&
matchesManifestActivationTrigger(plugin, params.trigger),
)
.map((plugin) => plugin.id),
),
].toSorted((left, right) => left.localeCompare(right));
return resolveManifestActivationPlan(params).pluginIds;
}
function matchesManifestActivationTrigger(
export function resolveManifestActivationPlan(params: {
trigger: PluginActivationPlannerTrigger;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
cache?: boolean;
origin?: PluginOrigin;
onlyPluginIds?: readonly string[];
}): PluginActivationPlan {
const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds));
const entries = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
})
.plugins.flatMap((plugin): PluginActivationPlanEntry[] => {
if (
(params.origin && plugin.origin !== params.origin) ||
(onlyPluginIdSet && !onlyPluginIdSet.has(plugin.id))
) {
return [];
}
const match = matchManifestActivationTrigger(plugin, params.trigger);
if (!match) {
return [];
}
return [
{
pluginId: plugin.id,
reasons: [match.reason],
compatReasons: match.compatReason ? [match.compatReason] : [],
},
];
})
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
return {
pluginIds: entries.map((entry) => entry.pluginId),
entries,
compatReasons: Object.fromEntries(
entries.flatMap((entry) =>
entry.compatReasons.length > 0 ? [[entry.pluginId, entry.compatReasons]] : [],
),
),
};
}
type ManifestActivationMatch = {
reason: string;
compatReason?: PluginCompatReason;
};
function matchManifestActivationTrigger(
plugin: PluginManifestRecord,
trigger: PluginActivationPlannerTrigger,
): boolean {
): ManifestActivationMatch | null {
switch (trigger.kind) {
case "command":
return listActivationCommandIds(plugin).includes(normalizeCommandId(trigger.command));
return matchActivationCommand(plugin, trigger.command);
case "provider":
return listActivationProviderIds(plugin).includes(normalizeProviderId(trigger.provider));
return matchActivationProvider(plugin, trigger.provider);
case "agentHarness":
return listActivationAgentHarnessIds(plugin).includes(normalizeCommandId(trigger.runtime));
return matchLegacyActivationList(
listActivationAgentHarnessIds(plugin),
normalizeCommandId(trigger.runtime),
"agent-harness",
);
case "channel":
return listActivationChannelIds(plugin).includes(normalizeCommandId(trigger.channel));
return matchActivationChannel(plugin, trigger.channel);
case "route":
return listActivationRouteIds(plugin).includes(normalizeCommandId(trigger.route));
return matchLegacyActivationList(
listActivationRouteIds(plugin),
normalizeCommandId(trigger.route),
"route",
);
case "capability":
return hasActivationCapability(plugin, trigger.capability);
return matchActivationCapability(plugin, trigger.capability);
}
const unreachableTrigger: never = trigger;
return unreachableTrigger;
}
function matchLegacyActivationList(
ids: readonly string[],
normalizedId: string,
reasonPrefix: string,
): ManifestActivationMatch | null {
if (!normalizedId || !ids.includes(normalizedId)) {
return null;
}
return {
reason: `${reasonPrefix}:${normalizedId}`,
compatReason: PLUGIN_COMPAT_REASON.legacyActivationField,
};
}
function listActivationAgentHarnessIds(plugin: PluginManifestRecord): string[] {
return [...(plugin.activation?.onAgentHarnesses ?? [])].map(normalizeCommandId).filter(Boolean);
}
function listActivationCommandIds(plugin: PluginManifestRecord): string[] {
return [
...(plugin.activation?.onCommands ?? []),
...(plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name),
]
function matchActivationCommand(
plugin: PluginManifestRecord,
command: string,
): ManifestActivationMatch | null {
const normalizedCommand = normalizeCommandId(command);
if (!normalizedCommand) {
return null;
}
const commandAliases = (plugin.commandAliases ?? [])
.flatMap((alias) => alias.cliCommand ?? alias.name)
.map(normalizeCommandId)
.filter(Boolean);
if (commandAliases.includes(normalizedCommand)) {
return { reason: `command:${normalizedCommand}` };
}
return matchLegacyActivationList(
(plugin.activation?.onCommands ?? []).map(normalizeCommandId).filter(Boolean),
normalizedCommand,
"command",
);
}
function listActivationProviderIds(plugin: PluginManifestRecord): string[] {
return [
...(plugin.activation?.onProviders ?? []),
function matchActivationProvider(
plugin: PluginManifestRecord,
provider: string,
): ManifestActivationMatch | null {
const normalizedProvider = normalizeProviderId(provider);
if (!normalizedProvider) {
return null;
}
const stableProviderIds = [
...plugin.providers,
...(plugin.setup?.providers?.map((provider) => provider.id) ?? []),
...(plugin.setup?.providers?.map((entry) => entry.id) ?? []),
]
.map((value) => normalizeProviderId(value))
.filter(Boolean);
if (stableProviderIds.includes(normalizedProvider)) {
return { reason: `provider:${normalizedProvider}` };
}
return matchLegacyActivationList(
(plugin.activation?.onProviders ?? [])
.map((value) => normalizeProviderId(value))
.filter(Boolean),
normalizedProvider,
"provider",
);
}
function listActivationChannelIds(plugin: PluginManifestRecord): string[] {
return [...(plugin.activation?.onChannels ?? []), ...plugin.channels]
.map(normalizeCommandId)
.filter(Boolean);
function matchActivationChannel(
plugin: PluginManifestRecord,
channel: string,
): ManifestActivationMatch | null {
const normalizedChannel = normalizeCommandId(channel);
if (!normalizedChannel) {
return null;
}
const stableChannelIds = plugin.channels.map(normalizeCommandId).filter(Boolean);
if (stableChannelIds.includes(normalizedChannel)) {
return { reason: `channel:${normalizedChannel}` };
}
return matchLegacyActivationList(
(plugin.activation?.onChannels ?? []).map(normalizeCommandId).filter(Boolean),
normalizedChannel,
"channel",
);
}
function listActivationRouteIds(plugin: PluginManifestRecord): string[] {
return (plugin.activation?.onRoutes ?? []).map(normalizeCommandId).filter(Boolean);
}
function hasActivationCapability(
function matchActivationCapability(
plugin: PluginManifestRecord,
capability: PluginManifestActivationCapability,
): boolean {
if (plugin.activation?.onCapabilities?.includes(capability)) {
return true;
}
): ManifestActivationMatch | null {
switch (capability) {
case "provider":
return listActivationProviderIds(plugin).length > 0;
case "provider": {
const hasProviderOwnership =
plugin.providers.length > 0 || (plugin.setup?.providers?.length ?? 0) > 0;
if (hasProviderOwnership) {
return { reason: "capability:provider" };
}
break;
}
case "channel":
return listActivationChannelIds(plugin).length > 0;
if (plugin.channels.length > 0) {
return { reason: "capability:channel" };
}
break;
case "tool":
return (plugin.contracts?.tools?.length ?? 0) > 0;
if ((plugin.contracts?.tools?.length ?? 0) > 0) {
return { reason: "capability:tool" };
}
break;
case "hook":
return plugin.hooks.length > 0;
if (plugin.hooks.length > 0) {
return { reason: "capability:hook" };
}
break;
}
const unreachableCapability: never = capability;
return unreachableCapability;
if (plugin.activation?.onCapabilities?.includes(capability)) {
return {
reason: `capability:${capability}`,
compatReason: PLUGIN_COMPAT_REASON.legacyActivationField,
};
}
return null;
}
function normalizeCommandId(value: string | undefined): string {

View File

@@ -0,0 +1,10 @@
export const PLUGIN_COMPAT_REASON = {
legacyActivationField: "legacy-activation-field",
legacySetupApi: "legacy-setup-api",
legacyRootSdkImport: "legacy-root-sdk-import",
legacyGlobalRegistry: "legacy-global-registry",
legacyManifestOwnerFallback: "legacy-manifest-owner-fallback",
legacyHookStage: "legacy-hook-stage",
} as const;
export type PluginCompatReason = (typeof PLUGIN_COMPAT_REASON)[keyof typeof PLUGIN_COMPAT_REASON];

View File

@@ -57,19 +57,19 @@ export type PluginManifestActivationCapability = "provider" | "channel" | "tool"
export type PluginManifestActivation = {
/**
* Provider ids that should activate this plugin when explicitly requested.
* This is metadata only; runtime loading still happens through the loader.
* Legacy provider activation hints. Prefer top-level `providers` and setup
* provider ownership for new manifests.
*/
onProviders?: string[];
/** Agent harness runtime ids that should activate this plugin. */
/** Legacy agent harness runtime activation hints. */
onAgentHarnesses?: string[];
/** Command ids that should activate this plugin. */
/** Legacy command activation hints. Prefer command aliases or CLI descriptors. */
onCommands?: string[];
/** Channel ids that should activate this plugin. */
/** Legacy channel activation hints. Prefer top-level `channels`. */
onChannels?: string[];
/** Route kinds that should activate this plugin. */
/** Legacy route activation hints. */
onRoutes?: string[];
/** Cheap capability hints used by future activation planning. */
/** Legacy broad capability hints. Do not add new uses. */
onCapabilities?: PluginManifestActivationCapability[];
};