test: dedupe auto-reply dispatch mock calls

This commit is contained in:
Peter Steinberger
2026-05-12 16:41:28 +01:00
parent 7d208f3a5d
commit fcd1073b72

View File

@@ -702,10 +702,34 @@ function createMockAcpSessionManager() {
};
}
function firstMockArg(mockFn: ReturnType<typeof vi.fn>, label: string, index = 0): unknown {
const call = mockFn.mock.calls.at(index);
if (!call) {
throw new Error(`expected ${label} call #${index + 1}`);
}
return call.at(0);
}
function firstToolResultPayload(dispatcher: ReplyDispatcher): ReplyPayload | undefined {
return (dispatcher.sendToolResult as ReturnType<typeof vi.fn>).mock.calls.at(0)?.[0] as
| ReplyPayload
| undefined;
return firstMockArg(
dispatcher.sendToolResult as ReturnType<typeof vi.fn>,
"tool result",
) as ReplyPayload;
}
function firstFinalReplyPayload(dispatcher: ReplyDispatcher): ReplyPayload | undefined {
return firstMockArg(
dispatcher.sendFinalReply as ReturnType<typeof vi.fn>,
"final reply",
) as ReplyPayload;
}
function firstRouteReplyCall(): Record<string, unknown> {
const call = firstMockArg(mocks.routeReply, "route reply");
if (!call || typeof call !== "object") {
throw new Error("expected route reply params");
}
return call as Record<string, unknown>;
}
function requireToolResultHandler(
@@ -916,7 +940,7 @@ describe("dispatchReplyFromConfig", () => {
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
const routeCall = firstRouteReplyCall() as
| {
accountId?: unknown;
channel?: unknown;
@@ -963,7 +987,7 @@ describe("dispatchReplyFromConfig", () => {
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
const routeCall = firstRouteReplyCall() as
| { accountId?: unknown; channel?: unknown; to?: unknown }
| undefined;
expect(routeCall?.channel).toBe("telegram");
@@ -1012,7 +1036,7 @@ describe("dispatchReplyFromConfig", () => {
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
const routeCall = firstRouteReplyCall() as
| { accountId?: unknown; channel?: unknown; to?: unknown }
| undefined;
expect(routeCall?.channel).toBe("discord");
@@ -1108,7 +1132,7 @@ describe("dispatchReplyFromConfig", () => {
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
const routeCall = firstRouteReplyCall() as
| { channel?: unknown; threadId?: unknown; to?: unknown }
| undefined;
expect(routeCall?.channel).toBe("discord");
@@ -1149,7 +1173,7 @@ describe("dispatchReplyFromConfig", () => {
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
const routeCall = firstRouteReplyCall() as
| { channel?: string; to?: string; threadId?: string | number }
| undefined;
expect(routeCall?.channel).toBe("mattermost");
@@ -1212,9 +1236,7 @@ describe("dispatchReplyFromConfig", () => {
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
| { channel?: unknown; to?: unknown }
| undefined;
const routeCall = firstRouteReplyCall() as { channel?: unknown; to?: unknown } | undefined;
expect(routeCall?.channel).toBe("telegram");
expect(routeCall?.to).toBe("telegram:999");
});
@@ -1235,9 +1257,7 @@ describe("dispatchReplyFromConfig", () => {
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
| { channel?: unknown; to?: unknown }
| undefined;
const routeCall = firstRouteReplyCall() as { channel?: unknown; to?: unknown } | undefined;
expect(routeCall?.channel).toBe("feishu");
expect(routeCall?.to).toBe("ou_feishu_direct_123");
});
@@ -1305,7 +1325,7 @@ describe("dispatchReplyFromConfig", () => {
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
const routeCall = firstRouteReplyCall() as
| { channel?: unknown; policyConversationType?: unknown; to?: unknown }
| undefined;
expect(routeCall?.channel).toBe("imessage");
@@ -1348,7 +1368,7 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
const routed = mocks.routeReply.mock.calls.at(0)?.[0] as { payload?: ReplyPayload } | undefined;
const routed = firstRouteReplyCall() as { payload?: ReplyPayload } | undefined;
expect(routed?.payload?.mediaUrls).toEqual(["https://example.com/tts-routed.opus"]);
expect(routed?.payload?.text).toBeUndefined();
});
@@ -2068,9 +2088,7 @@ describe("dispatchReplyFromConfig", () => {
const streamedText = blockCalls.map((call) => (call[0] as ReplyPayload).text ?? "").join("");
expect(streamedText).toContain("hello");
expect(streamedText).toContain("world");
const finalPayload = (dispatcher.sendFinalReply as Mock).mock.calls.at(0)?.[0] as
| ReplyPayload
| undefined;
const finalPayload = firstFinalReplyPayload(dispatcher);
expect(finalPayload?.text).toBe("hello world");
});
@@ -2559,9 +2577,7 @@ describe("dispatchReplyFromConfig", () => {
}
}
expect(blockTexts).toEqual(["What do you want to work on?"]);
const finalPayload = (dispatcher.sendFinalReply as Mock).mock.calls.at(0)?.[0] as
| ReplyPayload
| undefined;
const finalPayload = firstFinalReplyPayload(dispatcher);
expect(finalPayload?.text).toBe("What do you want to work on?");
});
@@ -2644,9 +2660,7 @@ describe("dispatchReplyFromConfig", () => {
const normalizerOptions = replyMediaPathMocks.createReplyMediaPathNormalizer.mock
.calls[0]?.[0] as { messageProvider?: unknown } | undefined;
expect(normalizerOptions?.messageProvider).toBe("feishu");
const finalPayload = (dispatcher.sendFinalReply as Mock).mock.calls.at(0)?.[0] as
| ReplyPayload
| undefined;
const finalPayload = firstFinalReplyPayload(dispatcher);
expect(finalPayload?.mediaUrl).toBe("/tmp/openclaw-media/normalized-tts.ogg");
expect(finalPayload?.mediaUrls).toStrictEqual(["/tmp/openclaw-media/normalized-tts.ogg"]);
expect(finalPayload?.audioAsVoice).toBe(true);
@@ -2800,9 +2814,7 @@ describe("dispatchReplyFromConfig", () => {
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(firstToolResultPayload(dispatcher)?.text).toBe("Approval required.");
expect(
((dispatcher.sendFinalReply as Mock).mock.calls.at(0)?.[0] as ReplyPayload | undefined)?.text,
).toBe("done");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("done");
});
it("suppresses local discord exec approval tool prompts when the native runtime is active", async () => {
@@ -2850,10 +2862,7 @@ describe("dispatchReplyFromConfig", () => {
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
expect(
((dispatcher.sendFinalReply as Mock).mock.calls.at(0)?.[0] as ReplyPayload | undefined)
?.text,
).toBe("done");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("done");
} finally {
await reporter.stop();
}
@@ -3005,9 +3014,7 @@ describe("dispatchReplyFromConfig", () => {
expect(internalHookEvent?.action).toBe("received");
expect(internalHookEvent?.sessionKey).toBe("agent:main:hook-test");
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(
((dispatcher.sendFinalReply as Mock).mock.calls.at(0)?.[0] as ReplyPayload | undefined)?.text,
).toBe("core reply");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("core reply");
});
it("emits internal message:received hook when a session key is available", async () => {
@@ -4180,7 +4187,7 @@ describe("before_dispatch hook", () => {
expect(beforeDispatchCall?.[1]?.channelId).toBe("telegram");
expect(beforeDispatchCall?.[1]?.senderId).toBe("signal:user:alice");
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
const routeCall = mocks.routeReply.mock.calls.at(0)?.[0] as
const routeCall = firstRouteReplyCall() as
| { channel?: unknown; payload?: ReplyPayload; to?: unknown }
| undefined;
expect(routeCall?.channel).toBe("telegram");
@@ -4839,9 +4846,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(
(dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls.at(0)?.[0]?.text,
).toBe("visible direct reply");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible direct reply");
});
it("uses harness defaults for direct source delivery when config is unset", async () => {
@@ -4901,9 +4906,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(
(dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls.at(0)?.[0]?.text,
).toBe("visible fallback");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible fallback");
});
it("falls back to automatic group/channel delivery when group tools remove the message tool", async () => {
@@ -4937,9 +4940,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(
(dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls.at(0)?.[0]?.text,
).toBe("group policy fallback");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("group policy fallback");
});
it("falls back when a channel precomputed message-tool-only delivery but the message tool is unavailable", async () => {
@@ -4965,9 +4966,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(
(dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls.at(0)?.[0]?.text,
).toBe("requested fallback");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("requested fallback");
});
it("keeps native command replies visible in group/channel turns", async () => {
@@ -4994,9 +4993,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(
(dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls.at(0)?.[0]?.text,
).toBe("status reply");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("status reply");
});
it("allows config to keep group/channel source delivery automatic", async () => {
@@ -5020,8 +5017,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(true);
expect(
(dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls.at(0)?.[0]?.text,
).toBe("final reply");
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("final reply");
});
});