mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 19:21:08 +00:00
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ChatCommandDefinition } from "../../auto-reply/commands-registry.types.js";
|
|
|
|
const mockSkillCommands = [
|
|
{
|
|
skillName: "code-review",
|
|
name: "code_review",
|
|
description: "Run code review",
|
|
acceptsArgs: true,
|
|
},
|
|
];
|
|
|
|
const mockChatCommands: ChatCommandDefinition[] = [
|
|
{
|
|
key: "model",
|
|
nativeName: "model",
|
|
description: "Set model",
|
|
textAliases: ["/model", "/m"],
|
|
acceptsArgs: true,
|
|
args: [
|
|
{
|
|
name: "model",
|
|
description: "Model identifier",
|
|
type: "string",
|
|
choices: [{ value: "gpt-5.4", label: "GPT-5.4" }, "sonnet-4.6"],
|
|
},
|
|
],
|
|
scope: "both",
|
|
category: "options",
|
|
},
|
|
{
|
|
key: "help",
|
|
nativeName: "help",
|
|
description: "Show help",
|
|
textAliases: ["/help"],
|
|
scope: "both",
|
|
category: "session",
|
|
},
|
|
{
|
|
key: "commands",
|
|
description: "List commands",
|
|
textAliases: ["/commands"],
|
|
scope: "text",
|
|
category: "session",
|
|
},
|
|
{
|
|
key: "skill:code-review",
|
|
nativeName: "code_review",
|
|
description: "Run code review",
|
|
textAliases: ["/code_review"],
|
|
acceptsArgs: true,
|
|
scope: "both",
|
|
category: "tools",
|
|
},
|
|
{
|
|
key: "debug_prompt",
|
|
nativeName: "debug_prompt",
|
|
description: "Show raw prompt",
|
|
textAliases: ["/debug"],
|
|
acceptsArgs: false,
|
|
args: [
|
|
{
|
|
name: "target",
|
|
description: "Prompt target",
|
|
type: "string",
|
|
choices: () => [{ value: "last", label: "Last" }],
|
|
},
|
|
],
|
|
scope: "native",
|
|
category: "tools",
|
|
},
|
|
];
|
|
|
|
const mockPluginSpecs = [{ name: "tts", description: "Text to speech", acceptsArgs: false }];
|
|
|
|
vi.mock("../../auto-reply/commands-registry.js", () => ({
|
|
listChatCommandsForConfig: vi.fn(() => mockChatCommands),
|
|
}));
|
|
vi.mock("../../auto-reply/skill-commands.js", () => ({
|
|
listSkillCommandsForAgents: vi.fn(() => mockSkillCommands),
|
|
}));
|
|
vi.mock("../../plugins/command-registry-state.js", () => ({
|
|
getPluginCommandSpecs: vi.fn((provider?: string) => {
|
|
if (provider === "whatsapp") {
|
|
return [];
|
|
}
|
|
if (provider === "discord") {
|
|
return [{ name: "discord_tts", description: "Text to speech", acceptsArgs: false }];
|
|
}
|
|
return mockPluginSpecs;
|
|
}),
|
|
}));
|
|
vi.mock("../../plugins/commands.js", () => ({
|
|
listPluginCommands: vi.fn(() => [
|
|
{
|
|
name: "tts",
|
|
description: "Text to speech",
|
|
pluginId: "plugin-tts",
|
|
acceptsArgs: false,
|
|
},
|
|
]),
|
|
}));
|
|
vi.mock("../../config/config.js", () => ({
|
|
loadConfig: vi.fn(() => ({})),
|
|
}));
|
|
vi.mock("../../agents/agent-scope.js", () => ({
|
|
listAgentIds: vi.fn(() => ["main", "dev"]),
|
|
resolveDefaultAgentId: vi.fn(() => "main"),
|
|
}));
|
|
vi.mock("../../channels/plugins/index.js", () => ({
|
|
getChannelPlugin: vi.fn((provider: string) => {
|
|
if (provider === "discord") {
|
|
return {
|
|
commands: {
|
|
resolveNativeCommandName: ({
|
|
commandKey,
|
|
defaultName,
|
|
}: {
|
|
commandKey: string;
|
|
defaultName: string;
|
|
}) => {
|
|
if (commandKey === "model") {
|
|
return "set_model";
|
|
}
|
|
return defaultName;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
return undefined;
|
|
}),
|
|
}));
|
|
|
|
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
import { commandsHandlers, buildCommandsListResult } from "./commands.js";
|
|
|
|
function callHandler(params: Record<string, unknown> = {}) {
|
|
let result: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
|
|
const respond = (ok: boolean, payload?: unknown, error?: unknown) => {
|
|
result = { ok, payload, error };
|
|
};
|
|
commandsHandlers["commands.list"]({
|
|
params,
|
|
respond,
|
|
req: {} as never,
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
context: {} as never,
|
|
});
|
|
return result!;
|
|
}
|
|
|
|
describe("commands.list handler", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns all command sources", () => {
|
|
const { ok, payload } = callHandler();
|
|
expect(ok).toBe(true);
|
|
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
|
const sources = new Set(commands.map((c) => c.source));
|
|
expect(sources).toEqual(new Set(["native", "skill", "plugin"]));
|
|
});
|
|
|
|
it("maps native commands with category, scope, and args", () => {
|
|
const { payload } = callHandler();
|
|
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
|
const model = commands.find((c) => c.name === "model");
|
|
expect(model).toMatchObject({
|
|
name: "model",
|
|
nativeName: "model",
|
|
textAliases: ["/model", "/m"],
|
|
description: "Set model",
|
|
category: "options",
|
|
source: "native",
|
|
scope: "both",
|
|
acceptsArgs: true,
|
|
});
|
|
const args = model!.args as Array<Record<string, unknown>>;
|
|
expect(args).toHaveLength(1);
|
|
expect(args[0].choices).toEqual([
|
|
{ value: "gpt-5.4", label: "GPT-5.4" },
|
|
{ value: "sonnet-4.6", label: "sonnet-4.6" },
|
|
]);
|
|
});
|
|
|
|
it("exposes per-command scope", () => {
|
|
const { payload } = callHandler();
|
|
const { commands } = payload as { commands: Array<{ name: string; scope: string }> };
|
|
expect(commands.find((c) => c.name === "model")!.scope).toBe("both");
|
|
expect(commands.find((c) => c.name === "commands")!.scope).toBe("text");
|
|
expect(commands.find((c) => c.name === "debug_prompt")!.scope).toBe("native");
|
|
expect(commands.find((c) => c.name === "tts")!.scope).toBe("both");
|
|
});
|
|
|
|
it("skips args when acceptsArgs is false", () => {
|
|
const { payload } = callHandler();
|
|
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
|
const debug = commands.find((c) => c.name === "debug_prompt");
|
|
expect(debug!.args).toBeUndefined();
|
|
});
|
|
|
|
it("serializes dynamic choices when acceptsArgs is true", () => {
|
|
const debugCmd = mockChatCommands.find((c) => c.key === "debug_prompt")!;
|
|
const saved = debugCmd.acceptsArgs;
|
|
debugCmd.acceptsArgs = true;
|
|
try {
|
|
const { payload } = callHandler();
|
|
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
|
const debug = commands.find((c) => c.name === "debug_prompt");
|
|
const args = debug!.args as Array<Record<string, unknown>>;
|
|
expect(args[0].dynamic).toBe(true);
|
|
expect(args[0].choices).toBeUndefined();
|
|
} finally {
|
|
debugCmd.acceptsArgs = saved;
|
|
}
|
|
});
|
|
|
|
it("identifies skill commands by source", () => {
|
|
const { payload } = callHandler();
|
|
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
|
const skill = commands.find((c) => c.name === "code_review");
|
|
expect(skill).toMatchObject({ source: "skill", category: "tools" });
|
|
});
|
|
|
|
it("always includes plugin commands regardless of scope filter", () => {
|
|
for (const scope of ["native", "text", "both"] as const) {
|
|
const { payload } = callHandler({ scope });
|
|
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
|
expect(commands.some((c) => c.source === "plugin")).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("filters built-in commands by scope=native (excludes text-only)", () => {
|
|
const { payload } = callHandler({ scope: "native" });
|
|
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
|
const builtinNames = commands.filter((c) => c.source !== "plugin").map((c) => c.name);
|
|
expect(builtinNames).not.toContain("commands");
|
|
expect(builtinNames).toContain("model");
|
|
expect(builtinNames).toContain("debug_prompt");
|
|
});
|
|
|
|
it("filters built-in commands by scope=text (excludes native-only)", () => {
|
|
const { payload } = callHandler({ scope: "text" });
|
|
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
|
const builtinNames = commands.filter((c) => c.source !== "plugin").map((c) => c.name);
|
|
expect(builtinNames).toContain("commands");
|
|
expect(builtinNames).not.toContain("debug_prompt");
|
|
});
|
|
|
|
it("resolves provider-specific native names", () => {
|
|
const { payload } = callHandler({ provider: "discord" });
|
|
const { commands } = payload as { commands: Array<{ name: string }> };
|
|
expect(commands.find((c) => c.name === "set_model")).toBeDefined();
|
|
expect(commands.find((c) => c.name === "model")).toBeUndefined();
|
|
});
|
|
|
|
it("normalizes mixed-case provider", () => {
|
|
const { payload } = callHandler({ provider: "Discord" });
|
|
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
|
expect(commands.find((c) => c.name === "set_model")).toBeDefined();
|
|
const plugin = commands.find((c) => c.source === "plugin");
|
|
expect(plugin).toMatchObject({ name: "discord_tts" });
|
|
});
|
|
|
|
it("uses default names without provider", () => {
|
|
const { payload } = callHandler();
|
|
const { commands } = payload as { commands: Array<{ name: string }> };
|
|
expect(commands.find((c) => c.name === "model")).toBeDefined();
|
|
expect(commands.find((c) => c.name === "set_model")).toBeUndefined();
|
|
});
|
|
|
|
it("omits plugin commands when provider lacks nativeCommandsAutoEnabled", () => {
|
|
const { payload } = callHandler({ provider: "whatsapp" });
|
|
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
|
expect(commands.filter((c) => c.source === "plugin")).toEqual([]);
|
|
});
|
|
|
|
it("uses text-surface names when scope=text even with provider-native aliases", () => {
|
|
const { payload } = callHandler({ provider: "discord", scope: "text" });
|
|
const { commands } = payload as {
|
|
commands: Array<{
|
|
name: string;
|
|
nativeName?: string;
|
|
textAliases?: string[];
|
|
source: string;
|
|
}>;
|
|
};
|
|
const model = commands.find((c) => c.source === "native" && c.name === "model");
|
|
expect(model).toMatchObject({
|
|
name: "model",
|
|
nativeName: "set_model",
|
|
textAliases: ["/model", "/m"],
|
|
});
|
|
expect(commands.find((c) => c.name === "set_model")).toBeUndefined();
|
|
});
|
|
|
|
it("keeps plugin text commands visible for scope=text even without native provider support", () => {
|
|
const { payload } = callHandler({ provider: "whatsapp", scope: "text" });
|
|
const { commands } = payload as {
|
|
commands: Array<{
|
|
name: string;
|
|
source: string;
|
|
textAliases?: string[];
|
|
nativeName?: string;
|
|
}>;
|
|
};
|
|
expect(commands.find((c) => c.source === "plugin")).toMatchObject({
|
|
name: "tts",
|
|
textAliases: ["/tts"],
|
|
});
|
|
expect(commands.find((c) => c.source === "plugin")?.nativeName).toBeUndefined();
|
|
});
|
|
|
|
it("keeps plugin text names while exposing provider-native aliases for scope=text", () => {
|
|
const { payload } = callHandler({ provider: "discord", scope: "text" });
|
|
const { commands } = payload as {
|
|
commands: Array<{
|
|
name: string;
|
|
source: string;
|
|
textAliases?: string[];
|
|
nativeName?: string;
|
|
}>;
|
|
};
|
|
expect(commands.find((c) => c.source === "plugin")).toMatchObject({
|
|
name: "tts",
|
|
nativeName: "discord_tts",
|
|
textAliases: ["/tts"],
|
|
});
|
|
});
|
|
|
|
it("returns provider-specific plugin command names", () => {
|
|
const { payload } = callHandler({ provider: "discord" });
|
|
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
|
const plugin = commands.find((c) => c.source === "plugin");
|
|
expect(plugin).toMatchObject({ name: "discord_tts" });
|
|
});
|
|
|
|
it("excludes args when includeArgs=false", () => {
|
|
const { payload } = callHandler({ includeArgs: false });
|
|
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
|
const model = commands.find((c) => c.name === "model");
|
|
expect(model!.args).toBeUndefined();
|
|
});
|
|
|
|
it("rejects unknown agentId", () => {
|
|
const { ok, error } = callHandler({ agentId: "nonexistent" });
|
|
expect(ok).toBe(false);
|
|
expect(error).toEqual(errorShape(ErrorCodes.INVALID_REQUEST, 'unknown agent id "nonexistent"'));
|
|
});
|
|
|
|
it("rejects invalid params", () => {
|
|
const { ok, error } = callHandler({ scope: "invalid" });
|
|
expect(ok).toBe(false);
|
|
expect((error as { code: number }).code).toBe(ErrorCodes.INVALID_REQUEST);
|
|
});
|
|
});
|
|
|
|
describe("buildCommandsListResult", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("is callable independently from handler", () => {
|
|
const result = buildCommandsListResult({ cfg: {} as never, agentId: "main" });
|
|
expect(result.commands.length).toBeGreaterThan(0);
|
|
expect(result.commands.every((c) => typeof c.scope === "string")).toBe(true);
|
|
});
|
|
});
|