mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 22:01:35 +00:00
perf(test): consolidate reply commands suites
This commit is contained in:
@@ -1,114 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
|
||||||
import { callGateway } from "../../gateway/call.js";
|
|
||||||
import { handleCommands } from "./commands.js";
|
|
||||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
|
||||||
|
|
||||||
vi.mock("../../gateway/call.js", () => ({
|
|
||||||
callGateway: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("/approve command", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid usage", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams("/approve", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Usage: /approve");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits approval", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams("/approve abc allow-once", cfg, { SenderId: "123" });
|
|
||||||
|
|
||||||
const mockCallGateway = vi.mocked(callGateway);
|
|
||||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
|
||||||
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
|
||||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "exec.approval.resolve",
|
|
||||||
params: { id: "abc", decision: "allow-once" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects gateway clients without approvals scope", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams("/approve abc allow-once", cfg, {
|
|
||||||
Provider: "webchat",
|
|
||||||
Surface: "webchat",
|
|
||||||
GatewayClientScopes: ["operator.write"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockCallGateway = vi.mocked(callGateway);
|
|
||||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
|
||||||
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("requires operator.approvals");
|
|
||||||
expect(mockCallGateway).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows gateway clients with approvals scope", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams("/approve abc allow-once", cfg, {
|
|
||||||
Provider: "webchat",
|
|
||||||
Surface: "webchat",
|
|
||||||
GatewayClientScopes: ["operator.approvals"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockCallGateway = vi.mocked(callGateway);
|
|
||||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
|
||||||
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
|
||||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "exec.approval.resolve",
|
|
||||||
params: { id: "abc", decision: "allow-once" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows gateway clients with admin scope", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams("/approve abc allow-once", cfg, {
|
|
||||||
Provider: "webchat",
|
|
||||||
Surface: "webchat",
|
|
||||||
GatewayClientScopes: ["operator.admin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockCallGateway = vi.mocked(callGateway);
|
|
||||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
|
||||||
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
|
||||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "exec.approval.resolve",
|
|
||||||
params: { id: "abc", decision: "allow-once" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
|
||||||
import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js";
|
|
||||||
import { handleCompactCommand } from "./commands-compact.js";
|
|
||||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
|
||||||
|
|
||||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
|
||||||
abortEmbeddedPiRun: vi.fn(),
|
|
||||||
compactEmbeddedPiSession: vi.fn(),
|
|
||||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
|
||||||
waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../infra/system-events.js", () => ({
|
|
||||||
enqueueSystemEvent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./session-updates.js", () => ({
|
|
||||||
incrementCompactionCount: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("/compact command", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when command is not /compact", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams("/status", cfg);
|
|
||||||
|
|
||||||
const result = await handleCompactCommand(
|
|
||||||
{
|
|
||||||
...params,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unauthorized /compact commands", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams("/compact", cfg);
|
|
||||||
|
|
||||||
const result = await handleCompactCommand(
|
|
||||||
{
|
|
||||||
...params,
|
|
||||||
command: {
|
|
||||||
...params.command,
|
|
||||||
isAuthorizedSender: false,
|
|
||||||
senderId: "unauthorized",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual({ shouldContinue: false });
|
|
||||||
expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes manual compaction with explicit trigger and context metadata", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
||||||
session: { store: "/tmp/openclaw-session-store.json" },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams("/compact: focus on decisions", cfg, {
|
|
||||||
From: "+15550001",
|
|
||||||
To: "+15550002",
|
|
||||||
});
|
|
||||||
vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
compacted: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await handleCompactCommand(
|
|
||||||
{
|
|
||||||
...params,
|
|
||||||
sessionEntry: {
|
|
||||||
sessionId: "session-1",
|
|
||||||
groupId: "group-1",
|
|
||||||
groupChannel: "#general",
|
|
||||||
space: "workspace-1",
|
|
||||||
spawnedBy: "agent:main:parent",
|
|
||||||
totalTokens: 12345,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result?.shouldContinue).toBe(false);
|
|
||||||
expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce();
|
|
||||||
expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
sessionId: "session-1",
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
trigger: "manual",
|
|
||||||
customInstructions: "focus on decisions",
|
|
||||||
messageChannel: "whatsapp",
|
|
||||||
groupId: "group-1",
|
|
||||||
groupChannel: "#general",
|
|
||||||
groupSpace: "workspace-1",
|
|
||||||
spawnedBy: "agent:main:parent",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildCommandsPaginationKeyboard } from "./commands-info.js";
|
|
||||||
|
|
||||||
describe("buildCommandsPaginationKeyboard", () => {
|
|
||||||
it("adds agent id to callback data when provided", () => {
|
|
||||||
const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main");
|
|
||||||
expect(keyboard[0]).toEqual([
|
|
||||||
{ text: "◀ Prev", callback_data: "commands_page_1:agent-main" },
|
|
||||||
{ text: "2/3", callback_data: "commands_page_noop:agent-main" },
|
|
||||||
{ text: "Next ▶", callback_data: "commands_page_3:agent-main" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
|
||||||
import { extractMessageText } from "./commands-subagents.js";
|
|
||||||
import { handleCommands } from "./commands.js";
|
|
||||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
|
||||||
import { parseConfigCommand } from "./config-commands.js";
|
|
||||||
import { parseDebugCommand } from "./debug-commands.js";
|
|
||||||
|
|
||||||
describe("parseConfigCommand", () => {
|
|
||||||
it("parses show/unset", () => {
|
|
||||||
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
|
||||||
expect(parseConfigCommand("/config show")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: undefined,
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
|
||||||
action: "unset",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses set with JSON", () => {
|
|
||||||
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
|
||||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseDebugCommand", () => {
|
|
||||||
it("parses show/reset", () => {
|
|
||||||
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
|
||||||
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
|
||||||
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses set with JSON", () => {
|
|
||||||
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
|
||||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses unset", () => {
|
|
||||||
const cmd = parseDebugCommand("/debug unset foo.bar");
|
|
||||||
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractMessageText", () => {
|
|
||||||
it("preserves user text that looks like tool call markers", () => {
|
|
||||||
const message = {
|
|
||||||
role: "user",
|
|
||||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
|
||||||
};
|
|
||||||
const result = extractMessageText(message);
|
|
||||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sanitizes assistant tool call markers", () => {
|
|
||||||
const message = {
|
|
||||||
role: "assistant",
|
|
||||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
|
||||||
};
|
|
||||||
const result = extractMessageText(message);
|
|
||||||
expect(result?.text).toBe("Here ok");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleCommands /config configWrites gating", () => {
|
|
||||||
it("blocks /config set when channel config writes are disabled", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { config: true, text: true },
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildCommandTestParams('/config set messages.ackReaction=":)"', cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
|
||||||
import type { MsgContext } from "../templating.js";
|
|
||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
|
||||||
|
|
||||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
|
||||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
|
||||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../../config/config.js", async () => {
|
|
||||||
const actual =
|
|
||||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
|
||||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
|
||||||
writeConfigFile: writeConfigFileMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
|
||||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
|
||||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
|
||||||
"../../pairing/pairing-store.js",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
|
||||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
|
||||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
|
||||||
"../../channels/plugins/pairing.js",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
listPairingChannels: () => ["telegram"],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../../agents/model-catalog.js", () => ({
|
|
||||||
loadModelCatalog: vi.fn(async () => [
|
|
||||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" },
|
|
||||||
{ provider: "openai", id: "gpt-4.1", name: "GPT-4.1" },
|
|
||||||
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
|
|
||||||
{ provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" },
|
|
||||||
]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial<MsgContext>) {
|
|
||||||
const ctx = {
|
|
||||||
Body: commandBody,
|
|
||||||
CommandBody: commandBody,
|
|
||||||
CommandSource: "text",
|
|
||||||
CommandAuthorized: true,
|
|
||||||
Provider: "telegram",
|
|
||||||
Surface: "telegram",
|
|
||||||
...ctxOverrides,
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const command = buildCommandContext({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
isGroup: false,
|
|
||||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
command,
|
|
||||||
directives: parseInlineDirectives(commandBody),
|
|
||||||
elevated: { enabled: true, allowed: true, failures: [] },
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
workspaceDir: "/tmp",
|
|
||||||
defaultGroupActivation: () => "mention",
|
|
||||||
resolvedVerboseLevel: "off" as const,
|
|
||||||
resolvedReasoningLevel: "off" as const,
|
|
||||||
resolveDefaultThinkingLevel: async () => undefined,
|
|
||||||
provider: "telegram",
|
|
||||||
model: "test-model",
|
|
||||||
contextTokens: 0,
|
|
||||||
isGroup: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("handleCommands /allowlist", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists config + store allowFrom entries", async () => {
|
|
||||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildParams("/allowlist list dm", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Channel: telegram");
|
|
||||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
|
||||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds entries to config and pairing store", async () => {
|
|
||||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
|
||||||
valid: true,
|
|
||||||
parsed: {
|
|
||||||
channels: { telegram: { allowFrom: ["123"] } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
|
||||||
ok: true,
|
|
||||||
config,
|
|
||||||
}));
|
|
||||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
|
||||||
changed: true,
|
|
||||||
allowFrom: ["123", "789"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true, config: true },
|
|
||||||
channels: { telegram: { allowFrom: ["123"] } },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const params = buildParams("/allowlist add dm 789", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
|
||||||
channel: "telegram",
|
|
||||||
entry: "789",
|
|
||||||
});
|
|
||||||
expect(result.reply?.text).toContain("DM allowlist added");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
|
||||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
|
||||||
valid: true,
|
|
||||||
parsed: {
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
allowFrom: ["U111", "U222"],
|
|
||||||
dm: { allowFrom: ["U111", "U222"] },
|
|
||||||
configWrites: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
|
||||||
ok: true,
|
|
||||||
config,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true, config: true },
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
allowFrom: ["U111", "U222"],
|
|
||||||
dm: { allowFrom: ["U111", "U222"] },
|
|
||||||
configWrites: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const params = buildParams("/allowlist remove dm U111", cfg, {
|
|
||||||
Provider: "slack",
|
|
||||||
Surface: "slack",
|
|
||||||
});
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
|
||||||
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
|
||||||
expect(written.channels?.slack?.allowFrom).toEqual(["U222"]);
|
|
||||||
expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined();
|
|
||||||
expect(result.reply?.text).toContain("channels.slack.allowFrom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
|
||||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
|
||||||
valid: true,
|
|
||||||
parsed: {
|
|
||||||
channels: {
|
|
||||||
discord: {
|
|
||||||
allowFrom: ["111", "222"],
|
|
||||||
dm: { allowFrom: ["111", "222"] },
|
|
||||||
configWrites: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
|
||||||
ok: true,
|
|
||||||
config,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true, config: true },
|
|
||||||
channels: {
|
|
||||||
discord: {
|
|
||||||
allowFrom: ["111", "222"],
|
|
||||||
dm: { allowFrom: ["111", "222"] },
|
|
||||||
configWrites: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const params = buildParams("/allowlist remove dm 111", cfg, {
|
|
||||||
Provider: "discord",
|
|
||||||
Surface: "discord",
|
|
||||||
});
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
|
||||||
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
|
||||||
expect(written.channels?.discord?.allowFrom).toEqual(["222"]);
|
|
||||||
expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined();
|
|
||||||
expect(result.reply?.text).toContain("channels.discord.allowFrom");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/models command", () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
|
||||||
} as unknown as OpenClawConfig;
|
|
||||||
|
|
||||||
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
|
|
||||||
const params = buildParams("/models", cfg, { Provider: surface, Surface: surface });
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Providers:");
|
|
||||||
expect(result.reply?.text).toContain("anthropic");
|
|
||||||
expect(result.reply?.text).toContain("Use: /models <provider>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists providers on telegram (buttons)", async () => {
|
|
||||||
const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toBe("Select a provider:");
|
|
||||||
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
|
|
||||||
?.telegram?.buttons;
|
|
||||||
expect(buttons).toBeDefined();
|
|
||||||
expect(buttons?.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists provider models with pagination hints", async () => {
|
|
||||||
// Use discord surface for text-based output tests
|
|
||||||
const params = buildParams("/models anthropic", cfg, { Surface: "discord" });
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
|
||||||
expect(result.reply?.text).toContain("page 1/");
|
|
||||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
|
||||||
expect(result.reply?.text).toContain("Switch: /model <provider/model>");
|
|
||||||
expect(result.reply?.text).toContain("All: /models anthropic all");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores page argument when all flag is present", async () => {
|
|
||||||
// Use discord surface for text-based output tests
|
|
||||||
const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" });
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
|
||||||
expect(result.reply?.text).toContain("page 1/1");
|
|
||||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
|
||||||
expect(result.reply?.text).not.toContain("Page out of range");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on out-of-range pages", async () => {
|
|
||||||
// Use discord surface for text-based output tests
|
|
||||||
const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" });
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Page out of range");
|
|
||||||
expect(result.reply?.text).toContain("valid: 1-");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles unknown providers", async () => {
|
|
||||||
const params = buildParams("/models not-a-provider", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Unknown provider");
|
|
||||||
expect(result.reply?.text).toContain("Available providers");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists configured models outside the curated catalog", async () => {
|
|
||||||
const customCfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: {
|
|
||||||
primary: "localai/ultra-chat",
|
|
||||||
fallbacks: ["anthropic/claude-opus-4-5"],
|
|
||||||
},
|
|
||||||
imageModel: "visionpro/studio-v1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpenClawConfig;
|
|
||||||
|
|
||||||
// Use discord surface for text-based output tests
|
|
||||||
const providerList = await handleCommands(
|
|
||||||
buildParams("/models", customCfg, { Surface: "discord" }),
|
|
||||||
);
|
|
||||||
expect(providerList.reply?.text).toContain("localai");
|
|
||||||
expect(providerList.reply?.text).toContain("visionpro");
|
|
||||||
|
|
||||||
const result = await handleCommands(
|
|
||||||
buildParams("/models localai", customCfg, { Surface: "discord" }),
|
|
||||||
);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Models (localai)");
|
|
||||||
expect(result.reply?.text).toContain("localai/ultra-chat");
|
|
||||||
expect(result.reply?.text).not.toContain("Unknown provider");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,14 +13,96 @@ import { updateSessionStore } from "../../config/sessions.js";
|
|||||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||||
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||||
|
import { handleCompactCommand } from "./commands-compact.js";
|
||||||
|
import { buildCommandsPaginationKeyboard } from "./commands-info.js";
|
||||||
|
import { extractMessageText } from "./commands-subagents.js";
|
||||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
import { buildCommandTestParams } from "./commands.test-harness.js";
|
||||||
|
import { parseConfigCommand } from "./config-commands.js";
|
||||||
|
import { parseDebugCommand } from "./debug-commands.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||||
|
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../config/config.js", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||||
|
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||||
|
writeConfigFile: writeConfigFileMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||||
|
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||||
|
"../../pairing/pairing-store.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||||
|
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||||
|
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||||
|
"../../channels/plugins/pairing.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
listPairingChannels: () => ["telegram"],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../agents/model-catalog.js", () => ({
|
||||||
|
loadModelCatalog: vi.fn(async () => [
|
||||||
|
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||||
|
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" },
|
||||||
|
{ provider: "openai", id: "gpt-4.1", name: "GPT-4.1" },
|
||||||
|
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
|
||||||
|
{ provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../agents/pi-embedded.js", () => {
|
||||||
|
const resolveEmbeddedSessionLane = (key: string) => {
|
||||||
|
const cleaned = key.trim() || "main";
|
||||||
|
return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
abortEmbeddedPiRun: vi.fn(),
|
||||||
|
compactEmbeddedPiSession: vi.fn(),
|
||||||
|
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||||
|
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||||
|
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||||
|
resolveEmbeddedSessionLane,
|
||||||
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
|
waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../infra/system-events.js", () => ({
|
||||||
|
enqueueSystemEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./session-updates.js", () => ({
|
||||||
|
incrementCompactionCount: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const callGatewayMock = vi.fn();
|
const callGatewayMock = vi.fn();
|
||||||
vi.mock("../../gateway/call.js", () => ({
|
vi.mock("../../gateway/call.js", () => ({
|
||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { handleCommands } from "./commands.js";
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
|
|
||||||
// Avoid expensive workspace scans during /context tests.
|
// Avoid expensive workspace scans during /context tests.
|
||||||
vi.mock("./commands-context-report.js", () => ({
|
vi.mock("./commands-context-report.js", () => ({
|
||||||
@@ -104,6 +186,293 @@ describe("handleCommands gating", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("/approve command", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid usage", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams("/approve", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Usage: /approve");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits approval", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" });
|
||||||
|
|
||||||
|
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||||
|
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "exec.approval.resolve",
|
||||||
|
params: { id: "abc", decision: "allow-once" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects gateway clients without approvals scope", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams("/approve abc allow-once", cfg, {
|
||||||
|
Provider: "webchat",
|
||||||
|
Surface: "webchat",
|
||||||
|
GatewayClientScopes: ["operator.write"],
|
||||||
|
});
|
||||||
|
|
||||||
|
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("requires operator.approvals");
|
||||||
|
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows gateway clients with approvals scope", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams("/approve abc allow-once", cfg, {
|
||||||
|
Provider: "webchat",
|
||||||
|
Surface: "webchat",
|
||||||
|
GatewayClientScopes: ["operator.approvals"],
|
||||||
|
});
|
||||||
|
|
||||||
|
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||||
|
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "exec.approval.resolve",
|
||||||
|
params: { id: "abc", decision: "allow-once" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows gateway clients with admin scope", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams("/approve abc allow-once", cfg, {
|
||||||
|
Provider: "webchat",
|
||||||
|
Surface: "webchat",
|
||||||
|
GatewayClientScopes: ["operator.admin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||||
|
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "exec.approval.resolve",
|
||||||
|
params: { id: "abc", decision: "allow-once" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/compact command", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when command is not /compact", async () => {
|
||||||
|
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams("/status", cfg);
|
||||||
|
|
||||||
|
const result = await handleCompactCommand(
|
||||||
|
{
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthorized /compact commands", async () => {
|
||||||
|
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams("/compact", cfg);
|
||||||
|
|
||||||
|
const result = await handleCompactCommand(
|
||||||
|
{
|
||||||
|
...params,
|
||||||
|
command: {
|
||||||
|
...params.command,
|
||||||
|
isAuthorizedSender: false,
|
||||||
|
senderId: "unauthorized",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ shouldContinue: false });
|
||||||
|
expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes manual compaction with explicit trigger and context metadata", async () => {
|
||||||
|
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: "/tmp/openclaw-session-store.json" },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams("/compact: focus on decisions", cfg, {
|
||||||
|
From: "+15550001",
|
||||||
|
To: "+15550002",
|
||||||
|
});
|
||||||
|
vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
compacted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleCompactCommand(
|
||||||
|
{
|
||||||
|
...params,
|
||||||
|
sessionEntry: {
|
||||||
|
sessionId: "session-1",
|
||||||
|
groupId: "group-1",
|
||||||
|
groupChannel: "#general",
|
||||||
|
space: "workspace-1",
|
||||||
|
spawnedBy: "agent:main:parent",
|
||||||
|
totalTokens: 12345,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.shouldContinue).toBe(false);
|
||||||
|
expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce();
|
||||||
|
expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: "session-1",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
trigger: "manual",
|
||||||
|
customInstructions: "focus on decisions",
|
||||||
|
messageChannel: "whatsapp",
|
||||||
|
groupId: "group-1",
|
||||||
|
groupChannel: "#general",
|
||||||
|
groupSpace: "workspace-1",
|
||||||
|
spawnedBy: "agent:main:parent",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildCommandsPaginationKeyboard", () => {
|
||||||
|
it("adds agent id to callback data when provided", () => {
|
||||||
|
const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main");
|
||||||
|
expect(keyboard[0]).toEqual([
|
||||||
|
{ text: "◀ Prev", callback_data: "commands_page_1:agent-main" },
|
||||||
|
{ text: "2/3", callback_data: "commands_page_noop:agent-main" },
|
||||||
|
{ text: "Next ▶", callback_data: "commands_page_3:agent-main" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseConfigCommand", () => {
|
||||||
|
it("parses show/unset", () => {
|
||||||
|
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
||||||
|
expect(parseConfigCommand("/config show")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: undefined,
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
||||||
|
action: "unset",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses set with JSON", () => {
|
||||||
|
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
||||||
|
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseDebugCommand", () => {
|
||||||
|
it("parses show/reset", () => {
|
||||||
|
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||||
|
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||||
|
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses set with JSON", () => {
|
||||||
|
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||||
|
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses unset", () => {
|
||||||
|
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||||
|
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractMessageText", () => {
|
||||||
|
it("preserves user text that looks like tool call markers", () => {
|
||||||
|
const message = {
|
||||||
|
role: "user",
|
||||||
|
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||||
|
};
|
||||||
|
const result = extractMessageText(message);
|
||||||
|
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes assistant tool call markers", () => {
|
||||||
|
const message = {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||||
|
};
|
||||||
|
const result = extractMessageText(message);
|
||||||
|
expect(result?.text).toBe("Here ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleCommands /config configWrites gating", () => {
|
||||||
|
it("blocks /config set when channel config writes are disabled", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { config: true, text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("handleCommands bash alias", () => {
|
describe("handleCommands bash alias", () => {
|
||||||
it("routes !poll through the /bash handler", async () => {
|
it("routes !poll through the /bash handler", async () => {
|
||||||
resetBashChatCommandForTests();
|
resetBashChatCommandForTests();
|
||||||
@@ -130,6 +499,289 @@ describe("handleCommands bash alias", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function buildPolicyParams(
|
||||||
|
commandBody: string,
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
ctxOverrides?: Partial<MsgContext>,
|
||||||
|
) {
|
||||||
|
const ctx = {
|
||||||
|
Body: commandBody,
|
||||||
|
CommandBody: commandBody,
|
||||||
|
CommandSource: "text",
|
||||||
|
CommandAuthorized: true,
|
||||||
|
Provider: "telegram",
|
||||||
|
Surface: "telegram",
|
||||||
|
...ctxOverrides,
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
isGroup: false,
|
||||||
|
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
command,
|
||||||
|
directives: parseInlineDirectives(commandBody),
|
||||||
|
elevated: { enabled: true, allowed: true, failures: [] },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
defaultGroupActivation: () => "mention",
|
||||||
|
resolvedVerboseLevel: "off" as const,
|
||||||
|
resolvedReasoningLevel: "off" as const,
|
||||||
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
|
provider: "telegram",
|
||||||
|
model: "test-model",
|
||||||
|
contextTokens: 0,
|
||||||
|
isGroup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("handleCommands /allowlist", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists config + store allowFrom entries", async () => {
|
||||||
|
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildPolicyParams("/allowlist list dm", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Channel: telegram");
|
||||||
|
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||||
|
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds entries to config and pairing store", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||||
|
changed: true,
|
||||||
|
allowFrom: ["123", "789"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildPolicyParams("/allowlist add dm 789", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||||
|
channel: "telegram",
|
||||||
|
entry: "789",
|
||||||
|
});
|
||||||
|
expect(result.reply?.text).toContain("DM allowlist added");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
allowFrom: ["U111", "U222"],
|
||||||
|
dm: { allowFrom: ["U111", "U222"] },
|
||||||
|
configWrites: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
allowFrom: ["U111", "U222"],
|
||||||
|
dm: { allowFrom: ["U111", "U222"] },
|
||||||
|
configWrites: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
const params = buildPolicyParams("/allowlist remove dm U111", cfg, {
|
||||||
|
Provider: "slack",
|
||||||
|
Surface: "slack",
|
||||||
|
});
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||||
|
expect(written.channels?.slack?.allowFrom).toEqual(["U222"]);
|
||||||
|
expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined();
|
||||||
|
expect(result.reply?.text).toContain("channels.slack.allowFrom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
allowFrom: ["111", "222"],
|
||||||
|
dm: { allowFrom: ["111", "222"] },
|
||||||
|
configWrites: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
allowFrom: ["111", "222"],
|
||||||
|
dm: { allowFrom: ["111", "222"] },
|
||||||
|
configWrites: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
|
||||||
|
const params = buildPolicyParams("/allowlist remove dm 111", cfg, {
|
||||||
|
Provider: "discord",
|
||||||
|
Surface: "discord",
|
||||||
|
});
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||||
|
expect(written.channels?.discord?.allowFrom).toEqual(["222"]);
|
||||||
|
expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined();
|
||||||
|
expect(result.reply?.text).toContain("channels.discord.allowFrom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/models command", () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
|
||||||
|
const params = buildPolicyParams("/models", cfg, { Provider: surface, Surface: surface });
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Providers:");
|
||||||
|
expect(result.reply?.text).toContain("anthropic");
|
||||||
|
expect(result.reply?.text).toContain("Use: /models <provider>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists providers on telegram (buttons)", async () => {
|
||||||
|
const params = buildPolicyParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toBe("Select a provider:");
|
||||||
|
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
|
||||||
|
?.telegram?.buttons;
|
||||||
|
expect(buttons).toBeDefined();
|
||||||
|
expect(buttons?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists provider models with pagination hints", async () => {
|
||||||
|
// Use discord surface for text-based output tests
|
||||||
|
const params = buildPolicyParams("/models anthropic", cfg, { Surface: "discord" });
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||||
|
expect(result.reply?.text).toContain("page 1/");
|
||||||
|
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||||
|
expect(result.reply?.text).toContain("Switch: /model <provider/model>");
|
||||||
|
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores page argument when all flag is present", async () => {
|
||||||
|
// Use discord surface for text-based output tests
|
||||||
|
const params = buildPolicyParams("/models anthropic 3 all", cfg, { Surface: "discord" });
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||||
|
expect(result.reply?.text).toContain("page 1/1");
|
||||||
|
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||||
|
expect(result.reply?.text).not.toContain("Page out of range");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on out-of-range pages", async () => {
|
||||||
|
// Use discord surface for text-based output tests
|
||||||
|
const params = buildPolicyParams("/models anthropic 4", cfg, { Surface: "discord" });
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Page out of range");
|
||||||
|
expect(result.reply?.text).toContain("valid: 1-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unknown providers", async () => {
|
||||||
|
const params = buildPolicyParams("/models not-a-provider", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Unknown provider");
|
||||||
|
expect(result.reply?.text).toContain("Available providers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists configured models outside the curated catalog", async () => {
|
||||||
|
const customCfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "localai/ultra-chat",
|
||||||
|
fallbacks: ["anthropic/claude-opus-4-5"],
|
||||||
|
},
|
||||||
|
imageModel: "visionpro/studio-v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
|
// Use discord surface for text-based output tests
|
||||||
|
const providerList = await handleCommands(
|
||||||
|
buildPolicyParams("/models", customCfg, { Surface: "discord" }),
|
||||||
|
);
|
||||||
|
expect(providerList.reply?.text).toContain("localai");
|
||||||
|
expect(providerList.reply?.text).toContain("visionpro");
|
||||||
|
|
||||||
|
const result = await handleCommands(
|
||||||
|
buildPolicyParams("/models localai", customCfg, { Surface: "discord" }),
|
||||||
|
);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Models (localai)");
|
||||||
|
expect(result.reply?.text).toContain("localai/ultra-chat");
|
||||||
|
expect(result.reply?.text).not.toContain("Unknown provider");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("handleCommands plugin commands", () => {
|
describe("handleCommands plugin commands", () => {
|
||||||
it("dispatches registered plugin commands", async () => {
|
it("dispatches registered plugin commands", async () => {
|
||||||
clearPluginCommands();
|
clearPluginCommands();
|
||||||
|
|||||||
Reference in New Issue
Block a user