diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index dff375cba62..c7f55363350 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -2,9 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { typedCases } from "../../test-utils/typed-cases.js"; import { ackDelivery, computeBackoffMs, @@ -24,11 +22,6 @@ import { enforceCrossContextPolicy, } from "./outbound-policy.js"; import { resolveOutboundSessionRoute } from "./outbound-session.js"; -import { - formatOutboundPayloadLog, - normalizeOutboundPayloads, - normalizeOutboundPayloadsForJson, -} from "./payloads.js"; import { runResolveOutboundTargetCoreTests } from "./targets.shared-test.js"; describe("delivery-queue", () => { @@ -923,124 +916,4 @@ describe("resolveOutboundSessionRoute", () => { }); }); -describe("normalizeOutboundPayloadsForJson", () => { - it("normalizes payloads for JSON output", () => { - const cases = typedCases<{ - input: Parameters[0]; - expected: ReturnType; - }>([ - { - input: [ - { text: "hi" }, - { text: "photo", mediaUrl: "https://x.test/a.jpg" }, - { text: "multi", mediaUrls: ["https://x.test/1.png"] }, - ], - expected: [ - { text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined }, - { - text: "photo", - mediaUrl: "https://x.test/a.jpg", - mediaUrls: ["https://x.test/a.jpg"], - channelData: undefined, - }, - { - text: "multi", - mediaUrl: null, - mediaUrls: ["https://x.test/1.png"], - channelData: undefined, - }, - ], - }, - { - input: [ - { - text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", - }, - ], - expected: [ - { - text: "", - mediaUrl: null, - mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], - channelData: undefined, - }, - ], - }, - ]); - - for (const testCase of cases) { - const input: ReplyPayload[] = testCase.input.map((payload) => - "mediaUrls" in payload - ? ({ - ...payload, - mediaUrls: payload.mediaUrls ? [...payload.mediaUrls] : undefined, - } as ReplyPayload) - : ({ ...payload } as ReplyPayload), - ); - expect(normalizeOutboundPayloadsForJson(input)).toEqual(testCase.expected); - } - }); - - it("suppresses reasoning payloads", () => { - const normalized = normalizeOutboundPayloadsForJson([ - { text: "Reasoning:\n_step_", isReasoning: true }, - { text: "final answer" }, - ]); - expect(normalized).toEqual([{ text: "final answer", mediaUrl: null, mediaUrls: undefined }]); - }); -}); - -describe("normalizeOutboundPayloads", () => { - it("keeps channelData-only payloads", () => { - const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } }; - const normalized = normalizeOutboundPayloads([{ channelData }]); - expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]); - }); - - it("suppresses reasoning payloads", () => { - const normalized = normalizeOutboundPayloads([ - { text: "Reasoning:\n_step_", isReasoning: true }, - { text: "final answer" }, - ]); - expect(normalized).toEqual([{ text: "final answer", mediaUrls: [] }]); - }); -}); - -describe("formatOutboundPayloadLog", () => { - it("formats text+media and media-only logs", () => { - const cases = typedCases<{ - name: string; - input: Parameters[0]; - expected: string; - }>([ - { - name: "text with media lines", - input: { - text: "hello ", - mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], - }, - expected: "hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", - }, - { - name: "media only", - input: { - text: "", - mediaUrls: ["https://x.test/a.png"], - }, - expected: "MEDIA:https://x.test/a.png", - }, - ]); - - for (const testCase of cases) { - expect( - formatOutboundPayloadLog({ - ...testCase.input, - mediaUrls: [...testCase.input.mediaUrls], - }), - testCase.name, - ).toBe(testCase.expected); - } - }); -}); - runResolveOutboundTargetCoreTests(); diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts new file mode 100644 index 00000000000..ef5ccbced53 --- /dev/null +++ b/src/infra/outbound/payloads.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import type { ReplyPayload } from "../../auto-reply/types.js"; +import { typedCases } from "../../test-utils/typed-cases.js"; +import { + formatOutboundPayloadLog, + normalizeOutboundPayloads, + normalizeOutboundPayloadsForJson, + normalizeReplyPayloadsForDelivery, +} from "./payloads.js"; + +describe("normalizeReplyPayloadsForDelivery", () => { + it("parses directives, merges media, and preserves reply metadata", () => { + expect( + normalizeReplyPayloadsForDelivery([ + { + text: "[[reply_to: 123]] Hello [[audio_as_voice]]\nMEDIA:https://x.test/a.png", + mediaUrl: " https://x.test/a.png ", + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + replyToTag: false, + }, + ]), + ).toEqual([ + { + text: "Hello", + mediaUrl: undefined, + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + replyToId: "123", + replyToTag: true, + replyToCurrent: false, + audioAsVoice: true, + }, + ]); + }); + + it("drops silent payloads without media and suppresses reasoning payloads", () => { + expect( + normalizeReplyPayloadsForDelivery([ + { text: "NO_REPLY" }, + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]), + ).toEqual([ + { + text: "final answer", + mediaUrls: undefined, + mediaUrl: undefined, + replyToId: undefined, + replyToCurrent: false, + replyToTag: false, + audioAsVoice: false, + }, + ]); + }); + + it("keeps renderable channel-data payloads and reply-to-current markers", () => { + expect( + normalizeReplyPayloadsForDelivery([ + { + text: "[[reply_to_current]]", + channelData: { line: { flexMessage: { altText: "Card", contents: {} } } }, + }, + ]), + ).toEqual([ + { + text: "", + mediaUrls: undefined, + mediaUrl: undefined, + replyToCurrent: true, + replyToTag: true, + audioAsVoice: false, + channelData: { line: { flexMessage: { altText: "Card", contents: {} } } }, + }, + ]); + }); +}); + +describe("normalizeOutboundPayloadsForJson", () => { + it("normalizes payloads for JSON output", () => { + const cases = typedCases<{ + input: Parameters[0]; + expected: ReturnType; + }>([ + { + input: [ + { text: "hi" }, + { text: "photo", mediaUrl: "https://x.test/a.jpg" }, + { text: "multi", mediaUrls: ["https://x.test/1.png"] }, + ], + expected: [ + { text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined }, + { + text: "photo", + mediaUrl: "https://x.test/a.jpg", + mediaUrls: ["https://x.test/a.jpg"], + channelData: undefined, + }, + { + text: "multi", + mediaUrl: null, + mediaUrls: ["https://x.test/1.png"], + channelData: undefined, + }, + ], + }, + { + input: [ + { + text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", + }, + ], + expected: [ + { + text: "", + mediaUrl: null, + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + channelData: undefined, + }, + ], + }, + ]); + + for (const testCase of cases) { + const input: ReplyPayload[] = testCase.input.map((payload) => + "mediaUrls" in payload + ? ({ + ...payload, + mediaUrls: payload.mediaUrls ? [...payload.mediaUrls] : undefined, + } as ReplyPayload) + : ({ ...payload } as ReplyPayload), + ); + expect(normalizeOutboundPayloadsForJson(input)).toEqual(testCase.expected); + } + }); + + it("suppresses reasoning payloads", () => { + expect( + normalizeOutboundPayloadsForJson([ + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]), + ).toEqual([{ text: "final answer", mediaUrl: null, mediaUrls: undefined }]); + }); +}); + +describe("normalizeOutboundPayloads", () => { + it("keeps channelData-only payloads", () => { + const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } }; + expect(normalizeOutboundPayloads([{ channelData }])).toEqual([ + { text: "", mediaUrls: [], channelData }, + ]); + }); + + it("suppresses reasoning payloads", () => { + expect( + normalizeOutboundPayloads([ + { text: "Reasoning:\n_step_", isReasoning: true }, + { text: "final answer" }, + ]), + ).toEqual([{ text: "final answer", mediaUrls: [] }]); + }); +}); + +describe("formatOutboundPayloadLog", () => { + it("formats text+media and media-only logs", () => { + const cases = typedCases<{ + name: string; + input: Parameters[0]; + expected: string; + }>([ + { + name: "text with media lines", + input: { + text: "hello ", + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + }, + expected: "hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", + }, + { + name: "media only", + input: { + text: "", + mediaUrls: ["https://x.test/a.png"], + }, + expected: "MEDIA:https://x.test/a.png", + }, + ]); + + for (const testCase of cases) { + expect( + formatOutboundPayloadLog({ + ...testCase.input, + mediaUrls: [...testCase.input.mediaUrls], + }), + testCase.name, + ).toBe(testCase.expected); + } + }); +});