fix: emit message_sent hook for all successful outbound paths (#15104)

This commit is contained in:
Tak Hoffman
2026-02-12 19:39:09 -06:00
committed by GitHub
parent f9e444dd56
commit 1d8bda4a21
2 changed files with 116 additions and 33 deletions

View File

@@ -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<typeof import("../../config/sessions.js")>(
@@ -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([]);

View File

@@ -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;
}