Files
openclaw/src/auto-reply/dispatch.test.ts
Jamil Zakirov 52267a6b75 fix(auto-reply): run message_sending before inbound delivery
Run inbound auto-reply delivery through message_sending hooks before sending replies.

Co-authored-by: Jamil Zakirov <15848838+jzakirov@users.noreply.github.com>
2026-04-25 10:07:35 +05:30

387 lines
13 KiB
TypeScript

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<DispatchReplyFromConfigFn>) =>
hoisted.dispatchReplyFromConfigMock(...args),
}));
vi.mock("./reply/inbound-context.js", () => ({
finalizeInboundContext: (...args: Parameters<FinalizeInboundContextFn>) =>
hoisted.finalizeInboundContextMock(...args),
}));
vi.mock("../hooks/message-hook-mappers.js", () => ({
deriveInboundMessageHookContext: (...args: Parameters<DeriveInboundMessageHookContextFn>) =>
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<GetGlobalHookRunnerFn>) =>
hoisted.getGlobalHookRunnerMock(...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,
createReplyDispatcher: (...args: Parameters<CreateReplyDispatcherFn>) =>
hoisted.createReplyDispatcherMock(...args),
createReplyDispatcherWithTyping: (...args: Parameters<CreateReplyDispatcherWithTypingFn>) =>
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];
expect(dispatcherOptions?.beforeDeliver).toEqual(expect.any(Function));
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("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" }));
});
});