refactor: unify reply dispatch across providers
This commit is contained in:
82
src/auto-reply/reply/reply-dispatcher.test.ts
Normal file
82
src/auto-reply/reply/reply-dispatcher.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||
|
||||
describe("createReplyDispatcher", () => {
|
||||
it("drops empty payloads and 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);
|
||||
|
||||
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);
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(2);
|
||||
expect(deliver.mock.calls[0][0].text).toBe("PFX already");
|
||||
expect(deliver.mock.calls[1][0].text).toBe("");
|
||||
});
|
||||
|
||||
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 new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
});
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
99
src/auto-reply/reply/reply-dispatcher.ts
Normal file
99
src/auto-reply/reply/reply-dispatcher.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
export type ReplyDispatchKind = "tool" | "block" | "final";
|
||||
|
||||
type ReplyDispatchErrorHandler = (
|
||||
err: unknown,
|
||||
info: { kind: ReplyDispatchKind },
|
||||
) => void;
|
||||
|
||||
type ReplyDispatchDeliverer = (
|
||||
payload: ReplyPayload,
|
||||
info: { kind: ReplyDispatchKind },
|
||||
) => Promise<void>;
|
||||
|
||||
export type ReplyDispatcherOptions = {
|
||||
deliver: ReplyDispatchDeliverer;
|
||||
responsePrefix?: string;
|
||||
onHeartbeatStrip?: () => void;
|
||||
onError?: ReplyDispatchErrorHandler;
|
||||
};
|
||||
|
||||
type ReplyDispatcher = {
|
||||
sendToolResult: (payload: ReplyPayload) => boolean;
|
||||
sendBlockReply: (payload: ReplyPayload) => boolean;
|
||||
sendFinalReply: (payload: ReplyPayload) => boolean;
|
||||
waitForIdle: () => Promise<void>;
|
||||
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
|
||||
};
|
||||
|
||||
function normalizeReplyPayload(
|
||||
payload: ReplyPayload,
|
||||
opts: Pick<ReplyDispatcherOptions, "responsePrefix" | "onHeartbeatStrip">,
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
|
||||
// Avoid sending the explicit silent token when no media is attached.
|
||||
if (trimmed === SILENT_REPLY_TOKEN && !hasMedia) return null;
|
||||
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && !trimmed) {
|
||||
// Keep empty text when media exists so media-only replies still send.
|
||||
text = "";
|
||||
}
|
||||
if (text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia) return null;
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (
|
||||
opts.responsePrefix &&
|
||||
text &&
|
||||
text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!text.startsWith(opts.responsePrefix)
|
||||
) {
|
||||
text = `${opts.responsePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
}
|
||||
|
||||
export function createReplyDispatcher(
|
||||
options: ReplyDispatcherOptions,
|
||||
): ReplyDispatcher {
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
// Serialize outbound replies to preserve tool/block/final order.
|
||||
const queuedCounts: Record<ReplyDispatchKind, number> = {
|
||||
tool: 0,
|
||||
block: 0,
|
||||
final: 0,
|
||||
};
|
||||
|
||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||
const normalized = normalizeReplyPayload(payload, options);
|
||||
if (!normalized) return false;
|
||||
queuedCounts[kind] += 1;
|
||||
sendChain = sendChain
|
||||
.then(() => options.deliver(normalized, { kind }))
|
||||
.catch((err) => {
|
||||
options.onError?.(err, { kind });
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
sendToolResult: (payload) => enqueue("tool", payload),
|
||||
sendBlockReply: (payload) => enqueue("block", payload),
|
||||
sendFinalReply: (payload) => enqueue("final", payload),
|
||||
waitForIdle: () => sendChain,
|
||||
getQueuedCounts: () => ({ ...queuedCounts }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user