| import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| import { setActivePluginRegistry } from "../plugins/runtime.js"; |
| import { 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, 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("does not enable restricted commands from inherited flags", () => { |
| const inheritedCommands = Object.create({ |
| config: 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 === "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, 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("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", () => { |
| 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("normalizes dock command aliases", () => { |
| 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(); |
| }); |
| }); |
|
|