import { describe, expect, it, vi } from "vitest" ;
import type { OpenClawConfig } from "../../config/types.openclaw.js" ;
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("rewrites exact NO_REPLY final payloads for direct sessions where rewrite is enabled" , async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow" ,
group: "allow" ,
internal: "allow" ,
},
silentReplyRewrite: {
direct: true ,
},
},
},
};
const dispatcher = createReplyDispatcher({
deliver,
silentReplyContext: {
cfg,
sessionKey: "agent:main:telegram:direct:123" ,
surface: "telegram" ,
},
});
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(true );
await dispatcher.waitForIdle();
expect(deliver).toHaveBeenCalledTimes(1 );
expect(deliver.mock.calls[0 ]?.[0 ]?.text).not.toBe(SILENT_REPLY_TOKEN);
expect(deliver.mock.calls[0 ]?.[0 ]?.text).toBeTruthy();
});
it("preserves exact NO_REPLY final payloads for direct sessions where rewrite is disabled" , async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow" ,
group: "allow" ,
internal: "allow" ,
},
silentReplyRewrite: {
direct: false ,
},
},
},
};
const dispatcher = createReplyDispatcher({
deliver,
silentReplyContext: {
cfg,
sessionKey: "agent:main:telegram:direct:123" ,
surface: "telegram" ,
},
});
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(true );
await dispatcher.waitForIdle();
expect(deliver).toHaveBeenCalledTimes(1 );
expect(deliver.mock.calls[0 ]?.[0 ]?.text).toBe(SILENT_REPLY_TOKEN);
});
it("still drops exact NO_REPLY final payloads for group sessions where silence is allowed" , async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow" ,
group: "allow" ,
internal: "allow" ,
},
silentReplyRewrite: {
direct: true ,
},
},
},
};
const dispatcher = createReplyDispatcher({
deliver,
silentReplyContext: {
cfg,
sessionKey: "agent:main:telegram:group:123" ,
surface: "telegram" ,
},
});
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false );
await dispatcher.waitForIdle();
expect(deliver).not.toHaveBeenCalled();
});
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();
});
});
Messung V0.5 in Prozent C=100 H=96 G=97
¤ Dauer der Verarbeitung: 0.4 Sekunden
¤
*© Formatika GbR, Deutschland