import { 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 CreateReplyDispatcherWithTypingFn = typeof import("./reply/reply-dispatcher.js").createReplyDispatcherWithTyping; const hoisted = vi.hoisted(() => ({ dispatchReplyFromConfigMock: vi.fn(), finalizeInboundContextMock: vi.fn((ctx: unknown, _opts?: unknown) => ctx), 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("./reply/reply-dispatcher.js", async () => { const actual = await vi.importActual( "./reply/reply-dispatcher.js", ); return { ...actual, createReplyDispatcherWithTyping: (...args: Parameters) => hoisted.createReplyDispatcherWithTypingMock(...args), }; }); const { dispatchInboundMessage, 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", () => { 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(); }); });