import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { ReplyDispatcher } from "./reply/reply-dispatcher.js"; import { buildTestCtx } from "./reply/test-ctx.js"; type DispatchReplyFromConfigFn = typeof import("./reply/dispatch-from-config.js").dispatchReplyFromConfig; type FinalizeInboundContextFn = typeof import("./reply/inbound-context.js").finalizeInboundContext; type DeriveInboundMessageHookContextFn = typeof import("../hooks/message-hook-mappers.js").deriveInboundMessageHookContext; type GetGlobalHookRunnerFn = typeof import("../plugins/hook-runner-global.js").getGlobalHookRunner; type CreateReplyDispatcherFn = typeof import("./reply/reply-dispatcher.js").createReplyDispatcher; type CreateReplyDispatcherWithTypingFn = typeof import("./reply/reply-dispatcher.js").createReplyDispatcherWithTyping; const hoisted = vi.hoisted(() => ({ dispatchReplyFromConfigMock: vi.fn(), finalizeInboundContextMock: vi.fn((ctx: unknown, _opts?: unknown) => ctx), deriveInboundMessageHookContextMock: vi.fn(), getGlobalHookRunnerMock: vi.fn(), createReplyDispatcherMock: vi.fn(), createReplyDispatcherWithTypingMock: vi.fn(), })); vi.mock("./reply/dispatch-from-config.js", () => ({ dispatchReplyFromConfig: (...args: Parameters) => hoisted.dispatchReplyFromConfigMock(...args), })); vi.mock("./reply/inbound-context.js", () => ({ finalizeInboundContext: (...args: Parameters) => hoisted.finalizeInboundContextMock(...args), })); vi.mock("../hooks/message-hook-mappers.js", () => ({ deriveInboundMessageHookContext: (...args: Parameters) => hoisted.deriveInboundMessageHookContextMock(...args), toPluginMessageContext: (canonical: { channelId?: string; accountId?: string; conversationId?: string; }) => ({ channelId: canonical.channelId, accountId: canonical.accountId, conversationId: canonical.conversationId, }), })); vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: (...args: Parameters) => hoisted.getGlobalHookRunnerMock(...args), })); vi.mock("./reply/reply-dispatcher.js", async () => { const actual = await vi.importActual( "./reply/reply-dispatcher.js", ); return { ...actual, createReplyDispatcher: (...args: Parameters) => hoisted.createReplyDispatcherMock(...args), createReplyDispatcherWithTyping: (...args: Parameters) => hoisted.createReplyDispatcherWithTypingMock(...args), }; }); const { dispatchInboundMessage, dispatchInboundMessageWithDispatcher, dispatchInboundMessageWithBufferedDispatcher, withReplyDispatcher, } = await import("./dispatch.js"); function createDispatcher(record: string[]): ReplyDispatcher { return { sendToolResult: () => true, sendBlockReply: () => true, sendFinalReply: () => true, getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }), getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }), markComplete: () => { record.push("markComplete"); }, waitForIdle: async () => { record.push("waitForIdle"); }, }; } describe("withReplyDispatcher", () => { beforeEach(() => { vi.clearAllMocks(); hoisted.finalizeInboundContextMock.mockImplementation((ctx: unknown) => ctx); hoisted.deriveInboundMessageHookContextMock.mockReturnValue({ channelId: "threads", accountId: "acct-1", conversationId: "conv-1", isGroup: false, to: "thread:1", }); hoisted.getGlobalHookRunnerMock.mockReturnValue({ hasHooks: vi.fn(() => false), runMessageSending: vi.fn(async () => undefined), }); }); it("dispatchInboundMessage owns dispatcher lifecycle", async () => { const order: string[] = []; const dispatcher = { sendToolResult: () => true, sendBlockReply: () => true, sendFinalReply: () => { order.push("sendFinalReply"); return true; }, getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }), getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }), markComplete: () => { order.push("markComplete"); }, waitForIdle: async () => { order.push("waitForIdle"); }, } satisfies ReplyDispatcher; hoisted.dispatchReplyFromConfigMock.mockImplementationOnce(async ({ dispatcher }) => { dispatcher.sendFinalReply({ text: "ok" }); return { text: "ok" }; }); await dispatchInboundMessage({ ctx: buildTestCtx(), cfg: {} as OpenClawConfig, dispatcher, replyResolver: async () => ({ text: "ok" }), }); expect(order).toEqual(["sendFinalReply", "markComplete", "waitForIdle"]); }); it("always marks complete and waits for idle after success", async () => { const order: string[] = []; const dispatcher = createDispatcher(order); const result = await withReplyDispatcher({ dispatcher, run: async () => { order.push("run"); return "ok"; }, onSettled: () => { order.push("onSettled"); }, }); expect(result).toBe("ok"); expect(order).toEqual(["run", "markComplete", "waitForIdle", "onSettled"]); }); it("still drains dispatcher after run throws", async () => { const order: string[] = []; const dispatcher = createDispatcher(order); const onSettled = vi.fn(() => { order.push("onSettled"); }); await expect( withReplyDispatcher({ dispatcher, run: async () => { order.push("run"); throw new Error("boom"); }, onSettled, }), ).rejects.toThrow("boom"); expect(onSettled).toHaveBeenCalledTimes(1); expect(order).toEqual(["run", "markComplete", "waitForIdle", "onSettled"]); }); it("dispatchInboundMessageWithBufferedDispatcher cleans up typing after a resolver starts it", async () => { const typing = { onReplyStart: vi.fn(async () => {}), startTypingLoop: vi.fn(async () => {}), startTypingOnText: vi.fn(async () => {}), refreshTypingTtl: vi.fn(), isActive: vi.fn(() => true), markRunComplete: vi.fn(), markDispatchIdle: vi.fn(), cleanup: vi.fn(), }; hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ dispatcher: createDispatcher([]), replyOptions: {}, markDispatchIdle: typing.markDispatchIdle, markRunComplete: typing.markRunComplete, }); hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); await dispatchInboundMessageWithBufferedDispatcher({ ctx: buildTestCtx(), cfg: {} as OpenClawConfig, dispatcherOptions: { deliver: async () => undefined, }, replyResolver: async (_ctx, opts) => { opts?.onTypingController?.(typing); return { text: "ok" }; }, }); expect(typing.markRunComplete).toHaveBeenCalledTimes(1); expect(typing.markDispatchIdle).toHaveBeenCalled(); }); it("runs message_sending hooks before inbound dispatcher delivery", async () => { const runMessageSending = vi.fn(async () => ({ content: "sanitized reply" })); hoisted.getGlobalHookRunnerMock.mockReturnValue({ hasHooks: vi.fn((hookName?: string) => hookName === "message_sending"), runMessageSending, }); hoisted.createReplyDispatcherMock.mockReturnValueOnce(createDispatcher([])); hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); await dispatchInboundMessageWithDispatcher({ ctx: buildTestCtx({ From: "whatsapp:+15551234567", To: "whatsapp:+15557654321", OriginatingTo: "whatsapp:+15551234567", }), cfg: {} as OpenClawConfig, dispatcherOptions: { deliver: async () => undefined, }, replyResolver: async () => ({ text: "ok" }), }); const dispatcherOptions = hoisted.createReplyDispatcherMock.mock.calls[0]?.[0]; if (!dispatcherOptions?.beforeDeliver) { throw new Error("expected beforeDeliver hook"); } const payload = await dispatcherOptions.beforeDeliver( { text: "original reply" }, { kind: "final" }, ); expect(payload).toEqual({ text: "sanitized reply" }); expect(runMessageSending).toHaveBeenCalledWith( { content: "original reply", to: "whatsapp:+15551234567" }, { channelId: "threads", accountId: "acct-1", conversationId: "conv-1", }, ); }); it("reconciles queuedFinal and counts after dispatcher-side cancellation", async () => { const dispatcher = { sendToolResult: () => true, sendBlockReply: () => true, sendFinalReply: () => true, getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }), getCancelledCounts: () => ({ tool: 0, block: 0, final: 1 }), getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }), markComplete: () => undefined, waitForIdle: async () => undefined, } satisfies ReplyDispatcher; hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ queuedFinal: true, counts: { tool: 0, block: 0, final: 1 }, }); const result = await dispatchInboundMessage({ ctx: buildTestCtx(), cfg: {} as OpenClawConfig, dispatcher, replyResolver: async () => ({ text: "ok" }), }); expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, }); }); it("reconciles queuedFinal and counts after dispatcher-side delivery failure", async () => { const dispatcher = { sendToolResult: () => true, sendBlockReply: () => true, sendFinalReply: () => true, getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }), getCancelledCounts: () => ({ tool: 0, block: 0, final: 0 }), getFailedCounts: () => ({ tool: 0, block: 0, final: 1 }), markComplete: () => undefined, waitForIdle: async () => undefined, } satisfies ReplyDispatcher; hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ queuedFinal: true, counts: { tool: 0, block: 0, final: 1 }, }); const result = await dispatchInboundMessage({ ctx: buildTestCtx(), cfg: {} as OpenClawConfig, dispatcher, replyResolver: async () => ({ text: "ok" }), }); expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, failedCounts: { tool: 0, block: 0, final: 1 }, }); }); it("uses CommandTargetSessionKey for silent-reply policy on native command turns", async () => { hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ dispatcher: createDispatcher([]), replyOptions: {}, markDispatchIdle: vi.fn(), markRunComplete: vi.fn(), }); hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); await dispatchInboundMessageWithBufferedDispatcher({ ctx: buildTestCtx({ SessionKey: "agent:test:telegram:slash:8231046597", CommandSource: "native", CommandTargetSessionKey: "agent:test:telegram:direct:8231046597", Surface: "telegram", }), cfg: {} as OpenClawConfig, dispatcherOptions: { deliver: async () => undefined, }, replyResolver: async () => ({ text: "ok" }), }); expect(hoisted.createReplyDispatcherWithTypingMock).toHaveBeenCalledWith( expect.objectContaining({ silentReplyContext: expect.objectContaining({ sessionKey: "agent:test:telegram:direct:8231046597", surface: "telegram", }), }), ); }); it("passes explicit direct conversation type for generic silent-reply policy keys", async () => { hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ dispatcher: createDispatcher([]), replyOptions: {}, markDispatchIdle: vi.fn(), markRunComplete: vi.fn(), }); hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); await dispatchInboundMessageWithBufferedDispatcher({ ctx: buildTestCtx({ SessionKey: "agent:test:main", ChatType: "dm", Surface: "discord", }), cfg: {} as OpenClawConfig, dispatcherOptions: { deliver: async () => undefined, }, replyResolver: async () => ({ text: "ok" }), }); expect(hoisted.createReplyDispatcherWithTypingMock).toHaveBeenCalledWith( expect.objectContaining({ silentReplyContext: expect.objectContaining({ sessionKey: "agent:test:main", surface: "discord", conversationType: "direct", }), }), ); }); it("does not copy source conversation type onto cross-session native silent-reply targets", async () => { hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ dispatcher: createDispatcher([]), replyOptions: {}, markDispatchIdle: vi.fn(), markRunComplete: vi.fn(), }); hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); await dispatchInboundMessageWithBufferedDispatcher({ ctx: buildTestCtx({ SessionKey: "agent:test:main", CommandSource: "native", CommandTargetSessionKey: "agent:test:direct:user", ChatType: "group", Surface: "telegram", }), cfg: {} as OpenClawConfig, dispatcherOptions: { deliver: async () => undefined, }, replyResolver: async () => ({ text: "ok" }), }); const silentReplyContext = hoisted.createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0]?.silentReplyContext; expect(silentReplyContext).toEqual( expect.objectContaining({ sessionKey: "agent:test:direct:user", surface: "telegram", }), ); expect(silentReplyContext).not.toEqual(expect.objectContaining({ conversationType: "group" })); }); });