fix(plugins): snapshot command registrations

This commit is contained in:
Vincent Koc
2026-06-05 14:47:28 +02:00
parent 42f61219ed
commit cc7b1f9c2c
3 changed files with 131 additions and 7 deletions

View File

@@ -375,7 +375,7 @@ function copyAgentPromptGuidance(
return { ok: true, value: guidance };
}
function snapshotPluginCommandDefinition(
export function snapshotPluginCommandDefinition(
command: OpenClawPluginCommandDefinition,
): CommandSnapshotResult {
if (!isRecord(command)) {

View File

@@ -0,0 +1,112 @@
// Command registration tests cover plugin-owned command definition snapshotting.
import {
createPluginRegistryFixture,
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it } from "vitest";
import {
getPluginCommandEntrySpecsFromRegistrations,
getPluginCommandSpecsFromRegistrations,
} from "../command-specs.js";
import { clearPluginCommands } from "../commands.js";
import { resetPluginRuntimeStateForTest } from "../runtime.js";
import { createPluginRecord } from "../status.test-helpers.js";
import type { OpenClawPluginCommandDefinition } from "../types.js";
describe("plugin command registration", () => {
afterEach(() => {
clearPluginCommands();
resetPluginRuntimeStateForTest();
});
it("snapshots command fields before registry command projection", () => {
let nameReads = 0;
let descriptionReads = 0;
let nativeNamesReads = 0;
let descriptionLocalizationsReads = 0;
let acceptsArgsReads = 0;
let handlerReads = 0;
const handler: OpenClawPluginCommandDefinition["handler"] = async () => ({
text: "ok",
});
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "volatile-command-plugin",
name: "Volatile Command Plugin",
}),
register(api) {
api.registerCommand({
get name() {
nameReads += 1;
if (nameReads > 1) {
throw new Error("command name getter re-read");
}
return " volatile ";
},
get description() {
descriptionReads += 1;
if (descriptionReads > 1) {
throw new Error("command description getter re-read");
}
return " Stable command description. ";
},
get nativeNames() {
nativeNamesReads += 1;
if (nativeNamesReads > 1) {
throw new Error("command nativeNames getter re-read");
}
return { default: "volatile-native" };
},
get descriptionLocalizations() {
descriptionLocalizationsReads += 1;
if (descriptionLocalizationsReads > 1) {
throw new Error("command descriptionLocalizations getter re-read");
}
return { fr: "Commande stable" };
},
get acceptsArgs() {
acceptsArgsReads += 1;
if (acceptsArgsReads > 1) {
throw new Error("command acceptsArgs getter re-read");
}
return true;
},
get handler() {
handlerReads += 1;
if (handlerReads > 1) {
throw new Error("command handler getter re-read");
}
return handler;
},
} as OpenClawPluginCommandDefinition);
},
});
expect(registry.registry.commands[0]?.command.name).toBe(" volatile ");
expect(getPluginCommandEntrySpecsFromRegistrations(registry.registry.commands)).toEqual([
{
name: "volatile",
description: "Stable command description.",
acceptsArgs: true,
nativeName: "volatile-native",
},
]);
expect(getPluginCommandSpecsFromRegistrations(registry.registry.commands)).toEqual([
{
name: "volatile-native",
description: "Stable command description.",
descriptionLocalizations: { fr: "Commande stable" },
acceptsArgs: true,
},
]);
expect(nameReads).toBe(1);
expect(descriptionReads).toBe(1);
expect(nativeNamesReads).toBe(1);
expect(descriptionLocalizationsReads).toBe(1);
expect(acceptsArgsReads).toBe(1);
expect(handlerReads).toBe(1);
});
});

View File

@@ -65,6 +65,7 @@ import type { CodexAppServerExtensionFactory } from "./codex-app-server-extensio
import {
isReservedCommandName,
registerPluginCommand,
snapshotPluginCommandDefinition,
validatePluginCommandDefinition,
} from "./command-registration.js";
import { clearPluginCommandsForPlugin, pluginCommands } from "./command-registry-state.js";
@@ -2008,7 +2009,18 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
};
const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => {
const name = command.name.trim();
const snapshot = snapshotPluginCommandDefinition(command);
if (!snapshot.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `command registration failed: ${snapshot.error}`,
});
return;
}
const commandSnapshot = snapshot.command;
const name = commandSnapshot.name.trim();
if (!name) {
pushDiagnostic({
level: "error",
@@ -2018,7 +2030,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
const allowReservedCommandNames = command.ownership === "reserved";
const allowReservedCommandNames = commandSnapshot.ownership === "reserved";
if (allowReservedCommandNames && !canClaimReservedCommandOwnership(record)) {
pushDiagnostic({
level: "error",
@@ -2054,7 +2066,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
// snapshot registries are isolated and never write to the global command table. Conflicts
// will surface when the plugin is loaded via the normal activation path at gateway startup.
if (!registryParams.activateGlobalSideEffects) {
const validationError = validatePluginCommandDefinition(command, {
const validationError = validatePluginCommandDefinition(commandSnapshot, {
allowReservedCommandNames,
});
if (validationError) {
@@ -2067,11 +2079,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
return;
}
} else {
const { ownership: _ownership, ...commandForRegistration } = command;
const { ownership: _ownership, ...commandForRegistration } = commandSnapshot;
void _ownership;
const result = registerPluginCommand(
record.id,
allowReservedCommandNames ? commandForRegistration : command,
allowReservedCommandNames ? commandForRegistration : commandSnapshot,
{
pluginName: record.name,
pluginRoot: record.rootDir,
@@ -2100,7 +2112,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registry.commands.push({
pluginId: record.id,
pluginName: record.name,
command,
command: commandSnapshot,
source: record.source,
rootDir: record.rootDir,
});