test(auto-reply): isolate dispatch runtime mocks

This commit is contained in:
Peter Steinberger
2026-04-07 12:35:02 +01:00
parent b03522bcd8
commit 5a652303b5
3 changed files with 74 additions and 33 deletions

View File

@@ -1,12 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
import type { ReplyDispatcher } from "./reply/reply-dispatcher.js";
import { buildTestCtx } from "./reply/test-ctx.js";
const hoisted = vi.hoisted(() => ({
dispatchReplyFromConfigMock: vi.fn(),
finalizeInboundContextMock: vi.fn((ctx: unknown) => ctx),
createReplyDispatcherWithTypingMock: vi.fn(),
}));
vi.mock("./reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: (...args: unknown[]) => hoisted.dispatchReplyFromConfigMock(...args),
}));
vi.mock("./reply/inbound-context.js", () => ({
finalizeInboundContext: (...args: unknown[]) => hoisted.finalizeInboundContextMock(...args),
}));
vi.mock("./reply/reply-dispatcher.js", async () => {
const actual = await vi.importActual<typeof import("./reply/reply-dispatcher.js")>(
"./reply/reply-dispatcher.js",
);
return {
...actual,
createReplyDispatcherWithTyping: (...args: unknown[]) =>
hoisted.createReplyDispatcherWithTypingMock(...args),
};
});
const {
dispatchInboundMessage,
dispatchInboundMessageWithBufferedDispatcher,
withReplyDispatcher,
} from "./dispatch.js";
import type { ReplyDispatcher } from "./reply/reply-dispatcher.js";
import { buildTestCtx } from "./reply/test-ctx.js";
} = await import("./dispatch.js");
function createDispatcher(record: string[]): ReplyDispatcher {
return {
@@ -25,6 +51,39 @@ function createDispatcher(record: string[]): ReplyDispatcher {
}
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);
@@ -66,35 +125,6 @@ describe("withReplyDispatcher", () => {
expect(order).toEqual(["run", "markComplete", "waitForIdle", "onSettled"]);
});
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;
await dispatchInboundMessage({
ctx: buildTestCtx(),
cfg: {} as OpenClawConfig,
dispatcher,
replyResolver: async () => ({ text: "ok" }),
});
expect(order).toEqual(["sendFinalReply", "markComplete", "waitForIdle"]);
});
it("dispatchInboundMessageWithBufferedDispatcher cleans up typing after a resolver starts it", async () => {
const typing = {
onReplyStart: vi.fn(async () => {}),
@@ -106,6 +136,13 @@ describe("withReplyDispatcher", () => {
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(),

View File

@@ -66,6 +66,7 @@ const acpMocks = vi.hoisted(() => ({
readAcpSessionEntry: vi.fn<(params: { sessionKey: string; cfg?: OpenClawConfig }) => unknown>(
() => null,
),
getAcpRuntimeBackend: vi.fn<() => unknown>(() => null),
upsertAcpSessionMeta: vi.fn<
(params: {
sessionKey: string;
@@ -185,6 +186,7 @@ vi.mock("../../acp/runtime/session-meta.js", () => ({
upsertAcpSessionMeta: acpMocks.upsertAcpSessionMeta,
}));
vi.mock("../../acp/runtime/registry.js", () => ({
getAcpRuntimeBackend: acpMocks.getAcpRuntimeBackend,
requireAcpRuntimeBackend: acpMocks.requireAcpRuntimeBackend,
}));
vi.mock("../../infra/outbound/session-binding-service.js", () => ({

View File

@@ -70,6 +70,7 @@ const acpMocks = vi.hoisted(() => ({
readAcpSessionEntry: vi.fn<(params: { sessionKey: string; cfg?: OpenClawConfig }) => unknown>(
() => null,
),
getAcpRuntimeBackend: vi.fn<() => unknown>(() => null),
upsertAcpSessionMeta: vi.fn<
(params: {
sessionKey: string;
@@ -248,6 +249,7 @@ vi.mock("../../acp/runtime/session-meta.js", () => ({
upsertAcpSessionMeta: acpMocks.upsertAcpSessionMeta,
}));
vi.mock("../../acp/runtime/registry.js", () => ({
getAcpRuntimeBackend: acpMocks.getAcpRuntimeBackend,
requireAcpRuntimeBackend: acpMocks.requireAcpRuntimeBackend,
}));
vi.mock("../../infra/outbound/session-binding-service.js", () => ({