diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 418b3c4252b3..86b82dc53093 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -16,6 +16,7 @@ import { runMessageAction, setMessageActionTestPlugin as setTestPlugin, } from "./message-action-runner.test-helpers.js"; +import type { MessageSendResult } from "./message.js"; const requireLabeledRecord = createRequireRecord("record", "expected-label"); @@ -375,6 +376,127 @@ describe("runMessageAction plugin dispatch", () => { expect(mocks.executeSendAction).not.toHaveBeenCalled(); }); + it.each<{ + name: string; + delivery: Partial; + outcome: { ok: boolean; error?: string; sentBeforeError?: true }; + }>([ + { + name: "sent", + delivery: { deliveryStatus: "sent" }, + outcome: { ok: true }, + }, + { + name: "suppressed", + delivery: { + deliveryStatus: "suppressed", + suppressionReason: "cancelled_by_message_sending_hook", + }, + outcome: { + ok: false, + error: "Broadcast send suppressed: cancelled_by_message_sending_hook.", + }, + }, + { + name: "failed", + delivery: { + deliveryStatus: "failed", + error: "provider rejected the message", + }, + outcome: { ok: false, error: "provider rejected the message" }, + }, + { + name: "failed without an error", + delivery: { deliveryStatus: "failed" }, + outcome: { ok: false, error: "Broadcast send failed." }, + }, + { + name: "partial_failed", + delivery: { + deliveryStatus: "partial_failed", + error: "second payload failed", + sentBeforeError: true, + }, + outcome: { ok: false, error: "second payload failed", sentBeforeError: true }, + }, + { + name: "partial_failed without an error", + delivery: { deliveryStatus: "partial_failed", sentBeforeError: true }, + outcome: { + ok: false, + error: "Broadcast send partially failed.", + sentBeforeError: true, + }, + }, + { + name: "legacy result without deliveryStatus", + delivery: { + via: "gateway", + result: { messageId: "legacy-message-1" }, + }, + outcome: { ok: true }, + }, + ])("derives broadcast truth from a $name send result", async ({ delivery, outcome }) => { + const nestedPayload = { ok: true, nested: "payload" }; + const sendResult = { + channel: "gatewaychat", + to: "user-123", + via: "direct", + mediaUrl: null, + ...delivery, + } satisfies MessageSendResult; + const gatewayPlugin = createGatewayActionPlugin({ + pluginId: "gatewaychat", + label: "Gateway Chat", + blurb: "Gateway Chat delivery truth test plugin.", + actions: ["send"], + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + handleAction: vi.fn(async () => jsonResult({ ok: true })), + }); + setTestPlugin(gatewayPlugin, "gatewaychat"); + mocks.executeSendAction.mockResolvedValue({ + handledBy: "core", + payload: nestedPayload, + sendResult, + }); + + const result = await runMessageAction({ + cfg: { + channels: { + gatewaychat: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "broadcast", + params: { + channel: "gatewaychat", + targets: ["user-123"], + message: "hello from broadcast", + }, + }); + + expect(result.kind).toBe("broadcast"); + if (result.kind !== "broadcast") { + throw new Error("expected broadcast result"); + } + expect(result.payload.results).toEqual([ + { + channel: "gatewaychat", + to: "user-123", + ...outcome, + payload: nestedPayload, + result: sendResult, + }, + ]); + expect(result.payload.results[0]?.payload).toBe(nestedPayload); + expect(result.payload.results[0]?.result).toBe(sendResult); + }); + it("preserves partial-delivery evidence from failed broadcast sends", async () => { const gatewayPlugin = createGatewayActionPlugin({ pluginId: "gatewaychat", diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 3c3ebe15532e..521e0163d394 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -57,6 +57,34 @@ function withSendNormalization( return normalization && result.kind === "send" ? { ...result, normalization } : result; } +function deriveBroadcastEntryOutcome( + sendResult?: MessageSendResult, +): { ok: true } | { ok: false; error: string; sentBeforeError?: true } { + if ( + !sendResult || + sendResult.deliveryStatus === undefined || + sendResult.deliveryStatus === "sent" + ) { + return { ok: true }; + } + switch (sendResult.deliveryStatus) { + case "suppressed": + return { + ok: false, + error: `Broadcast send suppressed: ${sendResult.suppressionReason ?? "unknown reason"}.`, + }; + case "failed": + return { ok: false, error: sendResult.error ?? "Broadcast send failed." }; + case "partial_failed": + return { + ok: false, + error: sendResult.error ?? "Broadcast send partially failed.", + sentBeforeError: true, + }; + } + return sendResult.deliveryStatus satisfies never; +} + async function handleBroadcastAction( input: MessageActionInput, params: Record, @@ -147,7 +175,9 @@ async function handleBroadcastAction( results.push({ channel: targetChannel, to: resolved.to, - ok: true, + ...deriveBroadcastEntryOutcome( + sendResult.kind === "send" ? sendResult.sendResult : undefined, + ), payload: sendResult.kind === "send" ? sendResult.payload : undefined, result: sendResult.kind === "send" ? sendResult.sendResult : undefined, });