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" }));
});
});
Messung V0.5 in Prozent C=100 H=100 G=100
¤ Dauer der Verarbeitung: 0.5 Sekunden
¤
*© Formatika GbR, Deutschland