diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts deleted file mode 100644 index cfb1f3cb7f0..00000000000 --- a/src/auto-reply/reply/commands-approve.test.ts +++ /dev/null @@ -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" }, - }), - ); - }); -}); diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts deleted file mode 100644 index 7c418ac239a..00000000000 --- a/src/auto-reply/reply/commands-compact.test.ts +++ /dev/null @@ -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", - }), - ); - }); -}); diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts deleted file mode 100644 index 9751c39cca5..00000000000 --- a/src/auto-reply/reply/commands-info.test.ts +++ /dev/null @@ -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" }, - ]); - }); -}); diff --git a/src/auto-reply/reply/commands-parsing.test.ts b/src/auto-reply/reply/commands-parsing.test.ts deleted file mode 100644 index 47309f93217..00000000000 --- a/src/auto-reply/reply/commands-parsing.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/commands-policy.test.ts b/src/auto-reply/reply/commands-policy.test.ts deleted file mode 100644 index c93b818e25f..00000000000 --- a/src/auto-reply/reply/commands-policy.test.ts +++ /dev/null @@ -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("../../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" }, - ]), -})); - -function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial) { - 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 "); - }); - - 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 "); - 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"); - }); -}); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 431755561dc..1351296a2a1 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; 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 { MsgContext } from "../templating.js"; import { @@ -13,14 +13,96 @@ import { updateSessionStore } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.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 { handleCommands } from "./commands.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; // Avoid expensive workspace scans during /context tests. 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", () => { it("routes !poll through the /bash handler", async () => { resetBashChatCommandForTests(); @@ -130,6 +499,289 @@ describe("handleCommands bash alias", () => { }); }); +function buildPolicyParams( + commandBody: string, + cfg: OpenClawConfig, + ctxOverrides?: Partial, +) { + 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 "); + }); + + 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();