refactor: unify reply dispatch across providers

This commit is contained in:
Peter Steinberger
2026-01-05 19:43:54 +01:00
parent bfe7f5f126
commit c75b2a7067
17 changed files with 953 additions and 476 deletions

View 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"]);
});
});

View 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 }),
};
}