320 lines
9.9 KiB
TypeScript
320 lines
9.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildCommandText,
|
|
buildCommandTextFromArgs,
|
|
findCommandByNativeName,
|
|
getCommandDetection,
|
|
listChatCommands,
|
|
listChatCommandsForConfig,
|
|
listNativeCommandSpecs,
|
|
listNativeCommandSpecsForConfig,
|
|
normalizeCommandBody,
|
|
parseCommandArgs,
|
|
resolveCommandArgMenu,
|
|
serializeCommandArgs,
|
|
shouldHandleTextCommands,
|
|
} from "./commands-registry.js";
|
|
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import { createTestRegistry } from "../test-utils/channel-plugins.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")).toBeFalsy();
|
|
});
|
|
|
|
it("filters commands based on config flags", () => {
|
|
const disabled = listChatCommandsForConfig({
|
|
commands: { config: false, debug: false },
|
|
});
|
|
expect(disabled.find((spec) => spec.key === "config")).toBeFalsy();
|
|
expect(disabled.find((spec) => spec.key === "debug")).toBeFalsy();
|
|
|
|
const enabled = listChatCommandsForConfig({
|
|
commands: { config: true, debug: true },
|
|
});
|
|
expect(enabled.find((spec) => spec.key === "config")).toBeTruthy();
|
|
expect(enabled.find((spec) => spec.key === "debug")).toBeTruthy();
|
|
|
|
const nativeDisabled = listNativeCommandSpecsForConfig({
|
|
commands: { config: false, debug: false, native: true },
|
|
});
|
|
expect(nativeDisabled.find((spec) => spec.name === "config")).toBeFalsy();
|
|
expect(nativeDisabled.find((spec) => spec.name === "debug")).toBeFalsy();
|
|
});
|
|
|
|
it("appends skill commands when provided", () => {
|
|
const skillCommands = [
|
|
{
|
|
name: "demo_skill",
|
|
skillName: "demo-skill",
|
|
description: "Demo skill",
|
|
},
|
|
];
|
|
const commands = listChatCommandsForConfig(
|
|
{
|
|
commands: { config: false, debug: false },
|
|
},
|
|
{ skillCommands },
|
|
);
|
|
expect(commands.find((spec) => spec.nativeName === "demo_skill")).toBeTruthy();
|
|
|
|
const native = listNativeCommandSpecsForConfig(
|
|
{ commands: { config: 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("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", () => {
|
|
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@clawdbot", { botUsername: "clawdbot" })).toBe("/help");
|
|
expect(
|
|
normalizeCommandBody("/help@clawdbot args", {
|
|
botUsername: "clawdbot",
|
|
}),
|
|
).toBe("/help args");
|
|
expect(
|
|
normalizeCommandBody("/help@clawdbot: args", {
|
|
botUsername: "clawdbot",
|
|
}),
|
|
).toBe("/help args");
|
|
});
|
|
|
|
it("keeps telegram-style command mentions for other bots", () => {
|
|
expect(normalizeCommandBody("/help@otherbot", { botUsername: "clawdbot" })).toBe(
|
|
"/help@otherbot",
|
|
);
|
|
});
|
|
|
|
it("normalizes dock command aliases", () => {
|
|
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram");
|
|
});
|
|
});
|
|
|
|
describe("commands registry args", () => {
|
|
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: ChatCommandDefinition = {
|
|
key: "usage",
|
|
description: "usage",
|
|
textAliases: [],
|
|
scope: "both",
|
|
argsMenu: "auto",
|
|
argsParsing: "positional",
|
|
args: [
|
|
{
|
|
name: "mode",
|
|
description: "mode",
|
|
type: "string",
|
|
choices: ["off", "tokens", "full", "cost"],
|
|
},
|
|
],
|
|
};
|
|
|
|
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
|
expect(menu?.arg.name).toBe("mode");
|
|
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
|
});
|
|
|
|
it("does not show menus when arg already provided", () => {
|
|
const command: ChatCommandDefinition = {
|
|
key: "usage",
|
|
description: "usage",
|
|
textAliases: [],
|
|
scope: "both",
|
|
argsMenu: "auto",
|
|
argsParsing: "positional",
|
|
args: [
|
|
{
|
|
name: "mode",
|
|
description: "mode",
|
|
type: "string",
|
|
choices: ["off", "tokens", "full", "cost"],
|
|
},
|
|
],
|
|
};
|
|
|
|
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(["low", "high"]);
|
|
expect(seen?.commandKey).toBe("think");
|
|
expect(seen?.argName).toBe("level");
|
|
expect(seen?.provider).toBeTruthy();
|
|
expect(seen?.model).toBeTruthy();
|
|
});
|
|
|
|
it("does not show menus when args were provided as raw text only", () => {
|
|
const command: ChatCommandDefinition = {
|
|
key: "usage",
|
|
description: "usage",
|
|
textAliases: [],
|
|
scope: "both",
|
|
argsMenu: "auto",
|
|
argsParsing: "none",
|
|
args: [
|
|
{
|
|
name: "mode",
|
|
description: "on or off",
|
|
type: "string",
|
|
choices: ["off", "tokens", "full", "cost"],
|
|
},
|
|
],
|
|
};
|
|
|
|
const menu = resolveCommandArgMenu({
|
|
command,
|
|
args: { raw: "on" },
|
|
cfg: {} as never,
|
|
});
|
|
expect(menu).toBeNull();
|
|
});
|
|
});
|