Files
openclaw/src/auto-reply/commands-registry.test.ts
2026-03-22 23:37:31 -07:00

445 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import {
buildCommandText,
buildCommandTextFromArgs,
findCommandByNativeName,
getCommandDetection,
listChatCommands,
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
normalizeCommandBody,
parseCommandArgs,
resolveCommandArgChoices,
resolveCommandArgMenu,
serializeCommandArgs,
shouldHandleTextCommands,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
describe("commands registry", () => {
it("builds command text with args", () => {
expect(buildCommandText("status")).toBe("/status");
expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5");
expect(buildCommandText("models")).toBe("/models");
});
it("exposes native specs", () => {
const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
expect(specs.find((spec) => spec.name === "skill")).toBeTruthy();
expect(specs.find((spec) => spec.name === "whoami")).toBeTruthy();
expect(specs.find((spec) => spec.name === "compact")).toBeTruthy();
});
it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({
commands: { config: false, plugins: false, debug: false },
});
expect(disabled.find((spec) => spec.key === "config")).toBeFalsy();
expect(disabled.find((spec) => spec.key === "plugins")).toBeFalsy();
expect(disabled.find((spec) => spec.key === "debug")).toBeFalsy();
const enabled = listChatCommandsForConfig({
commands: { config: true, plugins: true, debug: true },
});
expect(enabled.find((spec) => spec.key === "config")).toBeTruthy();
expect(enabled.find((spec) => spec.key === "plugins")).toBeTruthy();
expect(enabled.find((spec) => spec.key === "debug")).toBeTruthy();
const nativeDisabled = listNativeCommandSpecsForConfig({
commands: { config: false, plugins: false, debug: false, native: true },
});
expect(nativeDisabled.find((spec) => spec.name === "config")).toBeFalsy();
expect(nativeDisabled.find((spec) => spec.name === "plugins")).toBeFalsy();
expect(nativeDisabled.find((spec) => spec.name === "debug")).toBeFalsy();
});
it("does not enable restricted commands from inherited flags", () => {
const inheritedCommands = Object.create({
config: true,
plugins: true,
debug: true,
bash: true,
}) as Record<string, unknown>;
const commands = listChatCommandsForConfig({
commands: inheritedCommands as never,
});
expect(commands.find((spec) => spec.key === "config")).toBeFalsy();
expect(commands.find((spec) => spec.key === "plugins")).toBeFalsy();
expect(commands.find((spec) => spec.key === "debug")).toBeFalsy();
expect(commands.find((spec) => spec.key === "bash")).toBeFalsy();
});
it("appends skill commands when provided", () => {
const skillCommands = [
{
name: "demo_skill",
skillName: "demo-skill",
description: "Demo skill",
},
];
const commands = listChatCommandsForConfig(
{
commands: { config: false, plugins: false, debug: false },
},
{ skillCommands },
);
expect(commands.find((spec) => spec.nativeName === "demo_skill")).toBeTruthy();
const native = listNativeCommandSpecsForConfig(
{ commands: { config: false, plugins: false, debug: false, native: true } },
{ skillCommands },
);
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("applies provider-specific native names", () => {
const native = listNativeCommandSpecsForConfig(
{ commands: { native: true } },
{ provider: "discord" },
);
expect(native.find((spec) => spec.name === "voice")).toBeTruthy();
expect(findCommandByNativeName("voice", "discord")?.key).toBe("tts");
expect(findCommandByNativeName("tts", "discord")).toBeUndefined();
});
it("renames status to agentstatus for slack", () => {
const native = listNativeCommandSpecsForConfig(
{ commands: { native: true } },
{ provider: "slack" },
);
expect(native.find((spec) => spec.name === "agentstatus")).toBeTruthy();
expect(native.find((spec) => spec.name === "status")).toBeFalsy();
expect(findCommandByNativeName("agentstatus", "slack")?.key).toBe("status");
expect(findCommandByNativeName("status", "slack")).toBeUndefined();
});
it("keeps discord native command specs within slash-command limits", () => {
const cfg = { commands: { native: true } };
const native = listNativeCommandSpecsForConfig(cfg, { provider: "discord" });
for (const spec of native) {
expect(spec.name).toMatch(/^[a-z0-9_-]{1,32}$/);
expect(spec.description.length).toBeGreaterThan(0);
expect(spec.description.length).toBeLessThanOrEqual(100);
expect(spec.args?.length ?? 0).toBeLessThanOrEqual(25);
const command = findCommandByNativeName(spec.name, "discord");
expect(command).toBeTruthy();
const args = command?.args ?? spec.args ?? [];
const argNames = new Set<string>();
let sawOptional = false;
for (const arg of args) {
expect(argNames.has(arg.name)).toBe(false);
argNames.add(arg.name);
const isRequired = arg.required ?? false;
if (!isRequired) {
sawOptional = true;
} else {
expect(sawOptional).toBe(false);
}
expect(arg.name).toMatch(/^[a-z0-9_-]{1,32}$/);
expect(arg.description.length).toBeGreaterThan(0);
expect(arg.description.length).toBeLessThanOrEqual(100);
if (!command) {
continue;
}
const choices = resolveCommandArgChoices({
command,
arg,
cfg,
provider: "discord",
});
if (choices.length === 0) {
continue;
}
expect(choices.length).toBeLessThanOrEqual(25);
for (const choice of choices) {
expect(choice.label.length).toBeGreaterThan(0);
expect(choice.label.length).toBeLessThanOrEqual(100);
expect(choice.value.length).toBeGreaterThan(0);
expect(choice.value.length).toBeLessThanOrEqual(100);
}
}
}
});
it("keeps ACP native action choices aligned with implemented handlers", () => {
const acp = listChatCommands().find((command) => command.key === "acp");
expect(acp).toBeTruthy();
const actionArg = acp?.args?.find((arg) => arg.name === "action");
expect(actionArg?.choices).toEqual([
"spawn",
"cancel",
"steer",
"close",
"sessions",
"status",
"set-mode",
"set",
"cwd",
"permissions",
"timeout",
"model",
"reset-options",
"doctor",
"install",
"help",
]);
});
it("registers fast mode as a first-class options command", () => {
const fast = listChatCommands().find((command) => command.key === "fast");
expect(fast).toMatchObject({
nativeName: "fast",
textAliases: ["/fast"],
category: "options",
});
const modeArg = fast?.args?.find((arg) => arg.name === "mode");
expect(modeArg?.choices).toEqual(["status", "on", "off"]);
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);
expect(detection.exact.has("/skill")).toBe(true);
expect(detection.exact.has("/compact")).toBe(true);
expect(detection.exact.has("/whoami")).toBe(true);
expect(detection.exact.has("/id")).toBe(true);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
expect(detection.exact.has(alias.toLowerCase())).toBe(true);
expect(detection.regex.test(alias)).toBe(true);
expect(detection.regex.test(`${alias}:`)).toBe(true);
if (command.acceptsArgs) {
expect(detection.regex.test(`${alias} list`)).toBe(true);
expect(detection.regex.test(`${alias}: list`)).toBe(true);
} else {
expect(detection.regex.test(`${alias} list`)).toBe(false);
expect(detection.regex.test(`${alias}: list`)).toBe(false);
}
}
}
expect(detection.regex.test("try /status")).toBe(false);
});
it("respects text command gating", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
plugin: createChannelTestPluginBase({
id: "discord",
capabilities: { nativeCommands: true, chatTypes: ["direct"] },
}),
source: "test",
},
]),
);
const cfg = { commands: { text: false } };
expect(
shouldHandleTextCommands({
cfg,
surface: "discord",
commandSource: "text",
}),
).toBe(false);
expect(
shouldHandleTextCommands({
cfg,
surface: "whatsapp",
commandSource: "text",
}),
).toBe(true);
expect(
shouldHandleTextCommands({
cfg,
surface: "discord",
commandSource: "native",
}),
).toBe(true);
});
it("normalizes telegram-style command mentions for the current bot", () => {
expect(normalizeCommandBody("/help@openclaw", { botUsername: "openclaw" })).toBe("/help");
expect(
normalizeCommandBody("/help@openclaw args", {
botUsername: "openclaw",
}),
).toBe("/help args");
expect(
normalizeCommandBody("/help@openclaw: args", {
botUsername: "openclaw",
}),
).toBe("/help args");
});
it("keeps telegram-style command mentions for other bots", () => {
expect(normalizeCommandBody("/help@otherbot", { botUsername: "openclaw" })).toBe(
"/help@otherbot",
);
});
it("keeps unregistered dock underscore aliases unchanged", () => {
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock_telegram");
});
});
describe("commands registry args", () => {
function createUsageModeCommand(
argsParsing: ChatCommandDefinition["argsParsing"] = "positional",
description = "mode",
): ChatCommandDefinition {
return {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing,
args: [
{
name: "mode",
description,
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
}
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
description: "debug",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [
{ name: "action", description: "action", type: "string" },
{ name: "path", description: "path", type: "string" },
{ name: "value", description: "value", type: "string", captureRemaining: true },
],
};
const args = parseCommandArgs(command, "set foo bar baz");
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
});
it("serializes args via raw first, then values", () => {
const command: ChatCommandDefinition = {
key: "model",
description: "model",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
};
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"gpt-5.2-codex",
);
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"/model gpt-5.2-codex",
);
});
it("resolves auto arg menus when missing a choice arg", () => {
const command = createUsageModeCommand();
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual([
{ label: "off", value: "off" },
{ label: "tokens", value: "tokens" },
{ label: "full", value: "full" },
{ label: "cost", value: "cost" },
]);
});
it("does not show menus when arg already provided", () => {
const command = createUsageModeCommand();
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
});
it("resolves function-based choices with a default provider/model context", () => {
let seen: {
provider?: string;
model?: string;
commandKey: string;
argName: string;
} | null = null;
const command: ChatCommandDefinition = {
key: "think",
description: "think",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "level",
description: "level",
type: "string",
choices: ({ provider, model, command, arg }) => {
seen = { provider, model, commandKey: command.key, argName: arg.name };
return ["low", "high"];
},
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual([
{ label: "low", value: "low" },
{ label: "high", value: "high" },
]);
const seenChoice = seen as {
provider?: string;
model?: string;
commandKey: string;
argName: string;
} | null;
expect(seenChoice?.commandKey).toBe("think");
expect(seenChoice?.argName).toBe("level");
expect(seenChoice?.provider).toBeTruthy();
expect(seenChoice?.model).toBeTruthy();
});
it("does not show menus when args were provided as raw text only", () => {
const command = createUsageModeCommand("none", "on or off");
const menu = resolveCommandArgMenu({
command,
args: { raw: "on" },
cfg: {} as never,
});
expect(menu).toBeNull();
});
});