100 lines
2.9 KiB
TypeScript
100 lines
2.9 KiB
TypeScript
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 }),
|
|
};
|
|
}
|