mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 21:00:41 +00:00
Run inbound auto-reply delivery through message_sending hooks before sending replies. Co-authored-by: Jamil Zakirov <15848838+jzakirov@users.noreply.github.com>
387 lines
13 KiB
TypeScript
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" }));
|
|
});
|
|
});
|