diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 967ac254a34..221050cc49d 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -14,6 +14,12 @@ import { const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), })); +const hookMocks = vi.hoisted(() => ({ + runner: { + hasHooks: vi.fn(() => false), + runMessageSent: vi.fn(async () => {}), + }, +})); vi.mock("../../config/sessions.js", async () => { const actual = await vi.importActual( @@ -24,12 +30,19 @@ vi.mock("../../config/sessions.js", async () => { appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript, }; }); +vi.mock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => hookMocks.runner, +})); const { deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js"); describe("deliverOutboundPayloads", () => { beforeEach(() => { setActivePluginRegistry(defaultRegistry); + hookMocks.runner.hasHooks.mockReset(); + hookMocks.runner.hasHooks.mockReturnValue(false); + hookMocks.runner.runMessageSent.mockReset(); + hookMocks.runner.runMessageSent.mockResolvedValue(undefined); }); afterEach(() => { @@ -422,6 +435,86 @@ describe("deliverOutboundPayloads", () => { expect.objectContaining({ text: "report.pdf" }), ); }); + + it("emits message_sent success for text-only deliveries", async () => { + hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "message_sent"); + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hello" }], + deps: { sendWhatsApp }, + }); + + await vi.waitFor(() => { + expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ to: "+1555", content: "hello", success: true }), + expect.objectContaining({ channelId: "whatsapp" }), + ); + }); + }); + + it("emits message_sent success for sendPayload deliveries", async () => { + hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "message_sent"); + const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); + const sendText = vi.fn(); + const sendMedia = vi.fn(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { deliveryMode: "direct", sendPayload, sendText, sendMedia }, + }), + }, + ]), + ); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "payload text", channelData: { mode: "custom" } }], + }); + + await vi.waitFor(() => { + expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ to: "!room:1", content: "payload text", success: true }), + expect.objectContaining({ channelId: "matrix" }), + ); + }); + }); + + it("emits message_sent failure when delivery errors", async () => { + hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "message_sent"); + const sendWhatsApp = vi.fn().mockRejectedValue(new Error("downstream failed")); + + await expect( + deliverOutboundPayloads({ + cfg: {}, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hi" }], + deps: { sendWhatsApp }, + }), + ).rejects.toThrow("downstream failed"); + + await vi.waitFor(() => { + expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ + to: "+1555", + content: "hi", + success: false, + error: "downstream failed", + }), + expect.objectContaining({ channelId: "whatsapp" }), + ); + }); + }); }); const emptyRegistry = createTestRegistry([]); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 63887265ff2..a9872530f5a 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -345,6 +345,25 @@ export async function deliverOutboundPayloads(params: { mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), channelData: payload.channelData, }; + const emitMessageSent = (success: boolean, error?: string) => { + if (!hookRunner?.hasHooks("message_sent")) { + return; + } + void hookRunner + .runMessageSent( + { + to, + content: payloadSummary.text, + success, + ...(error ? { error } : {}), + }, + { + channelId: channel, + accountId: accountId ?? undefined, + }, + ) + .catch(() => {}); + }; try { throwIfAborted(abortSignal); @@ -378,6 +397,7 @@ export async function deliverOutboundPayloads(params: { params.onPayload?.(payloadSummary); if (handler.sendPayload && effectivePayload.channelData) { results.push(await handler.sendPayload(effectivePayload)); + emitMessageSent(true); continue; } if (payloadSummary.mediaUrls.length === 0) { @@ -386,6 +406,7 @@ export async function deliverOutboundPayloads(params: { } else { await sendTextChunks(payloadSummary.text); } + emitMessageSent(true); continue; } @@ -400,40 +421,9 @@ export async function deliverOutboundPayloads(params: { results.push(await handler.sendMedia(caption, url)); } } - // Run message_sent plugin hook (fire-and-forget) on success - if (hookRunner?.hasHooks("message_sent")) { - void hookRunner - .runMessageSent( - { - to, - content: payloadSummary.text, - success: true, - }, - { - channelId: channel, - accountId: accountId ?? undefined, - }, - ) - .catch(() => {}); - } + emitMessageSent(true); } catch (err) { - // Run message_sent plugin hook on failure (fire-and-forget) - if (hookRunner?.hasHooks("message_sent")) { - void hookRunner - .runMessageSent( - { - to, - content: payloadSummary.text, - success: false, - error: err instanceof Error ? err.message : String(err), - }, - { - channelId: channel, - accountId: accountId ?? undefined, - }, - ) - .catch(() => {}); - } + emitMessageSent(false, err instanceof Error ? err.message : String(err)); if (!params.bestEffort) { throw err; }