mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-16 19:51:11 +00:00
191 lines
6.5 KiB
TypeScript
191 lines
6.5 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
|
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
|
import { createReplyToModeFilter } from "./reply-threading.js";
|
|
|
|
describe("createReplyDispatcher", () => {
|
|
it("drops empty payloads and exact silent tokens without media", async () => {
|
|
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
const dispatcher = createReplyDispatcher({ deliver });
|
|
|
|
expect(dispatcher.sendFinalReply({})).toBe(false);
|
|
expect(dispatcher.sendFinalReply({ text: " " })).toBe(false);
|
|
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
|
|
expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(true);
|
|
expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(true);
|
|
|
|
await dispatcher.waitForIdle();
|
|
expect(deliver).toHaveBeenCalledTimes(2);
|
|
expect(deliver.mock.calls[0]?.[0]?.text).toBe(`${SILENT_REPLY_TOKEN} -- nope`);
|
|
expect(deliver.mock.calls[1]?.[0]?.text).toBe(`interject.${SILENT_REPLY_TOKEN}`);
|
|
});
|
|
|
|
it("strips heartbeat tokens and applies responsePrefix", async () => {
|
|
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
const onHeartbeatStrip = vi.fn();
|
|
const dispatcher = createReplyDispatcher({
|
|
deliver,
|
|
responsePrefix: "PFX",
|
|
onHeartbeatStrip,
|
|
});
|
|
|
|
expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false);
|
|
expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true);
|
|
await dispatcher.waitForIdle();
|
|
|
|
expect(deliver).toHaveBeenCalledTimes(1);
|
|
expect(deliver.mock.calls[0][0].text).toBe("PFX hello");
|
|
expect(onHeartbeatStrip).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => {
|
|
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
const dispatcher = createReplyDispatcher({
|
|
deliver,
|
|
responsePrefix: "PFX",
|
|
});
|
|
|
|
expect(
|
|
dispatcher.sendFinalReply({
|
|
text: "PFX already",
|
|
mediaUrl: "file:///tmp/photo.jpg",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
dispatcher.sendFinalReply({
|
|
text: HEARTBEAT_TOKEN,
|
|
mediaUrl: "file:///tmp/photo.jpg",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
dispatcher.sendFinalReply({
|
|
text: `${SILENT_REPLY_TOKEN} -- explanation`,
|
|
mediaUrl: "file:///tmp/photo.jpg",
|
|
}),
|
|
).toBe(true);
|
|
|
|
await dispatcher.waitForIdle();
|
|
|
|
expect(deliver).toHaveBeenCalledTimes(3);
|
|
expect(deliver.mock.calls[0][0].text).toBe("PFX already");
|
|
expect(deliver.mock.calls[1][0].text).toBe("");
|
|
expect(deliver.mock.calls[2][0].text).toBe(`PFX ${SILENT_REPLY_TOKEN} -- explanation`);
|
|
});
|
|
|
|
it("preserves ordering across tool, block, and final replies", async () => {
|
|
const delivered: string[] = [];
|
|
const deliver = vi.fn(async (_payload, info) => {
|
|
delivered.push(info.kind);
|
|
if (info.kind === "tool") {
|
|
await Promise.resolve();
|
|
}
|
|
});
|
|
const dispatcher = createReplyDispatcher({ deliver });
|
|
|
|
dispatcher.sendToolResult({ text: "tool" });
|
|
dispatcher.sendBlockReply({ text: "block" });
|
|
dispatcher.sendFinalReply({ text: "final" });
|
|
|
|
await dispatcher.waitForIdle();
|
|
expect(delivered).toEqual(["tool", "block", "final"]);
|
|
});
|
|
|
|
it("fires onIdle when the queue drains", async () => {
|
|
const deliver: Parameters<typeof createReplyDispatcher>[0]["deliver"] = async () =>
|
|
await Promise.resolve();
|
|
const onIdle = vi.fn();
|
|
const dispatcher = createReplyDispatcher({ deliver, onIdle });
|
|
|
|
dispatcher.sendToolResult({ text: "one" });
|
|
dispatcher.sendFinalReply({ text: "two" });
|
|
|
|
await dispatcher.waitForIdle();
|
|
dispatcher.markComplete();
|
|
await Promise.resolve();
|
|
expect(onIdle).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("delays block replies after the first when humanDelay is natural", async () => {
|
|
vi.useFakeTimers();
|
|
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
const dispatcher = createReplyDispatcher({
|
|
deliver,
|
|
humanDelay: { mode: "natural" },
|
|
});
|
|
|
|
dispatcher.sendBlockReply({ text: "first" });
|
|
await Promise.resolve();
|
|
expect(deliver).toHaveBeenCalledTimes(1);
|
|
|
|
dispatcher.sendBlockReply({ text: "second" });
|
|
await Promise.resolve();
|
|
expect(deliver).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(799);
|
|
expect(deliver).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.runAllTimersAsync();
|
|
await dispatcher.waitForIdle();
|
|
expect(deliver).toHaveBeenCalledTimes(2);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("uses custom bounds for humanDelay and clamps when max <= min", async () => {
|
|
vi.useFakeTimers();
|
|
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
const dispatcher = createReplyDispatcher({
|
|
deliver,
|
|
humanDelay: { mode: "custom", minMs: 1200, maxMs: 400 },
|
|
});
|
|
|
|
dispatcher.sendBlockReply({ text: "first" });
|
|
await Promise.resolve();
|
|
expect(deliver).toHaveBeenCalledTimes(1);
|
|
|
|
dispatcher.sendBlockReply({ text: "second" });
|
|
await vi.advanceTimersByTimeAsync(1199);
|
|
expect(deliver).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await dispatcher.waitForIdle();
|
|
expect(deliver).toHaveBeenCalledTimes(2);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe("createReplyToModeFilter", () => {
|
|
it("handles off/all mode behavior for replyToId", () => {
|
|
const cases: Array<{
|
|
filter: ReturnType<typeof createReplyToModeFilter>;
|
|
input: { text: string; replyToId?: string; replyToTag?: boolean };
|
|
expectedReplyToId?: string;
|
|
}> = [
|
|
{
|
|
filter: createReplyToModeFilter("off"),
|
|
input: { text: "hi", replyToId: "1" },
|
|
expectedReplyToId: undefined,
|
|
},
|
|
{
|
|
filter: createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }),
|
|
input: { text: "hi", replyToId: "1", replyToTag: true },
|
|
expectedReplyToId: "1",
|
|
},
|
|
{
|
|
filter: createReplyToModeFilter("all"),
|
|
input: { text: "hi", replyToId: "1" },
|
|
expectedReplyToId: "1",
|
|
},
|
|
];
|
|
for (const testCase of cases) {
|
|
expect(testCase.filter(testCase.input).replyToId).toBe(testCase.expectedReplyToId);
|
|
}
|
|
});
|
|
|
|
it("keeps only the first replyToId when mode is first", () => {
|
|
const filter = createReplyToModeFilter("first");
|
|
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
|
expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined();
|
|
});
|
|
});
|