diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 0e6654cfa..26961ad7c 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -25,6 +25,8 @@ export async function dispatchReplyFromConfig(params: { // This handles cross-provider routing (e.g., message from Telegram being processed // by a shared session that's currently on Slack) while preserving normal dispatcher // flow when the provider handles its own messages. + // + // Debug: `pnpm test src/auto-reply/reply/dispatch-from-config.test.ts` const originatingChannel = ctx.OriginatingChannel; const originatingTo = ctx.OriginatingTo; const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase(); diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index a5208e674..e2b089144 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -442,6 +442,10 @@ export function scheduleFollowupDrain( while (queue.items.length > 0 || queue.droppedCount > 0) { await waitForQueueDebounce(queue); if (queue.mode === "collect") { + // Once the batch is mixed, never collect again within this drain. + // Prevents “collect after shift” collapsing different targets. + // + // Debug: `pnpm test src/auto-reply/reply/queue.collect-routing.test.ts` if (forceIndividualCollect) { const next = queue.items.shift(); if (!next) break; diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts new file mode 100644 index 000000000..27f2bcdef --- /dev/null +++ b/src/auto-reply/reply/route-reply.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), + sendMessageIMessage: vi.fn(async () => ({ messageId: "ok" })), + sendMessageSignal: vi.fn(async () => ({ messageId: "t1" })), + sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), + sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })), + sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })), +})); + +vi.mock("../../discord/send.js", () => ({ + sendMessageDiscord: mocks.sendMessageDiscord, +})); +vi.mock("../../imessage/send.js", () => ({ + sendMessageIMessage: mocks.sendMessageIMessage, +})); +vi.mock("../../signal/send.js", () => ({ + sendMessageSignal: mocks.sendMessageSignal, +})); +vi.mock("../../slack/send.js", () => ({ + sendMessageSlack: mocks.sendMessageSlack, +})); +vi.mock("../../telegram/send.js", () => ({ + sendMessageTelegram: mocks.sendMessageTelegram, +})); +vi.mock("../../web/outbound.js", () => ({ + sendMessageWhatsApp: mocks.sendMessageWhatsApp, +})); + +const { routeReply } = await import("./route-reply.js"); + +describe("routeReply", () => { + it("no-ops on empty payload", async () => { + mocks.sendMessageSlack.mockClear(); + const res = await routeReply({ + payload: {}, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + }); + expect(res.ok).toBe(true); + expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + }); + + it("passes thread id to Telegram sends", async () => { + mocks.sendMessageTelegram.mockClear(); + await routeReply({ + payload: { text: "hi" }, + channel: "telegram", + to: "telegram:123", + threadId: 42, + cfg: {} as never, + }); + expect(mocks.sendMessageTelegram).toHaveBeenCalledWith( + "telegram:123", + "hi", + expect.objectContaining({ messageThreadId: 42 }), + ); + }); + + it("uses replyToId as threadTs for Slack", async () => { + mocks.sendMessageSlack.mockClear(); + await routeReply({ + payload: { text: "hi", replyToId: "1710000000.0001" }, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + }); + expect(mocks.sendMessageSlack).toHaveBeenCalledWith( + "channel:C123", + "hi", + expect.objectContaining({ threadTs: "1710000000.0001" }), + ); + }); + + it("sends multiple mediaUrls (caption only on first)", async () => { + mocks.sendMessageSlack.mockClear(); + await routeReply({ + payload: { text: "caption", mediaUrls: ["a", "b"] }, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + }); + expect(mocks.sendMessageSlack).toHaveBeenCalledTimes(2); + expect(mocks.sendMessageSlack).toHaveBeenNthCalledWith( + 1, + "channel:C123", + "caption", + expect.objectContaining({ mediaUrl: "a" }), + ); + expect(mocks.sendMessageSlack).toHaveBeenNthCalledWith( + 2, + "channel:C123", + "", + expect.objectContaining({ mediaUrl: "b" }), + ); + }); + + it("routes WhatsApp via outbound sender (accountId honored)", async () => { + mocks.sendMessageWhatsApp.mockClear(); + await routeReply({ + payload: { text: "hi" }, + channel: "whatsapp", + to: "+15551234567", + accountId: "acc-1", + cfg: {} as never, + }); + expect(mocks.sendMessageWhatsApp).toHaveBeenCalledWith( + "+15551234567", + "hi", + expect.objectContaining({ accountId: "acc-1", verbose: false }), + ); + }); +}); diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 39bb28ab8..cd8b0efdc 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -54,6 +54,7 @@ export async function routeReply( ): Promise { const { payload, channel, to, accountId, threadId } = params; + // Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts` const text = payload.text ?? ""; const mediaUrls = (payload.mediaUrls?.filter(Boolean) ?? []).length ? (payload.mediaUrls?.filter(Boolean) as string[])