// Auto-reply trigger harness builds trigger handling scenarios for tests. import { rmSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import { join } from "node:path"; import { afterAll, afterEach, beforeAll, expect, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles.js"; import type { EmbeddedAgentQueueMessageOutcome } from "../../../src/agents/embedded-agent-runner/runs.js"; import { withFastReplyConfig } from "../../../src/auto-reply/reply/get-reply-fast-path.js"; import type { OpenClawConfig } from "../../../src/config/types.openclaw.js"; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). type AnyMock = any; type AnyMocks = Record; function getSharedMocks(key: string, create: () => T): T { const symbol = Symbol.for(key); const store = globalThis as Record; if (!store[symbol]) { store[symbol] = create(); } return store[symbol]; } const embeddedAgentMocks = getSharedMocks("openclaw.trigger-handling.embedded-agent-mocks", () => ({ abortEmbeddedAgentRun: vi.fn().mockReturnValue(false), compactEmbeddedAgentSession: vi.fn(), runEmbeddedAgent: vi.fn(), queueEmbeddedAgentMessageWithOutcome: vi.fn( (sessionId: string, _text?: string, _options?: unknown): EmbeddedAgentQueueMessageOutcome => ({ queued: false, sessionId, reason: "not_streaming", gatewayHealth: "live", }), ), resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined), isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false), isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false), })); export function getAbortEmbeddedAgentRunMock(): AnyMock { return embeddedAgentMocks.abortEmbeddedAgentRun; } export function getCompactEmbeddedAgentSessionMock(): AnyMock { return embeddedAgentMocks.compactEmbeddedAgentSession; } export function getRunEmbeddedAgentMock(): AnyMock { return embeddedAgentMocks.runEmbeddedAgent; } const installEmbeddedAgentMock = () => vi.doMock("../../../src/agents/embedded-agent.js", () => ({ abortEmbeddedAgentRun: (...args: unknown[]) => embeddedAgentMocks.abortEmbeddedAgentRun(...args), compactEmbeddedAgentSession: (...args: unknown[]) => embeddedAgentMocks.compactEmbeddedAgentSession(...args), runEmbeddedAgent: (...args: unknown[]) => embeddedAgentMocks.runEmbeddedAgent(...args), queueEmbeddedAgentMessageWithOutcome: (sessionId: string, text: string, options?: unknown) => embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome(sessionId, text, options), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, resolveActiveEmbeddedRunSessionId: (...args: unknown[]) => embeddedAgentMocks.resolveActiveEmbeddedRunSessionId(...args), isEmbeddedAgentRunActive: (...args: unknown[]) => embeddedAgentMocks.isEmbeddedAgentRunActive(...args), isEmbeddedAgentRunStreaming: (...args: unknown[]) => embeddedAgentMocks.isEmbeddedAgentRunStreaming(...args), })); installEmbeddedAgentMock(); vi.doMock("../../../src/agents/embedded-agent-runner/runs.js", () => ({ abortEmbeddedAgentRun: (...args: unknown[]) => embeddedAgentMocks.abortEmbeddedAgentRun(...args), formatEmbeddedAgentQueueFailureSummary: (outcome: { reason?: string; sessionId?: string }) => outcome.reason && outcome.sessionId ? `queue_message_failed reason=${outcome.reason} sessionId=${outcome.sessionId} gatewayHealth=live` : undefined, queueEmbeddedAgentMessageWithOutcome: (sessionId: string, text: string, options?: unknown) => embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome(sessionId, text, options), resolveActiveEmbeddedRunSessionId: (...args: unknown[]) => embeddedAgentMocks.resolveActiveEmbeddedRunSessionId(...args), })); const providerUsageMocks = vi.hoisted(() => ({ loadProviderUsageSummary: vi.fn().mockResolvedValue({ updatedAt: 0, providers: [], }), formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"), formatUsageWindowSummary: vi.fn().mockReturnValue("Claude 80% left"), resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]), })); export function getProviderUsageMocks(): AnyMocks { return providerUsageMocks; } vi.mock("../../../src/infra/provider-usage.js", () => providerUsageMocks); const DEFAULT_MODEL_CATALOG = [ { provider: "anthropic", id: "claude-opus-4-7", name: "Claude Opus 4.7", contextWindow: 200000, }, { provider: "openrouter", id: "anthropic/claude-opus-4-7", name: "Claude Opus 4.7 (OpenRouter)", contextWindow: 200000, }, { provider: "openai", id: "gpt-5.5-mini", name: "GPT-5.5 mini" }, { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }, { provider: "openai", id: "gpt-5.5", name: "GPT-5.5 (Codex)" }, { provider: "minimax", id: "MiniMax-M2.7", name: "MiniMax M2.7" }, ]; const modelCatalogMocks = getSharedMocks("openclaw.trigger-handling.model-catalog-mocks", () => ({ loadManifestModelCatalog: vi.fn(() => DEFAULT_MODEL_CATALOG), loadModelCatalog: vi.fn().mockResolvedValue(DEFAULT_MODEL_CATALOG), resetModelCatalogCacheForTest: vi.fn(), })); const installModelCatalogMock = () => vi.doMock("../../../src/agents/model-catalog.js", () => modelCatalogMocks); installModelCatalogMock(); vi.doMock("../../../src/agents/model-catalog.runtime.js", () => ({ loadManifestModelCatalog: () => modelCatalogMocks.loadManifestModelCatalog(), loadModelCatalog: (...args: unknown[]) => modelCatalogMocks.loadModelCatalog(...args), })); vi.doMock("../../../src/plugins/provider-runtime.runtime.js", () => ({ augmentModelCatalogWithProviderPlugins: async (params: { catalog?: unknown[] }) => params.catalog ?? [], buildProviderAuthDoctorHintWithPlugin: () => undefined, buildProviderMissingAuthMessageWithPlugin: () => undefined, formatProviderAuthProfileApiKeyWithPlugin: (params: { apiKey?: string }) => params.apiKey, prepareProviderRuntimeAuth: async () => undefined, refreshProviderOAuthCredentialWithPlugin: async () => undefined, })); const modelFallbackMocks = getSharedMocks("openclaw.trigger-handling.model-fallback-mocks", () => ({ runWithModelFallback: vi.fn( async (params: { provider: string; model: string; run: (provider: string, model: string, runOptions?: unknown) => Promise; }) => ({ result: await params.run(params.provider, params.model), provider: params.provider, model: params.model, attempts: [], }), ), })); const installModelFallbackMock = () => vi.doMock("../../../src/agents/model-fallback.js", () => modelFallbackMocks); installModelFallbackMock(); vi.doMock("../../../src/infra/git-commit.js", () => ({ resolveCommitHash: vi.fn(() => "abcdef0"), })); const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({ webAuthExists: vi.fn().mockResolvedValue(true), getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), })); const installWebSessionMock = () => vi.doMock("../../../src/plugins/runtime/runtime-web-channel-plugin.js", () => ({ webAuthExists: (...args: unknown[]) => webSessionMocks.webAuthExists(...args), getWebAuthAgeMs: (...args: unknown[]) => webSessionMocks.getWebAuthAgeMs(...args), readWebSelfId: (...args: unknown[]) => webSessionMocks.readWebSelfId(...args), })); installWebSessionMock(); export const MAIN_SESSION_KEY = "agent:main:main"; type TempHomeEnvSnapshot = { home: string | undefined; userProfile: string | undefined; homeDrive: string | undefined; homePath: string | undefined; openclawHome: string | undefined; stateDir: string | undefined; }; let suiteTempHomeRoot = ""; let suiteTempHomeId = 0; function snapshotTempHomeEnv(): TempHomeEnvSnapshot { return { home: process.env.HOME, userProfile: process.env.USERPROFILE, homeDrive: process.env.HOMEDRIVE, homePath: process.env.HOMEPATH, openclawHome: process.env.OPENCLAW_HOME, stateDir: process.env.OPENCLAW_STATE_DIR, }; } function restoreTempHomeEnv(snapshot: TempHomeEnvSnapshot): void { const restoreKey = (key: string, value: string | undefined) => { if (value === undefined) { delete process.env[key]; return; } process.env[key] = value; }; restoreKey("HOME", snapshot.home); restoreKey("USERPROFILE", snapshot.userProfile); restoreKey("HOMEDRIVE", snapshot.homeDrive); restoreKey("HOMEPATH", snapshot.homePath); restoreKey("OPENCLAW_HOME", snapshot.openclawHome); restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir); } function setTempHomeEnv(home: string): void { process.env.HOME = home; process.env.USERPROFILE = home; delete process.env.OPENCLAW_HOME; process.env.OPENCLAW_STATE_DIR = join(home, ".openclaw"); if (process.platform !== "win32") { return; } const match = home.match(/^([A-Za-z]:)(.*)$/); if (!match) { return; } process.env.HOMEDRIVE = match[1]; process.env.HOMEPATH = match[2] || "\\"; } beforeAll(async () => { suiteTempHomeRoot = await fs.mkdtemp(join(os.tmpdir(), "openclaw-triggers-suite-")); }); afterAll(async () => { if (!suiteTempHomeRoot) { return; } try { rmSync(suiteTempHomeRoot, { recursive: true, force: true }); } catch { // Best-effort temp cleanup only. } suiteTempHomeRoot = ""; suiteTempHomeId = 0; }); export async function withTempHome(fn: (home: string) => Promise): Promise { const home = join(suiteTempHomeRoot, `case-${++suiteTempHomeId}`); const snapshot = snapshotTempHomeEnv(); await fs.mkdir(join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); setTempHomeEnv(home); try { // Hard reset shared mocks so non-isolated runs don't inherit prior behavior. embeddedAgentMocks.runEmbeddedAgent.mockReset(); embeddedAgentMocks.abortEmbeddedAgentRun.mockReset().mockReturnValue(false); embeddedAgentMocks.compactEmbeddedAgentSession.mockReset(); embeddedAgentMocks.queueEmbeddedAgentMessageWithOutcome .mockReset() .mockImplementation((sessionId: string) => ({ queued: false, sessionId, reason: "not_streaming", gatewayHealth: "live", })); embeddedAgentMocks.isEmbeddedAgentRunActive.mockReset().mockReturnValue(false); embeddedAgentMocks.isEmbeddedAgentRunStreaming.mockReset().mockReturnValue(false); modelFallbackMocks.runWithModelFallback.mockClear(); return await fn(home); } finally { restoreTempHomeEnv(snapshot); } } export function makeCfg(home: string): OpenClawConfig { return withFastReplyConfig({ agents: { defaults: { model: { primary: "anthropic/claude-opus-4-7" }, workspace: join(home, "openclaw"), // Test harness: avoid 1s coalescer idle sleeps that dominate trigger suites. blockStreamingCoalesce: { idleMs: 1 }, // Trigger tests assert routing/authorization behavior, not delivery pacing. humanDelay: { mode: "off" }, }, }, channels: { whatsapp: { allowFrom: ["*"], }, }, messages: { queue: { debounceMs: 0, }, }, session: { store: join(home, "sessions.json") }, } as OpenClawConfig); } export async function loadGetReplyFromConfig() { return (await import("../../../src/auto-reply/reply.js")).getReplyFromConfig; } export function installTriggerHandlingReplyHarness( setGetReplyFromConfig: ( getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig, ) => void, ): void { beforeAll(async () => { setGetReplyFromConfig(await loadGetReplyFromConfig()); }); installTriggerHandlingE2eTestHooks(); } export function requireSessionStorePath(cfg: { session?: { store?: string } }): string { const storePath = cfg.session?.store; if (!storePath) { throw new Error("expected session store path"); } return storePath; } export async function expectInlineCommandHandledAndStripped(params: { home: string; getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig; body: string; stripToken: string; blockReplyContains: string; requestOverrides?: Record; }) { const runEmbeddedAgentMock = mockRunEmbeddedAgentOk(); runEmbeddedAgentMock.mockClear(); const { blockReplies, handlers } = createBlockReplyCollector(); const res = await params.getReplyFromConfig( { Body: params.body, From: "+1002", To: "+2000", CommandAuthorized: true, ...params.requestOverrides, }, handlers, makeCfg(params.home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(blockReplies.length).toBe(1); expect(blockReplies[0]?.text).toContain(params.blockReplyContains); expect(runEmbeddedAgentMock).toHaveBeenCalled(); const lastCall = runEmbeddedAgentMock.mock.calls[runEmbeddedAgentMock.mock.calls.length - 1]; const prompt = lastCall?.[0]?.prompt ?? ""; expect(prompt).not.toContain(params.stripToken); expect(text).toBe("ok"); } export async function expectBareNewOrResetAcknowledged(params: { home: string; body: "/new" | "/reset"; getReplyFromConfig: typeof import("../../../src/auto-reply/reply.js").getReplyFromConfig; }) { const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); runEmbeddedAgentMock.mockClear(); runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "hello" }], meta: { durationMs: 1, agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); const res = await params.getReplyFromConfig( { Body: params.body, From: "+1003", To: "+2000", CommandAuthorized: true, }, {}, makeCfg(params.home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe(params.body === "/reset" ? "✅ Session reset." : "✅ New session started."); expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); } export function installTriggerHandlingE2eTestHooks() { afterEach(() => { clearRuntimeAuthProfileStoreSnapshots(); vi.clearAllMocks(); }); } export function mockRunEmbeddedAgentOk(text = "ok"): AnyMock { const runEmbeddedAgentMock = getRunEmbeddedAgentMock(); runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text }], meta: { durationMs: 1, agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, }); return runEmbeddedAgentMock; } export function createBlockReplyCollector() { const blockReplies: Array<{ text?: string }> = []; return { blockReplies, handlers: { onBlockReply: async (payload: { text?: string }) => { blockReplies.push(payload); }, }, }; }