import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { ReplyDispatcher } from "./reply-dispatcher.js"; import { buildTestCtx } from "./test-ctx.js"; const mocks = vi.hoisted(() => ({ routeReply: vi.fn(async () => ({ ok: true, messageId: "mock" })), tryFastAbortFromMessage: vi.fn(async () => ({ handled: false, aborted: false, })), })); const diagnosticMocks = vi.hoisted(() => ({ logMessageQueued: vi.fn(), logMessageProcessed: vi.fn(), logSessionStateChange: vi.fn(), })); const hookMocks = vi.hoisted(() => ({ runner: { hasHooks: vi.fn(() => false), runMessageReceived: vi.fn(async () => {}), }, })); vi.mock("./route-reply.js", () => ({ isRoutableChannel: (channel: string | undefined) => Boolean( channel && ["telegram", "slack", "discord", "signal", "imessage", "whatsapp"].includes(channel), ), routeReply: mocks.routeReply, })); vi.mock("./abort.js", () => ({ tryFastAbortFromMessage: mocks.tryFastAbortFromMessage, formatAbortReplyText: (stoppedSubagents?: number) => { if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) { return "⚙️ Agent was aborted."; } const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents"; return `⚙️ Agent was aborted. Stopped ${stoppedSubagents} ${label}.`; }, })); vi.mock("../../logging/diagnostic.js", () => ({ logMessageQueued: diagnosticMocks.logMessageQueued, logMessageProcessed: diagnosticMocks.logMessageProcessed, logSessionStateChange: diagnosticMocks.logSessionStateChange, })); vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookMocks.runner, })); const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); const { resetInboundDedupe } = await import("./inbound-dedupe.js"); const noAbortResult = { handled: false, aborted: false } as const; const emptyConfig = {} as OpenClawConfig; type DispatchReplyArgs = Parameters[0]; function createDispatcher(): ReplyDispatcher { return { sendToolResult: vi.fn(() => true), sendBlockReply: vi.fn(() => true), sendFinalReply: vi.fn(() => true), waitForIdle: vi.fn(async () => {}), getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), markComplete: vi.fn(), }; } function setNoAbort() { mocks.tryFastAbortFromMessage.mockResolvedValue(noAbortResult); } function firstToolResultPayload(dispatcher: ReplyDispatcher): ReplyPayload | undefined { return (dispatcher.sendToolResult as ReturnType).mock.calls[0]?.[0] as | ReplyPayload | undefined; } async function dispatchTwiceWithFreshDispatchers(params: Omit) { await dispatchReplyFromConfig({ ...params, dispatcher: createDispatcher(), }); await dispatchReplyFromConfig({ ...params, dispatcher: createDispatcher(), }); } describe("dispatchReplyFromConfig", () => { beforeEach(() => { resetInboundDedupe(); diagnosticMocks.logMessageQueued.mockReset(); diagnosticMocks.logMessageProcessed.mockReset(); diagnosticMocks.logSessionStateChange.mockReset(); hookMocks.runner.hasHooks.mockReset(); hookMocks.runner.hasHooks.mockReturnValue(false); hookMocks.runner.runMessageReceived.mockReset(); }); it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", Surface: undefined, OriginatingChannel: "slack", OriginatingTo: "channel:C123", }); const replyResolver = async ( _ctx: MsgContext, _opts: GetReplyOptions | undefined, _cfg: OpenClawConfig, ) => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(mocks.routeReply).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("routes when OriginatingChannel differs from Provider", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", AccountId: "acc-1", MessageThreadId: 123, OriginatingChannel: "telegram", OriginatingTo: "telegram:999", }); const replyResolver = async ( _ctx: MsgContext, _opts: GetReplyOptions | undefined, _cfg: OpenClawConfig, ) => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ channel: "telegram", to: "telegram:999", accountId: "acc-1", threadId: 123, }), ); }); it("routes media-only tool results when summaries are suppressed", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", ChatType: "group", AccountId: "acc-1", OriginatingChannel: "telegram", OriginatingTo: "telegram:999", }); const replyResolver = async ( _ctx: MsgContext, opts: GetReplyOptions | undefined, _cfg: OpenClawConfig, ) => { expect(opts?.onToolResult).toBeDefined(); await opts?.onToolResult?.({ text: "NO_REPLY", mediaUrls: ["https://example.com/tts-routed.opus"], }); return undefined; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledTimes(1); const routed = mocks.routeReply.mock.calls[0]?.[0] as { payload?: ReplyPayload } | undefined; expect(routed?.payload?.mediaUrls).toEqual(["https://example.com/tts-routed.opus"]); expect(routed?.payload?.text).toBeUndefined(); }); it("provides onToolResult in DM sessions", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "direct", }); const replyResolver = async ( _ctx: MsgContext, opts: GetReplyOptions | undefined, _cfg: OpenClawConfig, ) => { expect(opts?.onToolResult).toBeDefined(); expect(typeof opts?.onToolResult).toBe("function"); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("suppresses group tool summaries but still forwards tool media", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "group", }); const replyResolver = async ( _ctx: MsgContext, opts: GetReplyOptions | undefined, _cfg: OpenClawConfig, ) => { expect(opts?.onToolResult).toBeDefined(); await opts?.onToolResult?.({ text: "🔧 exec: ls" }); await opts?.onToolResult?.({ text: "NO_REPLY", mediaUrls: ["https://example.com/tts-group.opus"], }); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); const sent = firstToolResultPayload(dispatcher); expect(sent?.mediaUrls).toEqual(["https://example.com/tts-group.opus"]); expect(sent?.text).toBeUndefined(); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("sends tool results via dispatcher in DM sessions", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "direct", }); const replyResolver = async ( _ctx: MsgContext, opts: GetReplyOptions | undefined, _cfg: OpenClawConfig, ) => { // Simulate tool result emission await opts?.onToolResult?.({ text: "🔧 exec: ls" }); return { text: "done" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).toHaveBeenCalledWith( expect.objectContaining({ text: "🔧 exec: ls" }), ); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("suppresses native tool summaries but still forwards tool media", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "direct", CommandSource: "native", }); const replyResolver = async ( _ctx: MsgContext, opts: GetReplyOptions | undefined, _cfg: OpenClawConfig, ) => { expect(opts?.onToolResult).toBeDefined(); await opts?.onToolResult?.({ text: "🔧 tools/sessions_send" }); await opts?.onToolResult?.({ mediaUrl: "https://example.com/tts-native.opus", }); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); const sent = firstToolResultPayload(dispatcher); expect(sent?.mediaUrl).toBe("https://example.com/tts-native.opus"); expect(sent?.text).toBeUndefined(); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("fast-aborts without calling the reply resolver", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: true, aborted: true, }); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", Body: "/stop", }); const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(replyResolver).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "⚙️ Agent was aborted.", }); }); it("fast-abort reply includes stopped subagent count when provided", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: true, aborted: true, stoppedSubagents: 2, }); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", Body: "/stop", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver: vi.fn(async () => ({ text: "hi" }) as ReplyPayload), }); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "⚙️ Agent was aborted. Stopped 2 sub-agents.", }); }); it("deduplicates inbound messages by MessageSid and origin", async () => { setNoAbort(); const cfg = emptyConfig; const ctx = buildTestCtx({ Provider: "whatsapp", OriginatingChannel: "whatsapp", OriginatingTo: "whatsapp:+15555550123", MessageSid: "msg-1", }); const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); await dispatchTwiceWithFreshDispatchers({ ctx, cfg, replyResolver, }); expect(replyResolver).toHaveBeenCalledTimes(1); }); it("emits message_received hook with originating channel metadata", async () => { setNoAbort(); hookMocks.runner.hasHooks.mockReturnValue(true); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", Surface: "slack", OriginatingChannel: "Telegram", OriginatingTo: "telegram:999", CommandBody: "/search hello", RawBody: "raw text", Body: "body text", Timestamp: 1710000000000, MessageSidFull: "sid-full", SenderId: "user-1", SenderName: "Alice", SenderUsername: "alice", SenderE164: "+15555550123", AccountId: "acc-1", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(hookMocks.runner.runMessageReceived).toHaveBeenCalledWith( expect.objectContaining({ from: ctx.From, content: "/search hello", timestamp: 1710000000000, metadata: expect.objectContaining({ originatingChannel: "Telegram", originatingTo: "telegram:999", messageId: "sid-full", senderId: "user-1", senderName: "Alice", senderUsername: "alice", senderE164: "+15555550123", }), }), expect.objectContaining({ channelId: "telegram", accountId: "acc-1", conversationId: "telegram:999", }), ); }); it("emits diagnostics when enabled", async () => { setNoAbort(); const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", Surface: "slack", SessionKey: "agent:main:main", MessageSid: "msg-1", To: "slack:C123", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1); expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({ sessionKey: "agent:main:main", state: "processing", reason: "message_start", }); expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith( expect.objectContaining({ channel: "slack", outcome: "completed", sessionKey: "agent:main:main", }), ); }); it("marks diagnostics skipped for duplicate inbound messages", async () => { setNoAbort(); const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; const ctx = buildTestCtx({ Provider: "whatsapp", OriginatingChannel: "whatsapp", OriginatingTo: "whatsapp:+15555550123", MessageSid: "msg-dup", }); const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); await dispatchTwiceWithFreshDispatchers({ ctx, cfg, replyResolver, }); expect(replyResolver).toHaveBeenCalledTimes(1); expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith( expect.objectContaining({ channel: "whatsapp", outcome: "skipped", reason: "duplicate", }), ); }); });