Files
openclaw/src/gateway/server-methods/commands.test.ts
Frank Yang 360955a7c8 fix: preserve commands.list metadata (#64147)
Merged via squash.

Reviewed-by: @frankekn
2026-04-10 15:35:05 +08:00

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);
});
});