import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { addSubagentRunForTests, listSubagentRunsForRequester, resetSubagentRegistryForTests, } from "../../agents/subagent-registry.js"; import type { OpenClawConfig } from "../../config/config.js"; import { updateSessionStore } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import type { MsgContext } from "../templating.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 { 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("../../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( "../../pairing/pairing-store.js", ); return { ...actual, readChannelAllowFromStore: readChannelAllowFromStoreMock, addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, }; }); vi.mock("../../channels/plugins/pairing.js", async () => { const actual = await vi.importActual( "../../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(); vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); import type { HandleCommandsParams } from "./commands-types.js"; import { buildCommandContext, handleCommands } from "./commands.js"; // Avoid expensive workspace scans during /context tests. vi.mock("./commands-context-report.js", () => ({ buildContextReply: async (params: { command: { commandBodyNormalized: string } }) => { const normalized = params.command.commandBodyNormalized; if (normalized === "/context list") { return { text: "Injected workspace files:\n- AGENTS.md" }; } if (normalized === "/context detail") { return { text: "Context breakdown (detailed)\nTop tools (schema size):" }; } return { text: "/context\n- /context list\nInline shortcut" }; }, })); let testWorkspaceDir = os.tmpdir(); beforeAll(async () => { testWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commands-")); await fs.writeFile(path.join(testWorkspaceDir, "AGENTS.md"), "# Agents\n", "utf-8"); }); afterAll(async () => { await fs.rm(testWorkspaceDir, { recursive: true, force: true }); }); function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial) { return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir }); } describe("handleCommands gating", () => { it("blocks /bash when disabled", async () => { resetBashChatCommandForTests(); const cfg = { commands: { bash: false, text: true }, whatsapp: { allowFrom: ["*"] }, } as OpenClawConfig; const params = buildParams("/bash echo hi", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("bash is disabled"); }); it("blocks /bash when elevated is not allowlisted", async () => { resetBashChatCommandForTests(); const cfg = { commands: { bash: true, text: true }, whatsapp: { allowFrom: ["*"] }, } as OpenClawConfig; const params = buildParams("/bash echo hi", cfg); params.elevated = { enabled: true, allowed: false, failures: [{ gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" }], }; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("elevated is not available"); }); it("blocks /config when disabled", async () => { const cfg = { commands: { config: false, debug: false, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/config show", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("/config is disabled"); }); it("blocks /debug when disabled", async () => { const cfg = { commands: { config: false, debug: false, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/debug show", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("/debug is disabled"); }); }); 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("/mesh command", () => { beforeEach(() => { vi.clearAllMocks(); callGatewayMock.mockReset(); }); it("shows usage for bare /mesh", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/mesh", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Mesh command"); expect(result.reply?.text).toContain("/mesh run "); expect(callGatewayMock).not.toHaveBeenCalled(); }); it("runs auto plan + run for /mesh ", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/mesh build a landing animation", cfg); callGatewayMock .mockResolvedValueOnce({ plan: { planId: "mesh-plan-1", goal: "build a landing animation", createdAt: Date.now(), steps: [ { id: "design", prompt: "Design animation" }, { id: "mobile-test", prompt: "Test mobile", dependsOn: ["design"] }, ], }, order: ["design", "mobile-test"], source: "llm", }) .mockResolvedValueOnce({ runId: "mesh-run-1", status: "completed", stats: { total: 2, succeeded: 2, failed: 0, skipped: 0, running: 0, pending: 0 }, }); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Mesh Plan"); expect(result.reply?.text).toContain("Mesh Run"); expect(callGatewayMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ method: "mesh.plan.auto", params: expect.objectContaining({ goal: "build a landing animation", }), }), ); expect(callGatewayMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "mesh.run", }), ); }); it("returns status via /mesh status ", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/mesh status mesh-run-77", cfg); callGatewayMock.mockResolvedValueOnce({ runId: "mesh-run-77", status: "failed", stats: { total: 3, succeeded: 1, failed: 1, skipped: 1, running: 0, pending: 0 }, }); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Run: mesh-run-77"); expect(result.reply?.text).toContain("Status: failed"); expect(callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ method: "mesh.status", params: { runId: "mesh-run-77" }, }), ); }); it("runs a previously planned mesh plan id without re-planning", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const planParams = buildParams("/mesh plan Build Hero Animation", cfg); callGatewayMock.mockResolvedValueOnce({ plan: { planId: "mesh-plan-abc", goal: "Build Hero Animation", createdAt: Date.now(), steps: [{ id: "design", prompt: "Design hero animation" }], }, order: ["design"], source: "llm", }); const planResult = await handleCommands(planParams); expect(planResult.shouldContinue).toBe(false); expect(planResult.reply?.text).toContain("Run exact plan: /mesh run mesh-plan-abc"); expect(callGatewayMock).toHaveBeenCalledTimes(1); expect(callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ method: "mesh.plan.auto", params: expect.objectContaining({ goal: "Build Hero Animation", }), }), ); callGatewayMock.mockReset(); callGatewayMock.mockResolvedValueOnce({ runId: "mesh-run-abc", status: "completed", stats: { total: 1, succeeded: 1, failed: 0, skipped: 0, running: 0, pending: 0 }, }); const runParams = buildParams("/mesh run mesh-plan-abc", cfg); const runResult = await handleCommands(runParams); expect(runResult.shouldContinue).toBe(false); expect(callGatewayMock).toHaveBeenCalledTimes(1); expect(callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ method: "mesh.run", params: expect.objectContaining({ plan: expect.objectContaining({ planId: "mesh-plan-abc", goal: "Build Hero Animation", }), }), }), ); }); }); 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", updatedAt: Date.now(), 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", () => { it("routes !poll through the /bash handler", async () => { resetBashChatCommandForTests(); const cfg = { commands: { bash: true, text: true }, whatsapp: { allowFrom: ["*"] }, } as OpenClawConfig; const params = buildParams("!poll", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("No active bash job"); }); it("routes !stop through the /bash handler", async () => { resetBashChatCommandForTests(); const cfg = { commands: { bash: true, text: true }, whatsapp: { allowFrom: ["*"] }, } as OpenClawConfig; const params = buildParams("!stop", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("No active bash job"); }); }); function buildPolicyParams( commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial, ): HandleCommandsParams { 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, }); const params: HandleCommandsParams = { ctx, cfg, command, directives: parseInlineDirectives(commandBody), elevated: { enabled: true, allowed: true, failures: [] }, sessionKey: "agent:main:main", workspaceDir: "/tmp", defaultGroupActivation: () => "mention", resolvedVerboseLevel: "off", resolvedReasoningLevel: "off", resolveDefaultThinkingLevel: async () => undefined, provider: "telegram", model: "test-model", contextTokens: 0, isGroup: false, }; return params; } 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 "); }); 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 "); 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", () => { it("dispatches registered plugin commands", async () => { clearPluginCommands(); const result = registerPluginCommand("test-plugin", { name: "card", description: "Test card", handler: async () => ({ text: "from plugin" }), }); expect(result.ok).toBe(true); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/card", cfg); const commandResult = await handleCommands(params); expect(commandResult.shouldContinue).toBe(false); expect(commandResult.reply?.text).toBe("from plugin"); clearPluginCommands(); }); }); describe("handleCommands identity", () => { it("returns sender details for /whoami", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/whoami", cfg, { SenderId: "12345", SenderUsername: "TestUser", ChatType: "direct", }); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Channel: whatsapp"); expect(result.reply?.text).toContain("User id: 12345"); expect(result.reply?.text).toContain("Username: @TestUser"); expect(result.reply?.text).toContain("AllowFrom: 12345"); }); }); describe("handleCommands hooks", () => { it("triggers hooks for /new with arguments", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/new take notes", cfg); const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); await handleCommands(params); expect(spy).toHaveBeenCalledWith(expect.objectContaining({ type: "command", action: "new" })); spy.mockRestore(); }); }); describe("handleCommands context", () => { it("returns context help for /context", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/context", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("/context list"); expect(result.reply?.text).toContain("Inline shortcut"); }); it("returns a per-file breakdown for /context list", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/context list", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Injected workspace files:"); expect(result.reply?.text).toContain("AGENTS.md"); }); it("returns a detailed breakdown for /context detail", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/context detail", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Context breakdown (detailed)"); expect(result.reply?.text).toContain("Top tools (schema size):"); }); }); describe("handleCommands subagents", () => { it("lists subagents when none exist", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/subagents list", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("active subagents:"); expect(result.reply?.text).toContain("active subagents:\n-----\n"); expect(result.reply?.text).toContain("recent subagents (last 30m):"); expect(result.reply?.text).toContain("\n\nrecent subagents (last 30m):"); expect(result.reply?.text).toContain("recent subagents (last 30m):\n-----\n"); }); it("truncates long subagent task text in /subagents list", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-long-task", childSessionKey: "agent:main:subagent:long-task", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "This is a deliberately long task description used to verify that subagent list output keeps the full task text instead of appending ellipsis after a short hard cutoff.", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/subagents list", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain( "This is a deliberately long task description used to verify that subagent list output keeps the full task text", ); expect(result.reply?.text).toContain("..."); expect(result.reply?.text).not.toContain("after a short hard cutoff."); }); it("lists subagents for the current command session over the target session", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", requesterSessionKey: "agent:main:slack:slash:u1", requesterDisplayKey: "agent:main:slack:slash:u1", task: "do thing", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); addSubagentRunForTests({ runId: "run-2", childSessionKey: "agent:main:subagent:def", requesterSessionKey: "agent:main:slack:slash:u1", requesterDisplayKey: "agent:main:slack:slash:u1", task: "another thing", cleanup: "keep", createdAt: 2000, startedAt: 2000, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/subagents list", cfg, { CommandSource: "native", CommandTargetSessionKey: "agent:main:main", }); params.sessionKey = "agent:main:slack:slash:u1"; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("active subagents:"); expect(result.reply?.text).toContain("do thing"); expect(result.reply?.text).not.toContain("\n\n2."); }); it("formats subagent usage with io and prompt/cache breakdown", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-usage", childSessionKey: "agent:main:subagent:usage", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "do thing", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); const storePath = path.join(testWorkspaceDir, "sessions-subagents-usage.json"); await updateSessionStore(storePath, (store) => { store["agent:main:subagent:usage"] = { sessionId: "child-session-usage", updatedAt: Date.now(), inputTokens: 12, outputTokens: 1000, totalTokens: 197000, model: "opencode/claude-opus-4-6", }; }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { store: storePath }, } as OpenClawConfig; const params = buildParams("/subagents list", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toMatch(/tokens 1(\.0)?k \(in 12 \/ out 1(\.0)?k\)/); expect(result.reply?.text).toContain("prompt/cache 197k"); expect(result.reply?.text).not.toContain("1k io"); }); it("omits subagent status line when none exist", async () => { resetSubagentRegistryForTests(); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { mainKey: "main", scope: "per-sender" }, } as OpenClawConfig; const params = buildParams("/status", cfg); params.resolvedVerboseLevel = "on"; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).not.toContain("Subagents:"); }); it("returns help for unknown subagents action", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/subagents foo", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("/subagents"); }); it("returns usage for subagents info without target", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/subagents info", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("/subagents info"); }); it("includes subagent count in /status when active", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "do thing", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { mainKey: "main", scope: "per-sender" }, } as OpenClawConfig; const params = buildParams("/status", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("๐Ÿค– Subagents: 1 active"); }); it("includes subagent details in /status when verbose", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "do thing", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); addSubagentRunForTests({ runId: "run-2", childSessionKey: "agent:main:subagent:def", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "finished task", cleanup: "keep", createdAt: 900, startedAt: 900, endedAt: 1200, outcome: { status: "ok" }, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { mainKey: "main", scope: "per-sender" }, } as OpenClawConfig; const params = buildParams("/status", cfg); params.resolvedVerboseLevel = "on"; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("๐Ÿค– Subagents: 1 active"); expect(result.reply?.text).toContain("ยท 1 done"); }); it("returns info for a subagent", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const now = Date.now(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "do thing", cleanup: "keep", createdAt: now - 20_000, startedAt: now - 20_000, endedAt: now - 1_000, outcome: { status: "ok" }, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { mainKey: "main", scope: "per-sender" }, } as OpenClawConfig; const params = buildParams("/subagents info 1", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Subagent info"); expect(result.reply?.text).toContain("Run: run-1"); expect(result.reply?.text).toContain("Status: done"); }); it("kills subagents via /kill alias without a confirmation reply", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "do thing", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/kill 1", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply).toBeUndefined(); }); it("resolves numeric aliases in active-first display order", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const now = Date.now(); addSubagentRunForTests({ runId: "run-active", childSessionKey: "agent:main:subagent:active", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "active task", cleanup: "keep", createdAt: now - 120_000, startedAt: now - 120_000, }); addSubagentRunForTests({ runId: "run-recent", childSessionKey: "agent:main:subagent:recent", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "recent task", cleanup: "keep", createdAt: now - 30_000, startedAt: now - 30_000, endedAt: now - 10_000, outcome: { status: "ok" }, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/kill 1", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply).toBeUndefined(); }); it("sends follow-up messages to finished subagents", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string; params?: { runId?: string } }; if (request.method === "agent") { return { runId: "run-followup-1" }; } if (request.method === "agent.wait") { return { status: "done" }; } if (request.method === "chat.history") { return { messages: [] }; } return {}; }); const now = Date.now(); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "do thing", cleanup: "keep", createdAt: now - 20_000, startedAt: now - 20_000, endedAt: now - 1_000, outcome: { status: "ok" }, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/subagents send 1 continue with follow-up details", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("โœ… Sent to"); const agentCall = callGatewayMock.mock.calls.find( (call) => (call[0] as { method?: string }).method === "agent", ); expect(agentCall?.[0]).toMatchObject({ method: "agent", params: { lane: "subagent", sessionKey: "agent:main:subagent:abc", timeout: 0, }, }); const waitCall = callGatewayMock.mock.calls.find( (call) => (call[0] as { method?: string; params?: { runId?: string } }).method === "agent.wait" && (call[0] as { method?: string; params?: { runId?: string } }).params?.runId === "run-followup-1", ); expect(waitCall).toBeDefined(); }); it("steers subagents via /steer alias", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string }; if (request.method === "agent") { return { runId: "run-steer-1" }; } return {}; }); const storePath = path.join(testWorkspaceDir, "sessions-subagents-steer.json"); await updateSessionStore(storePath, (store) => { store["agent:main:subagent:abc"] = { sessionId: "child-session-steer", updatedAt: Date.now(), }; }); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "do thing", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, session: { store: storePath }, } as OpenClawConfig; const params = buildParams("/steer 1 check timer.ts instead", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("steered"); const steerWaitIndex = callGatewayMock.mock.calls.findIndex( (call) => (call[0] as { method?: string; params?: { runId?: string } }).method === "agent.wait" && (call[0] as { method?: string; params?: { runId?: string } }).params?.runId === "run-1", ); expect(steerWaitIndex).toBeGreaterThanOrEqual(0); const steerRunIndex = callGatewayMock.mock.calls.findIndex( (call) => (call[0] as { method?: string }).method === "agent", ); expect(steerRunIndex).toBeGreaterThan(steerWaitIndex); expect(callGatewayMock.mock.calls[steerWaitIndex]?.[0]).toMatchObject({ method: "agent.wait", params: { runId: "run-1", timeoutMs: 5_000 }, timeoutMs: 7_000, }); expect(callGatewayMock.mock.calls[steerRunIndex]?.[0]).toMatchObject({ method: "agent", params: { lane: "subagent", sessionKey: "agent:main:subagent:abc", sessionId: "child-session-steer", timeout: 0, }, }); const trackedRuns = listSubagentRunsForRequester("agent:main:main"); expect(trackedRuns).toHaveLength(1); expect(trackedRuns[0].runId).toBe("run-steer-1"); expect(trackedRuns[0].endedAt).toBeUndefined(); }); it("restores announce behavior when /steer replacement dispatch fails", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string }; if (request.method === "agent.wait") { return { status: "timeout" }; } if (request.method === "agent") { throw new Error("dispatch failed"); } return {}; }); addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", task: "do thing", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; const params = buildParams("/steer 1 check timer.ts instead", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("send failed: dispatch failed"); const trackedRuns = listSubagentRunsForRequester("agent:main:main"); expect(trackedRuns).toHaveLength(1); expect(trackedRuns[0].runId).toBe("run-1"); expect(trackedRuns[0].suppressAnnounceReason).toBeUndefined(); }); }); describe("handleCommands /tts", () => { it("returns status for bare /tts on text command surfaces", async () => { const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } }, } as OpenClawConfig; const params = buildParams("/tts", cfg); const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("TTS status"); }); });