diff --git a/src/channels/message/send.test.ts b/src/channels/message/send.test.ts index b342c357d36..7d6c73079f1 100644 --- a/src/channels/message/send.test.ts +++ b/src/channels/message/send.test.ts @@ -11,15 +11,43 @@ vi.mock("../../infra/outbound/deliver.js", () => ({ deliverOutboundPayloadsInternal: deliverOutboundPayloads, })); -import { sendDurableMessageBatch, withDurableMessageSendContext } from "./send.js"; +import { + sendDurableMessageBatch, + type DurableMessageBatchSendResult, + withDurableMessageSendContext, +} from "./send.js"; +import type { DurableMessageSendIntent } from "./types.js"; type DeliveryIntentCallbackParams = { onDeliveryIntent?: (intent: OutboundDeliveryIntent) => void; onPayloadDeliveryOutcome?: (outcome: OutboundPayloadDeliveryOutcome) => void; }; +type DeliveryRequest = DeliveryIntentCallbackParams & { + abortSignal?: AbortSignal; + payloads?: unknown; + queuePolicy?: string; + replyToId?: string; + threadId?: string | number; +}; + const cfg = {} as OpenClawConfig; +function latestDeliveryRequest(): DeliveryRequest { + const [request] = deliverOutboundPayloads.mock.calls.at(-1) as unknown as [DeliveryRequest]; + return request; +} + +function expectBatchStatus( + result: DurableMessageBatchSendResult, + status: TStatus, +): asserts result is Extract { + expect(result.status).toBe(status); + if (result.status !== status) { + throw new Error(`expected durable batch status ${status}`); + } +} + describe("withDurableMessageSendContext", () => { it("renders and sends through a durable send context", async () => { deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => { @@ -42,58 +70,45 @@ describe("withDurableMessageSendContext", () => { replyToId: "reply-1", }, async (ctx) => { - expect(ctx).toEqual( - expect.objectContaining({ - id: "telegram:chat-1", - channel: "telegram", - to: "chat-1", - durability: "required", - attempt: 1, - }), - ); + expect(ctx.id).toBe("telegram:chat-1"); + expect(ctx.channel).toBe("telegram"); + expect(ctx.to).toBe("chat-1"); + expect(ctx.durability).toBe("required"); + expect(ctx.attempt).toBe(1); const rendered = await ctx.render(); expect(rendered).toEqual({ payloads: [{ text: "hello" }], - plan: expect.objectContaining({ + plan: { payloadCount: 1, textCount: 1, mediaCount: 0, + voiceCount: 0, + presentationCount: 0, + interactiveCount: 0, + channelDataCount: 0, items: [{ index: 0, kinds: ["text"] as const, text: "hello", mediaUrls: [] }], - }), + }, }); const send = await ctx.send(rendered); - expect(ctx.intent).toEqual( - expect.objectContaining({ - id: "intent-1", - channel: "telegram", - to: "chat-1", - durability: "required", - renderedBatch: rendered, - }), - ); + expect(ctx.intent?.id).toBe("intent-1"); + expect(ctx.intent?.channel).toBe("telegram"); + expect(ctx.intent?.to).toBe("chat-1"); + expect(ctx.intent?.durability).toBe("required"); + expect(ctx.intent?.renderedBatch).toBe(rendered); return send; }, ); - expect(result).toEqual( - expect.objectContaining({ - status: "sent", - deliveryIntent: expect.objectContaining({ id: "intent-1" }), - receipt: expect.objectContaining({ - platformMessageIds: ["msg-1"], - threadId: "42", - replyToId: "reply-1", - }), - }), - ); - expect(deliverOutboundPayloads).toHaveBeenCalledWith( - expect.objectContaining({ - queuePolicy: "required", - payloads: [{ text: "hello" }], - threadId: 42, - replyToId: "reply-1", - }), - ); + expectBatchStatus(result, "sent"); + expect(result.deliveryIntent?.id).toBe("intent-1"); + expect(result.receipt?.platformMessageIds).toEqual(["msg-1"]); + expect(result.receipt?.threadId).toBe("42"); + expect(result.receipt?.replyToId).toBe("reply-1"); + const request = latestDeliveryRequest(); + expect(request.queuePolicy).toBe("required"); + expect(request.payloads).toEqual([{ text: "hello" }]); + expect(request.threadId).toBe(42); + expect(request.replyToId).toBe("reply-1"); }); it("records a replayable rendered batch plan on the durable intent", async () => { @@ -131,33 +146,28 @@ describe("withDurableMessageSendContext", () => { }, ); - expect(intent).toEqual( - expect.objectContaining({ - renderedBatch: expect.objectContaining({ - plan: { - payloadCount: 1, - textCount: 1, - mediaCount: 2, - voiceCount: 1, - presentationCount: 1, - interactiveCount: 1, - channelDataCount: 1, - items: [ - { - index: 0, - kinds: ["text", "voice", "presentation", "interactive", "channelData"] as const, - text: "caption", - mediaUrls: ["file:///tmp/a.png", "file:///tmp/b.png"], - audioAsVoice: true, - presentationBlockCount: 1, - hasInteractive: true, - hasChannelData: true, - }, - ], - }, - }), - }), - ); + const renderedBatch = (intent as DurableMessageSendIntent | undefined)?.renderedBatch; + expect(renderedBatch?.plan).toEqual({ + payloadCount: 1, + textCount: 1, + mediaCount: 2, + voiceCount: 1, + presentationCount: 1, + interactiveCount: 1, + channelDataCount: 1, + items: [ + { + index: 0, + kinds: ["text", "voice", "presentation", "interactive", "channelData"] as const, + text: "caption", + mediaUrls: ["file:///tmp/a.png", "file:///tmp/b.png"], + audioAsVoice: true, + presentationBlockCount: 1, + hasInteractive: true, + hasChannelData: true, + }, + ], + }); }); it("forwards the durable send context signal to outbound delivery", async () => { @@ -177,20 +187,11 @@ describe("withDurableMessageSendContext", () => { signal: abortController.signal, }); - expect(result).toEqual( - expect.objectContaining({ - status: "sent", - receipt: expect.objectContaining({ - platformMessageIds: ["msg-1"], - }), - }), - ); - expect(deliverOutboundPayloads).toHaveBeenLastCalledWith( - expect.objectContaining({ - abortSignal: abortController.signal, - queuePolicy: "required", - }), - ); + expectBatchStatus(result, "sent"); + expect(result.receipt?.platformMessageIds).toEqual(["msg-1"]); + const request = latestDeliveryRequest(); + expect(request.abortSignal).toBe(abortController.signal); + expect(request.queuePolicy).toBe("required"); }); it("maps best-effort durability to best-effort queue policy", async () => { @@ -212,17 +213,9 @@ describe("withDurableMessageSendContext", () => { durability: "best_effort", }); - expect(result).toEqual( - expect.objectContaining({ - status: "sent", - deliveryIntent: expect.objectContaining({ id: "intent-best-effort" }), - }), - ); - expect(deliverOutboundPayloads).toHaveBeenLastCalledWith( - expect.objectContaining({ - queuePolicy: "best_effort", - }), - ); + expectBatchStatus(result, "sent"); + expect(result.deliveryIntent?.id).toBe("intent-best-effort"); + expect(latestDeliveryRequest().queuePolicy).toBe("best_effort"); }); it("preserves adapter-provided multipart receipts in durable sends", async () => { @@ -249,19 +242,15 @@ describe("withDurableMessageSendContext", () => { payloads: [{ text: "hello" }], }); - expect(result).toEqual( - expect.objectContaining({ - status: "sent", - receipt: expect.objectContaining({ - primaryPlatformMessageId: "platform-1", - platformMessageIds: ["platform-1", "platform-2"], - parts: [ - expect.objectContaining({ platformMessageId: "platform-1", kind: "text" }), - expect.objectContaining({ platformMessageId: "platform-2", kind: "media" }), - ], - }), - }), - ); + expectBatchStatus(result, "sent"); + expect(result.receipt?.primaryPlatformMessageId).toBe("platform-1"); + expect(result.receipt?.platformMessageIds).toEqual(["platform-1", "platform-2"]); + expect( + result.receipt?.parts.map(({ platformMessageId, kind }) => ({ platformMessageId, kind })), + ).toEqual([ + { platformMessageId: "platform-1", kind: "text" }, + { platformMessageId: "platform-2", kind: "media" }, + ]); }); it("supports preview, edit, and delete send-context hooks", async () => { @@ -303,10 +292,13 @@ describe("withDurableMessageSendContext", () => { }, ); - expect(onEditReceipt).toHaveBeenCalledWith( - receipt, - expect.objectContaining({ payloads: [{ text: "final" }] }), - ); + expect(onEditReceipt).toHaveBeenCalledTimes(1); + const [editReceiptArg, renderedArg] = onEditReceipt.mock.calls[0] as unknown as [ + unknown, + { payloads?: unknown }, + ]; + expect(editReceiptArg).toBe(receipt); + expect(renderedArg.payloads).toEqual([{ text: "final" }]); expect(onDeleteReceipt).toHaveBeenCalledWith(editedReceipt); }); @@ -357,18 +349,14 @@ describe("withDurableMessageSendContext", () => { onCommitReceipt, }); - expect(result).toEqual( - expect.objectContaining({ - status: "suppressed", - reason: "no_visible_result", - deliveryIntent: expect.objectContaining({ id: "intent-2" }), - }), - ); - expect(onCommitReceipt).toHaveBeenCalledWith( - expect.objectContaining({ - platformMessageIds: [], - }), - ); + expectBatchStatus(result, "suppressed"); + expect(result.reason).toBe("no_visible_result"); + expect(result.deliveryIntent?.id).toBe("intent-2"); + expect(onCommitReceipt).toHaveBeenCalledTimes(1); + const [receiptArg] = onCommitReceipt.mock.calls[0] as unknown as [ + { platformMessageIds?: unknown }, + ]; + expect(receiptArg.platformMessageIds).toEqual([]); }); it("reports hook-cancelled deliveries as explicit suppressed sends", async () => { @@ -391,22 +379,19 @@ describe("withDurableMessageSendContext", () => { onCommitReceipt, }); - expect(result).toEqual( - expect.objectContaining({ - status: "suppressed", - reason: "cancelled_by_message_sending_hook", - payloadOutcomes: [ - expect.objectContaining({ - status: "suppressed", - reason: "cancelled_by_message_sending_hook", - hookEffect: { cancelReason: "owned-by-other-agent" }, - }), - ], - }), - ); - expect(onCommitReceipt).toHaveBeenCalledWith( - expect.objectContaining({ platformMessageIds: [] }), - ); + expectBatchStatus(result, "suppressed"); + expect(result.reason).toBe("cancelled_by_message_sending_hook"); + expect(result.payloadOutcomes?.[0]).toEqual({ + index: 0, + status: "suppressed", + reason: "cancelled_by_message_sending_hook", + hookEffect: { cancelReason: "owned-by-other-agent" }, + }); + expect(onCommitReceipt).toHaveBeenCalledTimes(1); + const [receiptArg] = onCommitReceipt.mock.calls[0] as unknown as [ + { platformMessageIds?: unknown }, + ]; + expect(receiptArg.platformMessageIds).toEqual([]); }); it("forwards payload delivery outcomes to callers while collecting durable outcomes", async () => { @@ -428,25 +413,22 @@ describe("withDurableMessageSendContext", () => { onPayloadDeliveryOutcome, }); - expect(result).toEqual( - expect.objectContaining({ - status: "suppressed", - payloadOutcomes: [ - expect.objectContaining({ - index: 0, - status: "suppressed", - reason: "cancelled_by_message_sending_hook", - }), - ], - }), - ); - expect(onPayloadDeliveryOutcome).toHaveBeenCalledWith( - expect.objectContaining({ - index: 0, - status: "suppressed", - reason: "cancelled_by_message_sending_hook", - }), - ); + expectBatchStatus(result, "suppressed"); + expect(result.payloadOutcomes?.[0]).toEqual({ + index: 0, + status: "suppressed", + reason: "cancelled_by_message_sending_hook", + }); + expect(onPayloadDeliveryOutcome).toHaveBeenCalledTimes(1); + const [outcomeArg] = onPayloadDeliveryOutcome.mock.calls[0] as unknown as [ + OutboundPayloadDeliveryOutcome, + ]; + expect(outcomeArg.index).toBe(0); + expect(outcomeArg.status).toBe("suppressed"); + if (outcomeArg.status !== "suppressed") { + throw new Error("expected suppressed payload outcome"); + } + expect(outcomeArg.reason).toBe("cancelled_by_message_sending_hook"); }); it("reports zero-result failed best-effort payloads as failed sends", async () => { @@ -474,21 +456,16 @@ describe("withDurableMessageSendContext", () => { onSendFailure, }); - expect(result).toEqual( - expect.objectContaining({ - status: "failed", - error, - stage: "platform_send", - payloadOutcomes: [ - expect.objectContaining({ - index: 0, - status: "failed", - error, - stage: "platform_send", - }), - ], - }), - ); + expectBatchStatus(result, "failed"); + expect(result.error).toBe(error); + expect(result.stage).toBe("platform_send"); + expect(result.payloadOutcomes?.[0]).toEqual({ + index: 0, + status: "failed", + error, + sentBeforeError: false, + stage: "platform_send", + }); expect(onCommitReceipt).not.toHaveBeenCalled(); expect(onSendFailure).toHaveBeenCalledWith(error); }); @@ -520,15 +497,11 @@ describe("withDurableMessageSendContext", () => { onSendFailure, }); - expect(result).toEqual( - expect.objectContaining({ - status: "partial_failed", - results: [{ channel: "telegram", messageId: "msg-1" }], - receipt: expect.objectContaining({ platformMessageIds: ["msg-1"] }), - error, - sentBeforeError: true, - }), - ); + expectBatchStatus(result, "partial_failed"); + expect(result.results).toEqual([{ channel: "telegram", messageId: "msg-1" }]); + expect(result.receipt?.platformMessageIds).toEqual(["msg-1"]); + expect(result.error).toBe(error); + expect(result.sentBeforeError).toBe(true); expect(onSendFailure).toHaveBeenCalledWith(error); }); @@ -564,15 +537,11 @@ describe("withDurableMessageSendContext", () => { onSendFailure, }); - expect(result).toEqual( - expect.objectContaining({ - status: "partial_failed", - results: [{ channel: "telegram", messageId: "msg-1" }], - receipt: expect.objectContaining({ platformMessageIds: ["msg-1"] }), - error, - sentBeforeError: true, - }), - ); + expectBatchStatus(result, "partial_failed"); + expect(result.results).toEqual([{ channel: "telegram", messageId: "msg-1" }]); + expect(result.receipt?.platformMessageIds).toEqual(["msg-1"]); + expect(result.error).toBe(error); + expect(result.sentBeforeError).toBe(true); expect(onSendFailure).toHaveBeenCalledWith(error); });