From 58f43c703eea2fc691b681fecebbf4567bf9e332 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 14:23:11 -0700 Subject: [PATCH] test(auto-reply): consolidate command suites (#109342) --- docs/refactor/database-first.md | 2 +- .../reply/commands-context-report.test.ts | 111 ++++- src/auto-reply/reply/commands-context.test.ts | 107 ----- .../reply/commands-core.send-policy.test.ts | 120 ----- src/auto-reply/reply/commands-core.test.ts | 125 +++++- ...mands-export-session-file.test-support.ts} | 6 +- .../reply/commands-export-session.test.ts | 18 +- ...ommands-export-trajectory.test-support.ts} | 0 src/auto-reply/reply/commands-info.test.ts | 409 ++++++++++++++++- .../reply/commands-info.tools.test.ts | 415 ------------------ src/auto-reply/reply/commands-status.test.ts | 183 +++++++- .../commands-status.thinking-default.test.ts | 215 --------- 12 files changed, 837 insertions(+), 874 deletions(-) delete mode 100644 src/auto-reply/reply/commands-context.test.ts delete mode 100644 src/auto-reply/reply/commands-core.send-policy.test.ts rename src/auto-reply/reply/{commands-export-session-file.test.ts => commands-export-session-file.test-support.ts} (95%) rename src/auto-reply/reply/{commands-export-trajectory.test.ts => commands-export-trajectory.test-support.ts} (100%) delete mode 100644 src/auto-reply/reply/commands-info.tools.test.ts delete mode 100644 src/auto-reply/reply/commands-status.thinking-default.test.ts diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index a9aa2c5b86c6..d8896e2ba4d0 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -187,7 +187,7 @@ without exceptions outside doctor/import/export/debug boundaries. Proof: no schema change in this pass; `pnpm db:kysely:check`; `pnpm lint:kysely`. - [x] Re-run focused tests for touched stores, commands, and scripts. - Proof: `pnpm test src/cron/service/store.test.ts src/cron/store.test.ts src/cron/service.heartbeat-ok-summary-suppressed.test.ts src/cron/service.main-job-passes-heartbeat-target-last.test.ts src/cron/service.every-jobs-fire.test.ts src/cron/service.persists-delivered-status.test.ts src/cron/service.runs-one-shot-main-job-disables-it.test.ts src/cron/service/ops.test.ts src/cron/service/timer.regression.test.ts src/auto-reply/reply/commands-export-trajectory.test.ts extensions/telegram/src/thread-bindings.test.ts extensions/slack/src/monitor/message-handler/prepare.test.ts src/acp/translator.session-lineage-meta.test.ts`; `git diff --check`. + Proof: `pnpm test src/cron/service/store.test.ts src/cron/store.test.ts src/cron/service.heartbeat-ok-summary-suppressed.test.ts src/cron/service.main-job-passes-heartbeat-target-last.test.ts src/cron/service.every-jobs-fire.test.ts src/cron/service.persists-delivered-status.test.ts src/cron/service.runs-one-shot-main-job-disables-it.test.ts src/cron/service/ops.test.ts src/cron/service/timer.regression.test.ts src/auto-reply/reply/commands-export-session.test.ts extensions/telegram/src/thread-bindings.test.ts extensions/slack/src/monitor/message-handler/prepare.test.ts src/acp/translator.session-lineage-meta.test.ts`; `git diff --check`. - [x] Before declaring `done`, run the changed gate or remote broad proof. Proof: `pnpm check:changed --timed -- ` passed on Hetzner Crabbox run `run_3f1cabf6b25c` after temporary Node 24/pnpm setup and diff --git a/src/auto-reply/reply/commands-context-report.test.ts b/src/auto-reply/reply/commands-context-report.test.ts index ea05c6f71501..0069c5bb8309 100644 --- a/src/auto-reply/reply/commands-context-report.test.ts +++ b/src/auto-reply/reply/commands-context-report.test.ts @@ -1,11 +1,17 @@ -/** Tests context report command output and generated report files. */ +/** Tests context command behavior, token reporting, and generated report files. */ import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { buildContextReply } from "./commands-context-report.js"; +import { buildCommandContext } from "./commands-context.js"; import type { HandleCommandsParams } from "./commands-types.js"; +import { stripStructuralPrefixes } from "./mentions.js"; +import { buildTestCtx } from "./test-ctx.js"; + +/** Tests context report command output and generated report files. */ function makeParams( commandBodyNormalized: string, @@ -412,3 +418,106 @@ describe("buildContextReply", () => { expect(result.mediaUrl).toBeUndefined(); }); }); + +/** Tests context command behavior and token reporting. */ + +describe("buildCommandContext", () => { + it("canonicalizes registered aliases like /id to their primary command", () => { + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + From: "user", + To: "bot", + Body: "/id", + RawBody: "/id", + CommandBody: "/id", + BodyForCommands: "/id", + }); + + const result = buildCommandContext({ + ctx, + cfg: {} as OpenClawConfig, + isGroup: false, + triggerBodyNormalized: "/id", + commandAuthorized: true, + }); + + expect(result.commandBodyNormalized).toBe("/whoami"); + }); + + it("preserves multiline soft reset tails after structural normalization", () => { + const ctx = buildTestCtx({ + Provider: "whatsapp", + Surface: "whatsapp", + From: "user", + To: "bot", + Body: "/reset soft\nre-read persona files", + RawBody: "/reset soft\nre-read persona files", + CommandBody: "/reset soft\nre-read persona files", + BodyForCommands: "/reset soft\nre-read persona files", + }); + + const result = buildCommandContext({ + ctx, + cfg: {} as OpenClawConfig, + isGroup: false, + triggerBodyNormalized: stripStructuralPrefixes("/reset soft\nre-read persona files"), + commandAuthorized: true, + }); + + expect(result.commandBodyNormalized).toBe("/reset soft re-read persona files"); + }); + + it("preserves multiline slash skill payloads after structural normalization", () => { + const body = "/skill demo_skill first line\nsecond line"; + const ctx = buildTestCtx({ + Provider: "whatsapp", + Surface: "whatsapp", + From: "user", + To: "bot", + Body: body, + RawBody: body, + CommandBody: body, + BodyForCommands: body, + }); + + const result = buildCommandContext({ + ctx, + cfg: {} as OpenClawConfig, + isGroup: false, + triggerBodyNormalized: stripStructuralPrefixes(body), + commandAuthorized: true, + }); + + expect(result.commandBodyNormalized).toBe("/skill demo_skill first line\nsecond line"); + }); + + it("maps explicit gateway origin into command context", () => { + const ctx = buildTestCtx({ + Provider: "internal", + Surface: "internal", + OriginatingChannel: "slack", + OriginatingTo: "user:U123", + SenderId: "gateway-client", + From: undefined, + To: undefined, + Body: "/codex bind", + RawBody: "/codex bind", + CommandBody: "/codex bind", + BodyForCommands: "/codex bind", + }); + + const result = buildCommandContext({ + ctx, + cfg: {} as OpenClawConfig, + isGroup: false, + triggerBodyNormalized: "/codex bind", + commandAuthorized: true, + }); + + expect(result.channel).toBe("slack"); + expect(result.channelId).toBe("slack"); + expect(result.from).toBe("gateway-client"); + expect(result.to).toBe("user:U123"); + }); +}); diff --git a/src/auto-reply/reply/commands-context.test.ts b/src/auto-reply/reply/commands-context.test.ts deleted file mode 100644 index 06f123aded8d..000000000000 --- a/src/auto-reply/reply/commands-context.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** Tests context command behavior and token reporting. */ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { buildCommandContext } from "./commands-context.js"; -import { stripStructuralPrefixes } from "./mentions.js"; -import { buildTestCtx } from "./test-ctx.js"; - -describe("buildCommandContext", () => { - it("canonicalizes registered aliases like /id to their primary command", () => { - const ctx = buildTestCtx({ - Provider: "webchat", - Surface: "webchat", - From: "user", - To: "bot", - Body: "/id", - RawBody: "/id", - CommandBody: "/id", - BodyForCommands: "/id", - }); - - const result = buildCommandContext({ - ctx, - cfg: {} as OpenClawConfig, - isGroup: false, - triggerBodyNormalized: "/id", - commandAuthorized: true, - }); - - expect(result.commandBodyNormalized).toBe("/whoami"); - }); - - it("preserves multiline soft reset tails after structural normalization", () => { - const ctx = buildTestCtx({ - Provider: "whatsapp", - Surface: "whatsapp", - From: "user", - To: "bot", - Body: "/reset soft\nre-read persona files", - RawBody: "/reset soft\nre-read persona files", - CommandBody: "/reset soft\nre-read persona files", - BodyForCommands: "/reset soft\nre-read persona files", - }); - - const result = buildCommandContext({ - ctx, - cfg: {} as OpenClawConfig, - isGroup: false, - triggerBodyNormalized: stripStructuralPrefixes("/reset soft\nre-read persona files"), - commandAuthorized: true, - }); - - expect(result.commandBodyNormalized).toBe("/reset soft re-read persona files"); - }); - - it("preserves multiline slash skill payloads after structural normalization", () => { - const body = "/skill demo_skill first line\nsecond line"; - const ctx = buildTestCtx({ - Provider: "whatsapp", - Surface: "whatsapp", - From: "user", - To: "bot", - Body: body, - RawBody: body, - CommandBody: body, - BodyForCommands: body, - }); - - const result = buildCommandContext({ - ctx, - cfg: {} as OpenClawConfig, - isGroup: false, - triggerBodyNormalized: stripStructuralPrefixes(body), - commandAuthorized: true, - }); - - expect(result.commandBodyNormalized).toBe("/skill demo_skill first line\nsecond line"); - }); - - it("maps explicit gateway origin into command context", () => { - const ctx = buildTestCtx({ - Provider: "internal", - Surface: "internal", - OriginatingChannel: "slack", - OriginatingTo: "user:U123", - SenderId: "gateway-client", - From: undefined, - To: undefined, - Body: "/codex bind", - RawBody: "/codex bind", - CommandBody: "/codex bind", - BodyForCommands: "/codex bind", - }); - - const result = buildCommandContext({ - ctx, - cfg: {} as OpenClawConfig, - isGroup: false, - triggerBodyNormalized: "/codex bind", - commandAuthorized: true, - }); - - expect(result.channel).toBe("slack"); - expect(result.channelId).toBe("slack"); - expect(result.from).toBe("gateway-client"); - expect(result.to).toBe("user:U123"); - }); -}); diff --git a/src/auto-reply/reply/commands-core.send-policy.test.ts b/src/auto-reply/reply/commands-core.send-policy.test.ts deleted file mode 100644 index 1ab5805288c7..000000000000 --- a/src/auto-reply/reply/commands-core.send-policy.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Tests command send policy behavior for visible replies and message-tool routing. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { CommandHandler, HandleCommandsParams } from "./commands-types.js"; - -const loadCommandHandlersMock = vi.hoisted( - (): ReturnType CommandHandler[]>> => vi.fn<() => CommandHandler[]>(() => []), -); - -vi.mock("./commands-handlers.runtime.js", () => ({ - loadCommandHandlers: () => loadCommandHandlersMock(), -})); - -vi.mock("./commands-reset.js", () => ({ - maybeHandleResetCommand: vi.fn(async () => null), -})); - -vi.mock("../commands-registry.js", () => ({ - shouldHandleTextCommands: vi.fn(() => true), -})); - -function makeParams(): HandleCommandsParams { - return { - cfg: { - commands: { text: true }, - session: { - sendPolicy: { - default: "allow", - rules: [{ action: "deny", match: { channel: "telegram" } }], - }, - }, - }, - ctx: { - Provider: "whatsapp", - Surface: "whatsapp", - CommandSource: "text", - }, - command: { - commandBodyNormalized: "/unknown", - rawBodyNormalized: "/unknown", - isAuthorizedSender: true, - senderIsOwner: true, - senderId: "owner", - channel: "whatsapp", - channelId: "whatsapp", - surface: "whatsapp", - ownerList: [], - from: "owner", - to: "bot", - }, - directives: {}, - elevated: { enabled: true, allowed: true, failures: [] }, - sessionKey: "agent:target:main", - sessionEntry: { - sessionId: "wrapper-session", - updatedAt: Date.now(), - channel: "whatsapp", - chatType: "direct", - }, - sessionStore: { - "agent:target:main": { - sessionId: "target-session", - updatedAt: Date.now(), - channel: "telegram", - chatType: "direct", - }, - }, - workspaceDir: "/tmp/workspace", - defaultGroupActivation: () => "mention", - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolveDefaultThinkingLevel: async () => undefined, - provider: "openai", - model: "gpt-5.4", - contextTokens: 0, - isGroup: false, - } as unknown as HandleCommandsParams; -} - -describe("handleCommands send policy", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - loadCommandHandlersMock.mockReturnValue([]); - }); - - it("allows processing to continue even when send policy is deny (#53328)", async () => { - const { handleCommands } = await import("./commands-core.js"); - // sendPolicy deny now only suppresses outbound delivery, not inbound processing. - // The deny gate moved to dispatch-from-config.ts where it suppresses delivery - // after the agent has processed the message. - const result = await handleCommands(makeParams()); - - expect(result).toEqual({ shouldContinue: true }); - }); - - it("marks command replies as non-threaded", async () => { - const { handleCommands } = await import("./commands-core.js"); - loadCommandHandlersMock.mockReturnValue([ - vi.fn(async () => ({ - shouldContinue: false, - reply: { - text: "done", - replyToId: "msg-123", - replyToCurrent: true, - }, - })), - ]); - - const result = await handleCommands(makeParams()); - - expect(result).toEqual({ - shouldContinue: false, - reply: { - text: "done", - replyToId: undefined, - replyToCurrent: false, - }, - }); - }); -}); diff --git a/src/auto-reply/reply/commands-core.test.ts b/src/auto-reply/reply/commands-core.test.ts index 824a6ab7f734..b21773d2410c 100644 --- a/src/auto-reply/reply/commands-core.test.ts +++ b/src/auto-reply/reply/commands-core.test.ts @@ -1,7 +1,9 @@ -// Tests core command dispatch, aliases, authorization, and handler outcomes. +// Tests core command dispatch, reset hooks, authorization, and send policy. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HookRunner } from "../../plugins/hooks.js"; -import type { HandleCommandsParams } from "./commands-types.js"; +import type { CommandHandler, HandleCommandsParams } from "./commands-types.js"; + +// Tests core command dispatch, aliases, authorization, and handler outcomes. const hookRunnerMocks = vi.hoisted(() => ({ hasHooks: vi.fn(), @@ -208,3 +210,122 @@ describe("emitResetCommandHooks", () => { ]); }); }); + +// Tests command send policy behavior for visible replies and message-tool routing. + +const loadCommandHandlersMock = vi.hoisted( + (): ReturnType CommandHandler[]>> => vi.fn<() => CommandHandler[]>(() => []), +); + +vi.mock("./commands-handlers.runtime.js", () => ({ + loadCommandHandlers: () => loadCommandHandlersMock(), +})); + +vi.mock("./commands-reset.js", () => ({ + maybeHandleResetCommand: vi.fn(async () => null), +})); + +vi.mock("../commands-registry.js", () => ({ + shouldHandleTextCommands: vi.fn(() => true), +})); + +function makeParams(): HandleCommandsParams { + return { + cfg: { + commands: { text: true }, + session: { + sendPolicy: { + default: "allow", + rules: [{ action: "deny", match: { channel: "telegram" } }], + }, + }, + }, + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + CommandSource: "text", + }, + command: { + commandBodyNormalized: "/unknown", + rawBodyNormalized: "/unknown", + isAuthorizedSender: true, + senderIsOwner: true, + senderId: "owner", + channel: "whatsapp", + channelId: "whatsapp", + surface: "whatsapp", + ownerList: [], + from: "owner", + to: "bot", + }, + directives: {}, + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:target:main", + sessionEntry: { + sessionId: "wrapper-session", + updatedAt: Date.now(), + channel: "whatsapp", + chatType: "direct", + }, + sessionStore: { + "agent:target:main": { + sessionId: "target-session", + updatedAt: Date.now(), + channel: "telegram", + chatType: "direct", + }, + }, + workspaceDir: "/tmp/workspace", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + provider: "openai", + model: "gpt-5.4", + contextTokens: 0, + isGroup: false, + } as unknown as HandleCommandsParams; +} + +describe("handleCommands send policy", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + loadCommandHandlersMock.mockReturnValue([]); + }); + + it("allows processing to continue even when send policy is deny (#53328)", async () => { + const { handleCommands } = await import("./commands-core.js"); + // sendPolicy deny now only suppresses outbound delivery, not inbound processing. + // The deny gate moved to dispatch-from-config.ts where it suppresses delivery + // after the agent has processed the message. + const result = await handleCommands(makeParams()); + + expect(result).toEqual({ shouldContinue: true }); + }); + + it("marks command replies as non-threaded", async () => { + const { handleCommands } = await import("./commands-core.js"); + loadCommandHandlersMock.mockReturnValue([ + vi.fn(async () => ({ + shouldContinue: false, + reply: { + text: "done", + replyToId: "msg-123", + replyToCurrent: true, + }, + })), + ]); + + const result = await handleCommands(makeParams()); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "done", + replyToId: undefined, + replyToCurrent: false, + }, + }); + }); +}); diff --git a/src/auto-reply/reply/commands-export-session-file.test.ts b/src/auto-reply/reply/commands-export-session-file.test-support.ts similarity index 95% rename from src/auto-reply/reply/commands-export-session-file.test.ts rename to src/auto-reply/reply/commands-export-session-file.test-support.ts index 157e42da5696..a978bd568d1a 100644 --- a/src/auto-reply/reply/commands-export-session-file.test.ts +++ b/src/auto-reply/reply/commands-export-session-file.test-support.ts @@ -1,9 +1,11 @@ // Tests the real fs-safe boundary used by session-export artifacts. import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; -import { writeSessionExportFile } from "./commands-export-session-file.js"; +const { writeSessionExportFile } = await vi.importActual< + typeof import("./commands-export-session-file.js") +>("./commands-export-session-file.js"); const tempDirs = useAutoCleanupTempDirTracker(afterEach); diff --git a/src/auto-reply/reply/commands-export-session.test.ts b/src/auto-reply/reply/commands-export-session.test.ts index 25b796fd4fcc..ff19346b18f5 100644 --- a/src/auto-reply/reply/commands-export-session.test.ts +++ b/src/auto-reply/reply/commands-export-session.test.ts @@ -1,8 +1,12 @@ -// Tests session export command packaging, filesystem writes, and prompt bundle capture. +// Tests session and trajectory export command packaging, filesystem writes, and approval routing. import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FsSafeError } from "../../infra/fs-safe.js"; +import { buildExportSessionReply } from "./commands-export-session.js"; import type { HandleCommandsParams } from "./commands-types.js"; +// Tests session export command packaging, filesystem writes, and prompt bundle capture. + const hoisted = await vi.hoisted(async () => { const { createExportCommandSessionMocks } = await import("./commands-export-test-mocks.js"); return { @@ -60,9 +64,9 @@ vi.mock("./commands-system-prompt.js", () => ({ resolveCommandsSystemPromptBundle: hoisted.resolveCommandsSystemPromptBundleMock, })); -vi.mock("./commands-export-session-file.js", () => { - return { writeSessionExportFile: hoisted.writeSessionExportFileMock }; -}); +vi.mock("./commands-export-session-file.js", () => ({ + writeSessionExportFile: hoisted.writeSessionExportFileMock, +})); vi.mock("../../agents/sessions/session-manager.js", async (importOriginal) => { const actual = await importOriginal(); @@ -113,9 +117,6 @@ vi.mock("node:fs/promises", async () => { }; }); -import { FsSafeError } from "../../infra/fs-safe.js"; -import { buildExportSessionReply } from "./commands-export-session.js"; - function makeParams(): HandleCommandsParams { return { cfg: {}, @@ -776,3 +777,6 @@ describe("buildExportSessionReply", () => { expect(sessionDataFromHtml(writtenHtml()).warning).toBeUndefined(); }); }); + +await import("./commands-export-session-file.test-support.js"); +await import("./commands-export-trajectory.test-support.js"); diff --git a/src/auto-reply/reply/commands-export-trajectory.test.ts b/src/auto-reply/reply/commands-export-trajectory.test-support.ts similarity index 100% rename from src/auto-reply/reply/commands-export-trajectory.test.ts rename to src/auto-reply/reply/commands-export-trajectory.test-support.ts diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts index 19e03ae775f3..58651407cc9a 100644 --- a/src/auto-reply/reply/commands-info.test.ts +++ b/src/auto-reply/reply/commands-info.test.ts @@ -1,7 +1,13 @@ -// Tests info-style commands that report context, status, skills, and session exports. -import { beforeEach, describe, expect, it, vi } from "vitest"; +// Tests info-style command responses, including effective tool inventory. +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import type { EffectiveToolInventoryResult } from "../../agents/tools-effective-inventory.types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import type { MsgContext } from "../templating.js"; import { handleContextCommand } from "./commands-context-command.js"; import { @@ -14,6 +20,8 @@ import { buildStatusPluginsReply, buildStatusReply } from "./commands-status.js" import type { HandleCommandsParams } from "./commands-types.js"; import { handleWhoamiCommand } from "./commands-whoami.js"; +// Tests info-style commands that report context, status, skills, and session exports. + const buildContextReplyMock = vi.hoisted(() => vi.fn()); const buildExportTrajectoryCommandReplyMock = vi.hoisted(() => vi.fn(async () => ({ text: "exported" })), @@ -497,3 +505,400 @@ describe("info command handlers", () => { expect(listParams.agentIds).toEqual(["target"]); }); }); + +// Tests tool listing in info command responses. + +function makeInventoryEntry(params: { + id: string; + label: string; + description: string; + source: "core" | "plugin" | "channel"; + pluginId?: string; + channelId?: string; +}) { + return { + ...params, + rawDescription: params.description, + }; +} + +function makeDefaultInventory(): EffectiveToolInventoryResult { + return { + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + makeInventoryEntry({ + id: "exec", + label: "Exec", + description: "Run shell commands", + source: "core", + }), + ], + }, + { + id: "plugin", + label: "Connected tools", + source: "plugin", + tools: [ + makeInventoryEntry({ + id: "docs_lookup", + label: "Docs Lookup", + description: "Search internal documentation", + source: "plugin", + pluginId: "docs", + }), + ], + }, + ], + }; +} + +const toolsTestState = vi.hoisted(() => { + const defaultResolveTools = (): EffectiveToolInventoryResult => makeDefaultInventory(); + + return { + resolveToolsImpl: defaultResolveTools, + resolveToolsMock: vi.fn((..._args: unknown[]) => defaultResolveTools()), + threadingContext: { + currentChannelId: "channel-123", + currentMessageId: "message-456", + }, + replyToMode: "all" as const, + }; +}); + +vi.mock("../../agents/tools-effective-inventory.js", () => ({ + resolveEffectiveToolInventory: (...args: unknown[]) => toolsTestState.resolveToolsMock(...args), +})); + +vi.mock("./agent-runner-utils.js", () => ({ + buildThreadingToolContext: () => toolsTestState.threadingContext, +})); + +vi.mock("./reply-threading.js", () => ({ + resolveReplyToMode: () => toolsTestState.replyToMode, +})); + +let buildCommandTestParamsImpl: typeof import("./commands.test-harness.js").buildCommandTestParams; +let handleToolsCommandImpl: typeof import("./commands-info.js").handleToolsCommand; + +async function loadToolsHarness(options?: { resolveTools?: () => EffectiveToolInventoryResult }) { + toolsTestState.resolveToolsImpl = options?.resolveTools ?? (() => makeDefaultInventory()); + toolsTestState.resolveToolsMock.mockImplementation((..._args: unknown[]) => + toolsTestState.resolveToolsImpl(), + ); + + return { + buildCommandTestParamsLocal: buildCommandTestParamsImpl, + handleToolsCommandLocal: handleToolsCommandImpl, + resolveToolsMock: toolsTestState.resolveToolsMock, + }; +} + +function buildConfig() { + return { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; +} + +function resolveToolsArg(resolveToolsMock: { mock: { calls: unknown[][] } }, index = 0) { + const [arg] = resolveToolsMock.mock.calls[index] ?? []; + if (!arg || typeof arg !== "object") { + throw new Error(`expected resolve tools call ${index + 1}`); + } + return arg as Record; +} + +describe("handleToolsCommand", () => { + beforeAll(async () => { + ({ buildCommandTestParams: buildCommandTestParamsImpl } = + await import("./commands.test-harness.js")); + ({ handleToolsCommand: handleToolsCommandImpl } = await import("./commands-info.js")); + }); + + beforeEach(() => { + vi.mocked(resolveSessionAgentId).mockReturnValue("main"); + toolsTestState.resolveToolsMock.mockReset(); + toolsTestState.resolveToolsImpl = () => makeDefaultInventory(); + setActivePluginRegistry(createTestRegistry([])); + }); + + it("renders a product-facing tool list", async () => { + const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = + await loadToolsHarness(); + const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.agentId = "main"; + params.provider = "openai"; + params.model = "gpt-4.1"; + params.ctx = { + ...params.ctx, + From: "telegram:group:abc123", + GroupChannel: "#ops", + GroupSpace: "workspace-1", + SenderName: "User Name", + SenderUsername: "user_name", + SenderE164: "+1000", + MessageThreadId: 99, + AccountId: "acct-1", + Provider: "telegram", + ChatType: "group", + }; + + const result = await handleToolsCommandLocal(params, true); + + expect(result?.reply?.text).toContain("Available tools"); + expect(result?.reply?.text).toContain("Profile: coding"); + expect(result?.reply?.text).toContain("Built-in tools"); + expect(result?.reply?.text).toContain("exec"); + expect(result?.reply?.text).toContain("Connected tools"); + expect(result?.reply?.text).toContain("docs_lookup (docs)"); + expect(result?.reply?.text).not.toContain("unavailable right now"); + const toolsArg = resolveToolsArg(resolveToolsMock); + expect(toolsArg).not.toHaveProperty("senderIsOwner"); + expect(toolsArg.senderId).toBeUndefined(); + expect(toolsArg.senderName).toBe("User Name"); + expect(toolsArg.senderUsername).toBe("user_name"); + expect(toolsArg.senderE164).toBe("+1000"); + expect(toolsArg.accountId).toBe("acct-1"); + expect(toolsArg.currentChannelId).toBe("channel-123"); + expect(toolsArg.currentThreadTs).toBe("99"); + expect(toolsArg.currentMessageId).toBe("message-456"); + expect(toolsArg.groupId).toBe("abc123"); + expect(toolsArg.groupChannel).toBe("#ops"); + expect(toolsArg.groupSpace).toBe("workspace-1"); + expect(toolsArg.replyToMode).toBe("all"); + }); + + it("returns usage when arguments are provided", async () => { + const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness(); + const result = await handleToolsCommandLocal( + buildCommandTestParamsLocal("/tools extra", buildConfig(), undefined, { + workspaceDir: "/tmp", + }), + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Usage: /tools [compact|verbose]" }, + }); + }); + + it("does not synthesize group ids for direct-chat sender ids", async () => { + const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = + await loadToolsHarness(); + const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.ctx = { + ...params.ctx, + From: "telegram:8231046597", + Provider: "telegram", + ChatType: "dm", + }; + + await handleToolsCommandLocal(params, true); + + expect(resolveToolsArg(resolveToolsMock).groupId).toBeUndefined(); + }); + + it("prefers the target session entry for tool inventory group metadata", async () => { + const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = + await loadToolsHarness(); + const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.sessionEntry = { + sessionId: "wrapper-session", + updatedAt: Date.now(), + groupId: "wrapper-group", + groupChannel: "#wrapper", + space: "wrapper-space", + }; + params.sessionStore = { + [params.sessionKey]: { + sessionId: "target-session", + updatedAt: Date.now(), + groupId: "target-group", + groupChannel: "#target", + space: "target-space", + }, + }; + params.ctx = { + ...params.ctx, + From: "telegram:group:abc123", + Provider: "telegram", + Surface: "telegram", + GroupChannel: "#ctx", + GroupSpace: "ctx-space", + }; + + await handleToolsCommandLocal(params, true); + + const toolsArg = resolveToolsArg(resolveToolsMock); + expect(toolsArg.groupId).toBe("target-group"); + expect(toolsArg.groupChannel).toBe("#target"); + expect(toolsArg.groupSpace).toBe("target-space"); + }); + + it("renders the detailed tool list in verbose mode", async () => { + const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness(); + const result = await handleToolsCommandLocal( + buildCommandTestParamsLocal("/tools verbose", buildConfig(), undefined, { + workspaceDir: "/tmp", + }), + true, + ); + + expect(result?.reply?.text).toContain("What this agent can use right now:"); + expect(result?.reply?.text).toContain("Profile: coding"); + expect(result?.reply?.text).toContain("Exec - Run shell commands"); + expect(result?.reply?.text).toContain("Docs Lookup - Search internal documentation"); + }); + + it("accepts explicit compact mode", async () => { + const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness(); + const result = await handleToolsCommandLocal( + buildCommandTestParamsLocal("/tools compact", buildConfig(), undefined, { + workspaceDir: "/tmp", + }), + true, + ); + + expect(result?.reply?.text).toContain("exec"); + expect(result?.reply?.text).toContain("Use /tools verbose for descriptions."); + }); + + it("ignores unauthorized senders", async () => { + const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness(); + const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.command = { + ...params.command, + isAuthorizedSender: false, + senderId: "unauthorized", + }; + + const result = await handleToolsCommandLocal(params, true); + + expect(result).toEqual({ shouldContinue: false }); + }); + + it("uses the configured default account when /tools omits AccountId", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: { + ...createChannelTestPluginBase({ + id: "telegram", + label: "Telegram", + config: { + listAccountIds: () => ["default", "work"], + defaultAccountId: () => "work", + resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "work" }), + }, + }), + }, + }, + ]), + ); + + const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = + await loadToolsHarness(); + const params = buildCommandTestParamsLocal( + "/tools", + { + commands: { text: true }, + channels: { telegram: { defaultAccount: "work" } }, + } as OpenClawConfig, + undefined, + { workspaceDir: "/tmp" }, + ); + params.agentId = "main"; + params.provider = "openai"; + params.model = "gpt-4.1"; + params.ctx = { + ...params.ctx, + OriginatingChannel: "telegram", + Provider: "telegram", + Surface: "telegram", + ChatType: "group", + AccountId: undefined, + }; + params.command = { + ...params.command, + channel: "telegram", + }; + + await handleToolsCommandLocal(params, true); + + expect(resolveToolsArg(resolveToolsMock).accountId).toBe("work"); + }); + + it("returns a concise fallback error on effective inventory failures", async () => { + const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness({ + resolveTools: () => { + throw new Error("boom"); + }, + }); + + const result = await handleToolsCommandLocal( + buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { workspaceDir: "/tmp" }), + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Couldn't load available tools right now. Try again in a moment." }, + }); + }); + + it("uses the canonical target session agent for /tools inventory", async () => { + vi.mocked(resolveSessionAgentId).mockReturnValue("target"); + const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = + await loadToolsHarness(); + const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.agentId = "main"; + params.sessionKey = "agent:target:whatsapp:direct:12345"; + + const result = await handleToolsCommandLocal(params, true); + + expect(result?.shouldContinue).toBe(false); + const toolsArg = resolveToolsArg(resolveToolsMock); + expect(toolsArg.agentId).toBe("target"); + expect(toolsArg.sessionKey).toBe("agent:target:whatsapp:direct:12345"); + }); + + it("does not forward a stale ambient agentDir for session-bound /tools", async () => { + vi.mocked(resolveSessionAgentId).mockReturnValue("target"); + const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = + await loadToolsHarness(); + const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { + workspaceDir: "/tmp", + }); + params.agentId = "main"; + params.agentDir = "/tmp/agents/main/agent"; + params.sessionKey = "agent:target:whatsapp:direct:12345"; + + const result = await handleToolsCommandLocal(params, true); + + expect(result?.shouldContinue).toBe(false); + const toolsArg = resolveToolsArg(resolveToolsMock); + expect(toolsArg.agentId).toBe("target"); + expect(toolsArg.agentDir).toBeUndefined(); + expect(toolsArg.sessionKey).toBe("agent:target:whatsapp:direct:12345"); + }); +}); diff --git a/src/auto-reply/reply/commands-info.tools.test.ts b/src/auto-reply/reply/commands-info.tools.test.ts deleted file mode 100644 index a46fd7420cc1..000000000000 --- a/src/auto-reply/reply/commands-info.tools.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -// Tests tool listing in info command responses. -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { EffectiveToolInventoryResult } from "../../agents/tools-effective-inventory.types.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { - createChannelTestPluginBase, - createTestRegistry, -} from "../../test-utils/channel-plugins.js"; - -function makeInventoryEntry(params: { - id: string; - label: string; - description: string; - source: "core" | "plugin" | "channel"; - pluginId?: string; - channelId?: string; -}) { - return { - ...params, - rawDescription: params.description, - }; -} - -function makeDefaultInventory(): EffectiveToolInventoryResult { - return { - agentId: "main", - profile: "coding", - groups: [ - { - id: "core", - label: "Built-in tools", - source: "core", - tools: [ - makeInventoryEntry({ - id: "exec", - label: "Exec", - description: "Run shell commands", - source: "core", - }), - ], - }, - { - id: "plugin", - label: "Connected tools", - source: "plugin", - tools: [ - makeInventoryEntry({ - id: "docs_lookup", - label: "Docs Lookup", - description: "Search internal documentation", - source: "plugin", - pluginId: "docs", - }), - ], - }, - ], - }; -} - -const toolsTestState = vi.hoisted(() => { - const defaultResolveTools = (): EffectiveToolInventoryResult => makeDefaultInventory(); - - return { - resolveToolsImpl: defaultResolveTools, - resolveToolsMock: vi.fn((..._args: unknown[]) => defaultResolveTools()), - threadingContext: { - currentChannelId: "channel-123", - currentMessageId: "message-456", - }, - replyToMode: "all" as const, - }; -}); - -vi.mock("../../agents/agent-scope.js", async () => { - const actual = await vi.importActual( - "../../agents/agent-scope.js", - ); - return { - ...actual, - resolveSessionAgentId: vi.fn(() => "main"), - }; -}); - -vi.mock("../../agents/tools-effective-inventory.js", () => ({ - resolveEffectiveToolInventory: (...args: unknown[]) => toolsTestState.resolveToolsMock(...args), -})); - -vi.mock("./agent-runner-utils.js", () => ({ - buildThreadingToolContext: () => toolsTestState.threadingContext, -})); - -vi.mock("./reply-threading.js", () => ({ - resolveReplyToMode: () => toolsTestState.replyToMode, -})); - -let buildCommandTestParamsImpl: typeof import("./commands.test-harness.js").buildCommandTestParams; -let handleToolsCommandImpl: typeof import("./commands-info.js").handleToolsCommand; - -async function loadToolsHarness(options?: { resolveTools?: () => EffectiveToolInventoryResult }) { - toolsTestState.resolveToolsImpl = options?.resolveTools ?? (() => makeDefaultInventory()); - toolsTestState.resolveToolsMock.mockImplementation((..._args: unknown[]) => - toolsTestState.resolveToolsImpl(), - ); - - return { - buildCommandTestParamsLocal: buildCommandTestParamsImpl, - handleToolsCommandLocal: handleToolsCommandImpl, - resolveToolsMock: toolsTestState.resolveToolsMock, - }; -} - -function buildConfig() { - return { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; -} - -function resolveToolsArg(resolveToolsMock: { mock: { calls: unknown[][] } }, index = 0) { - const [arg] = resolveToolsMock.mock.calls[index] ?? []; - if (!arg || typeof arg !== "object") { - throw new Error(`expected resolve tools call ${index + 1}`); - } - return arg as Record; -} - -describe("handleToolsCommand", () => { - beforeAll(async () => { - ({ buildCommandTestParams: buildCommandTestParamsImpl } = - await import("./commands.test-harness.js")); - ({ handleToolsCommand: handleToolsCommandImpl } = await import("./commands-info.js")); - }); - - beforeEach(() => { - toolsTestState.resolveToolsMock.mockReset(); - toolsTestState.resolveToolsImpl = () => makeDefaultInventory(); - setActivePluginRegistry(createTestRegistry([])); - }); - - it("renders a product-facing tool list", async () => { - const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = - await loadToolsHarness(); - const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { - workspaceDir: "/tmp", - }); - params.agentId = "main"; - params.provider = "openai"; - params.model = "gpt-4.1"; - params.ctx = { - ...params.ctx, - From: "telegram:group:abc123", - GroupChannel: "#ops", - GroupSpace: "workspace-1", - SenderName: "User Name", - SenderUsername: "user_name", - SenderE164: "+1000", - MessageThreadId: 99, - AccountId: "acct-1", - Provider: "telegram", - ChatType: "group", - }; - - const result = await handleToolsCommandLocal(params, true); - - expect(result?.reply?.text).toContain("Available tools"); - expect(result?.reply?.text).toContain("Profile: coding"); - expect(result?.reply?.text).toContain("Built-in tools"); - expect(result?.reply?.text).toContain("exec"); - expect(result?.reply?.text).toContain("Connected tools"); - expect(result?.reply?.text).toContain("docs_lookup (docs)"); - expect(result?.reply?.text).not.toContain("unavailable right now"); - const toolsArg = resolveToolsArg(resolveToolsMock); - expect(toolsArg).not.toHaveProperty("senderIsOwner"); - expect(toolsArg.senderId).toBeUndefined(); - expect(toolsArg.senderName).toBe("User Name"); - expect(toolsArg.senderUsername).toBe("user_name"); - expect(toolsArg.senderE164).toBe("+1000"); - expect(toolsArg.accountId).toBe("acct-1"); - expect(toolsArg.currentChannelId).toBe("channel-123"); - expect(toolsArg.currentThreadTs).toBe("99"); - expect(toolsArg.currentMessageId).toBe("message-456"); - expect(toolsArg.groupId).toBe("abc123"); - expect(toolsArg.groupChannel).toBe("#ops"); - expect(toolsArg.groupSpace).toBe("workspace-1"); - expect(toolsArg.replyToMode).toBe("all"); - }); - - it("returns usage when arguments are provided", async () => { - const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness(); - const result = await handleToolsCommandLocal( - buildCommandTestParamsLocal("/tools extra", buildConfig(), undefined, { - workspaceDir: "/tmp", - }), - true, - ); - - expect(result).toEqual({ - shouldContinue: false, - reply: { text: "Usage: /tools [compact|verbose]" }, - }); - }); - - it("does not synthesize group ids for direct-chat sender ids", async () => { - const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = - await loadToolsHarness(); - const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { - workspaceDir: "/tmp", - }); - params.ctx = { - ...params.ctx, - From: "telegram:8231046597", - Provider: "telegram", - ChatType: "dm", - }; - - await handleToolsCommandLocal(params, true); - - expect(resolveToolsArg(resolveToolsMock).groupId).toBeUndefined(); - }); - - it("prefers the target session entry for tool inventory group metadata", async () => { - const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = - await loadToolsHarness(); - const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { - workspaceDir: "/tmp", - }); - params.sessionEntry = { - sessionId: "wrapper-session", - updatedAt: Date.now(), - groupId: "wrapper-group", - groupChannel: "#wrapper", - space: "wrapper-space", - }; - params.sessionStore = { - [params.sessionKey]: { - sessionId: "target-session", - updatedAt: Date.now(), - groupId: "target-group", - groupChannel: "#target", - space: "target-space", - }, - }; - params.ctx = { - ...params.ctx, - From: "telegram:group:abc123", - Provider: "telegram", - Surface: "telegram", - GroupChannel: "#ctx", - GroupSpace: "ctx-space", - }; - - await handleToolsCommandLocal(params, true); - - const toolsArg = resolveToolsArg(resolveToolsMock); - expect(toolsArg.groupId).toBe("target-group"); - expect(toolsArg.groupChannel).toBe("#target"); - expect(toolsArg.groupSpace).toBe("target-space"); - }); - - it("renders the detailed tool list in verbose mode", async () => { - const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness(); - const result = await handleToolsCommandLocal( - buildCommandTestParamsLocal("/tools verbose", buildConfig(), undefined, { - workspaceDir: "/tmp", - }), - true, - ); - - expect(result?.reply?.text).toContain("What this agent can use right now:"); - expect(result?.reply?.text).toContain("Profile: coding"); - expect(result?.reply?.text).toContain("Exec - Run shell commands"); - expect(result?.reply?.text).toContain("Docs Lookup - Search internal documentation"); - }); - - it("accepts explicit compact mode", async () => { - const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness(); - const result = await handleToolsCommandLocal( - buildCommandTestParamsLocal("/tools compact", buildConfig(), undefined, { - workspaceDir: "/tmp", - }), - true, - ); - - expect(result?.reply?.text).toContain("exec"); - expect(result?.reply?.text).toContain("Use /tools verbose for descriptions."); - }); - - it("ignores unauthorized senders", async () => { - const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness(); - const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { - workspaceDir: "/tmp", - }); - params.command = { - ...params.command, - isAuthorizedSender: false, - senderId: "unauthorized", - }; - - const result = await handleToolsCommandLocal(params, true); - - expect(result).toEqual({ shouldContinue: false }); - }); - - it("uses the configured default account when /tools omits AccountId", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - source: "test", - plugin: { - ...createChannelTestPluginBase({ - id: "telegram", - label: "Telegram", - config: { - listAccountIds: () => ["default", "work"], - defaultAccountId: () => "work", - resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "work" }), - }, - }), - }, - }, - ]), - ); - - const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = - await loadToolsHarness(); - const params = buildCommandTestParamsLocal( - "/tools", - { - commands: { text: true }, - channels: { telegram: { defaultAccount: "work" } }, - } as OpenClawConfig, - undefined, - { workspaceDir: "/tmp" }, - ); - params.agentId = "main"; - params.provider = "openai"; - params.model = "gpt-4.1"; - params.ctx = { - ...params.ctx, - OriginatingChannel: "telegram", - Provider: "telegram", - Surface: "telegram", - ChatType: "group", - AccountId: undefined, - }; - params.command = { - ...params.command, - channel: "telegram", - }; - - await handleToolsCommandLocal(params, true); - - expect(resolveToolsArg(resolveToolsMock).accountId).toBe("work"); - }); - - it("returns a concise fallback error on effective inventory failures", async () => { - const { buildCommandTestParamsLocal, handleToolsCommandLocal } = await loadToolsHarness({ - resolveTools: () => { - throw new Error("boom"); - }, - }); - - const result = await handleToolsCommandLocal( - buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { workspaceDir: "/tmp" }), - true, - ); - - expect(result).toEqual({ - shouldContinue: false, - reply: { text: "Couldn't load available tools right now. Try again in a moment." }, - }); - }); - - it("uses the canonical target session agent for /tools inventory", async () => { - const { resolveSessionAgentId } = await import("../../agents/agent-scope.js"); - vi.mocked(resolveSessionAgentId).mockReturnValue("target"); - const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = - await loadToolsHarness(); - const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { - workspaceDir: "/tmp", - }); - params.agentId = "main"; - params.sessionKey = "agent:target:whatsapp:direct:12345"; - - const result = await handleToolsCommandLocal(params, true); - - expect(result?.shouldContinue).toBe(false); - const toolsArg = resolveToolsArg(resolveToolsMock); - expect(toolsArg.agentId).toBe("target"); - expect(toolsArg.sessionKey).toBe("agent:target:whatsapp:direct:12345"); - }); - - it("does not forward a stale ambient agentDir for session-bound /tools", async () => { - const { resolveSessionAgentId } = await import("../../agents/agent-scope.js"); - vi.mocked(resolveSessionAgentId).mockReturnValue("target"); - const { buildCommandTestParamsLocal, handleToolsCommandLocal, resolveToolsMock } = - await loadToolsHarness(); - const params = buildCommandTestParamsLocal("/tools", buildConfig(), undefined, { - workspaceDir: "/tmp", - }); - params.agentId = "main"; - params.agentDir = "/tmp/agents/main/agent"; - params.sessionKey = "agent:target:whatsapp:direct:12345"; - - const result = await handleToolsCommandLocal(params, true); - - expect(result?.shouldContinue).toBe(false); - const toolsArg = resolveToolsArg(resolveToolsMock); - expect(toolsArg.agentId).toBe("target"); - expect(toolsArg.agentDir).toBeUndefined(); - expect(toolsArg.sessionKey).toBe("agent:target:whatsapp:direct:12345"); - }); -}); diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 136ee52cf7af..19565a4b95b8 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -1,9 +1,9 @@ -// Tests status command rendering for sessions, agents, and diagnostics. +// Tests status command rendering for sessions, agents, diagnostics, and model defaults. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../../test/helpers/normalize-text.js"; import { saveAuthProfileStore } from "../../agents/auth-profiles/store.js"; import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js"; @@ -13,6 +13,7 @@ import { addSubagentRunForTests, resetSubagentRegistryForTests, } from "../../agents/subagent-registry.test-helpers.js"; +import type { OpenClawConfig } from "../../config/config.js"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; import { completeTaskRunByRunId, @@ -29,6 +30,8 @@ import { configureInMemoryTaskRegistryStoreForTests, } from "./commands.test-harness.js"; +// Tests status command rendering for sessions, agents, and diagnostics. + type LoadProviderUsageSummary = typeof import("../../infra/provider-usage.js").loadProviderUsageSummary; @@ -2308,3 +2311,179 @@ describe("buildStatusReply subagent summary", () => { }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ + +async function buildKiraStatusReply(cfg: OpenClawConfig) { + return await buildStatusReply({ + cfg, + command: { + isAuthorizedSender: true, + channel: "whatsapp", + } as never, + sessionKey: "agent:kira:main", + provider: "openai", + model: "gpt-5.4", + contextTokens: 0, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + }); +} + +describe("buildStatusReply", () => { + beforeAll(async () => { + await buildKiraStatusReply({ + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { + model: "openai/gpt-5.4", + }, + }, + channels: { + whatsapp: { allowFrom: ["*"] }, + }, + } as OpenClawConfig); + }); + + it("shows per-agent thinkingDefault in the status card", async () => { + const cfg = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { + model: "openai/gpt-5.4", + }, + list: [ + { + id: "kira", + model: "openai/gpt-5.4", + thinkingDefault: "xhigh", + }, + ], + }, + channels: { + whatsapp: { allowFrom: ["*"] }, + }, + } as OpenClawConfig; + + const reply = await buildKiraStatusReply(cfg); + + expect(reply?.text).toContain("Think: xhigh"); + }); + + it("shows per-agent fallback overrides in the status card", async () => { + const cfg = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + list: [ + { + id: "kira", + model: { + primary: "openai/gpt-5.4", + fallbacks: ["google/gemini-2.5-flash"], + }, + }, + ], + }, + channels: { + whatsapp: { allowFrom: ["*"] }, + }, + } as OpenClawConfig; + + const reply = await buildKiraStatusReply(cfg); + + expect(reply?.text).toContain("Fallbacks: google/gemini-2.5-flash"); + expect(reply?.text).not.toContain("Fallbacks: anthropic/claude-sonnet-4-6"); + }); + + it("keeps default fallback config when the agent has no explicit model", async () => { + const cfg = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + list: [ + { + id: "kira", + }, + ], + }, + channels: { + whatsapp: { allowFrom: ["*"] }, + }, + } as OpenClawConfig; + + const reply = await buildKiraStatusReply(cfg); + + expect(reply?.text).toContain("Fallbacks: anthropic/claude-sonnet-4-6"); + }); + + it("keeps agent primary strict when the agent has no explicit fallback override", async () => { + const cfg = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + list: [ + { + id: "kira", + model: { + primary: "openai/gpt-5.4", + }, + }, + ], + }, + channels: { + whatsapp: { allowFrom: ["*"] }, + }, + } as OpenClawConfig; + + const reply = await buildKiraStatusReply(cfg); + + expect(reply?.text).not.toContain("Fallbacks:"); + }); + + it("treats an explicit empty per-agent fallback override as disabling inherited fallbacks", async () => { + const cfg = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + list: [ + { + id: "kira", + model: { + primary: "openai/gpt-5.4", + fallbacks: [], + }, + }, + ], + }, + channels: { + whatsapp: { allowFrom: ["*"] }, + }, + } as OpenClawConfig; + + const reply = await buildKiraStatusReply(cfg); + + expect(reply?.text).not.toContain("Fallbacks:"); + }); +}); diff --git a/src/auto-reply/reply/commands-status.thinking-default.test.ts b/src/auto-reply/reply/commands-status.thinking-default.test.ts deleted file mode 100644 index 69fd7211e2d5..000000000000 --- a/src/auto-reply/reply/commands-status.thinking-default.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -// Tests status command defaults for thinking and reasoning display. -import { beforeAll, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; - -vi.mock("../../agents/fast-mode.js", () => ({ - resolveFastModeState: () => ({ mode: false, enabled: false, source: "default" }), -})); - -vi.mock("../../agents/model-auth-label.js", () => ({ - resolveModelAuthLabel: () => "api-key", -})); - -vi.mock("../../agents/subagent-registry.js", () => ({ - listSubagentRunsForRequester: () => [], -})); - -vi.mock("../../infra/provider-usage.js", () => ({ - resolveUsageProviderId: () => undefined, - loadProviderUsageSummary: async () => ({ - updatedAt: Date.now(), - providers: [], - }), - formatUsageWindowSummary: () => undefined, -})); - -vi.mock("../group-activation.js", () => ({ - normalizeGroupActivation: (value: unknown) => value, -})); - -vi.mock("./queue.js", async () => { - const actual = await vi.importActual("./queue.js"); - return { - ...actual, - getFollowupQueueDepth: () => 0, - resolveQueueSettings: () => ({ mode: "interrupt" }), - }; -}); - -const { buildStatusReply } = await import("./commands-status.js"); - -async function buildKiraStatusReply(cfg: OpenClawConfig) { - return await buildStatusReply({ - cfg, - command: { - isAuthorizedSender: true, - channel: "whatsapp", - } as never, - sessionKey: "agent:kira:main", - provider: "openai", - model: "gpt-5.4", - contextTokens: 0, - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolveDefaultThinkingLevel: async () => undefined, - isGroup: false, - defaultGroupActivation: () => "mention", - }); -} - -describe("buildStatusReply", () => { - beforeAll(async () => { - await buildKiraStatusReply({ - session: { mainKey: "main", scope: "per-sender" }, - agents: { - defaults: { - model: "openai/gpt-5.4", - }, - }, - channels: { - whatsapp: { allowFrom: ["*"] }, - }, - } as OpenClawConfig); - }); - - it("shows per-agent thinkingDefault in the status card", async () => { - const cfg = { - session: { mainKey: "main", scope: "per-sender" }, - agents: { - defaults: { - model: "openai/gpt-5.4", - }, - list: [ - { - id: "kira", - model: "openai/gpt-5.4", - thinkingDefault: "xhigh", - }, - ], - }, - channels: { - whatsapp: { allowFrom: ["*"] }, - }, - } as OpenClawConfig; - - const reply = await buildKiraStatusReply(cfg); - - expect(reply?.text).toContain("Think: xhigh"); - }); - - it("shows per-agent fallback overrides in the status card", async () => { - const cfg = { - session: { mainKey: "main", scope: "per-sender" }, - agents: { - defaults: { - model: { - primary: "openai/gpt-5.4", - fallbacks: ["anthropic/claude-sonnet-4-6"], - }, - }, - list: [ - { - id: "kira", - model: { - primary: "openai/gpt-5.4", - fallbacks: ["google/gemini-2.5-flash"], - }, - }, - ], - }, - channels: { - whatsapp: { allowFrom: ["*"] }, - }, - } as OpenClawConfig; - - const reply = await buildKiraStatusReply(cfg); - - expect(reply?.text).toContain("Fallbacks: google/gemini-2.5-flash"); - expect(reply?.text).not.toContain("Fallbacks: anthropic/claude-sonnet-4-6"); - }); - - it("keeps default fallback config when the agent has no explicit model", async () => { - const cfg = { - session: { mainKey: "main", scope: "per-sender" }, - agents: { - defaults: { - model: { - primary: "openai/gpt-5.4", - fallbacks: ["anthropic/claude-sonnet-4-6"], - }, - }, - list: [ - { - id: "kira", - }, - ], - }, - channels: { - whatsapp: { allowFrom: ["*"] }, - }, - } as OpenClawConfig; - - const reply = await buildKiraStatusReply(cfg); - - expect(reply?.text).toContain("Fallbacks: anthropic/claude-sonnet-4-6"); - }); - - it("keeps agent primary strict when the agent has no explicit fallback override", async () => { - const cfg = { - session: { mainKey: "main", scope: "per-sender" }, - agents: { - defaults: { - model: { - primary: "openai/gpt-5.4", - fallbacks: ["anthropic/claude-sonnet-4-6"], - }, - }, - list: [ - { - id: "kira", - model: { - primary: "openai/gpt-5.4", - }, - }, - ], - }, - channels: { - whatsapp: { allowFrom: ["*"] }, - }, - } as OpenClawConfig; - - const reply = await buildKiraStatusReply(cfg); - - expect(reply?.text).not.toContain("Fallbacks:"); - }); - - it("treats an explicit empty per-agent fallback override as disabling inherited fallbacks", async () => { - const cfg = { - session: { mainKey: "main", scope: "per-sender" }, - agents: { - defaults: { - model: { - primary: "openai/gpt-5.4", - fallbacks: ["anthropic/claude-sonnet-4-6"], - }, - }, - list: [ - { - id: "kira", - model: { - primary: "openai/gpt-5.4", - fallbacks: [], - }, - }, - ], - }, - channels: { - whatsapp: { allowFrom: ["*"] }, - }, - } as OpenClawConfig; - - const reply = await buildKiraStatusReply(cfg); - - expect(reply?.text).not.toContain("Fallbacks:"); - }); -});