// Interactive payload tests cover validation of interactive response payloads. import { describe, expect, it } from "vitest"; import type { MessagePresentationAction, ModelPickerAction } from "./payload.js"; import { hasReplyChannelData, hasReplyContent, hasReplyPayloadContent, normalizeInteractiveReply, normalizeMessagePresentation, presentationToInteractiveControlsReply, presentationToInteractiveReply, renderMessagePresentationFallbackText, resolveMessagePresentationButtonAction, resolveMessagePresentationControlValue, resolveMessagePresentationOptionAction, resolveInteractiveTextFallback, } from "./payload.js"; describe("hasReplyChannelData", () => { it.each([ { value: undefined, expected: false }, { value: {}, expected: false }, { value: [], expected: false }, { value: { slack: { blocks: [] } }, expected: true }, ] as const)("accepts non-empty objects only: %j", ({ value, expected }) => { expect(hasReplyChannelData(value)).toBe(expected); }); }); describe("hasReplyContent", () => { it("treats whitespace-only text and empty structured payloads as empty", () => { expect( hasReplyContent({ text: " ", mediaUrls: ["", " "], interactive: { blocks: [] }, hasChannelData: false, }), ).toBe(false); }); it.each([ { name: "shared interactive blocks", input: { interactive: { blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }], }, }, }, { name: "explicit extra content", input: { text: " ", extraContent: true, }, }, ] as const)("accepts $name", ({ input }) => { expect(hasReplyContent(input)).toBe(true); }); }); describe("hasReplyPayloadContent", () => { it("treats portable locations as content", () => { expect( hasReplyPayloadContent({ location: { latitude: 1, longitude: 2 }, }), ).toBe(true); }); it("trims text and falls back to channel data by default", () => { expect( hasReplyPayloadContent({ text: " ", channelData: { slack: { blocks: [] } }, }), ).toBe(true); }); it.each([ { name: "explicit channel-data overrides", payload: { text: " ", channelData: {}, }, options: { hasChannelData: true, }, }, { name: "extra content", payload: { text: " ", }, options: { extraContent: true, }, }, ] as const)("accepts $name", ({ payload, options }) => { expect(hasReplyPayloadContent(payload, options)).toBe(true); }); }); describe("interactive payload helpers", () => { it("normalizes interactive replies and resolves text fallbacks", () => { const interactive = normalizeInteractiveReply({ blocks: [ { type: "text", text: "First" }, { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, { type: "text", text: "Second" }, ], }); expect(interactive).toEqual({ blocks: [ { type: "text", text: "First" }, { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, { type: "text", text: "Second" }, ], }); expect(resolveInteractiveTextFallback({ interactive })).toBe("First\n\nSecond"); }); it("preserves URL-only presentation buttons for native link renderers and fallback text", () => { const presentation = { blocks: [ { type: "buttons" as const, buttons: [{ label: "Docs", url: "https://example.com/docs" }], }, ], }; expect(presentationToInteractiveReply(presentation)).toEqual({ blocks: [ { type: "buttons", buttons: [{ label: "Docs", url: "https://example.com/docs" }], }, ], }); expect(renderMessagePresentationFallbackText({ presentation })).toBe( "- Docs: https://example.com/docs", ); }); it("preserves web app presentation buttons for channel-native renderers", () => { const presentation = { blocks: [ { type: "buttons" as const, buttons: [{ label: "Launch", web_app: { url: "https://example.com/app" } }], }, ], }; const normalized = normalizeMessagePresentation(presentation); expect(normalized).toEqual({ blocks: [ { type: "buttons", buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }], }, ], }); expect(presentationToInteractiveReply(normalized!)).toEqual({ blocks: [ { type: "buttons", buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }], }, ], }); expect(renderMessagePresentationFallbackText({ presentation: normalized })).toBe( "- Launch: https://example.com/app", ); }); it("normalizes typed presentation actions and bridges them to legacy values", () => { const normalized = normalizeMessagePresentation({ blocks: [ { type: "buttons", buttons: [ { label: "Plugins", action: { type: "command", command: "/codex plugins menu" }, }, { label: "Approve", action: { type: "callback", value: "/approve req allow-once" }, }, { label: "Allow once", action: { type: "approval", approvalId: "approval/😀", approvalKind: "exec", decision: "allow-once", }, }, { label: "Review", action: { type: "url", url: "https://example.com/approve/id" }, }, { label: "Open app", action: { type: "web-app", url: "https://example.com/app" }, }, ], }, ], }); expect(normalized).toEqual({ blocks: [ { type: "buttons", buttons: [ { label: "Plugins", action: { type: "command", command: "/codex plugins menu" }, }, { label: "Approve", action: { type: "callback", value: "/approve req allow-once" }, }, { label: "Allow once", action: { type: "approval", approvalId: "approval/😀", approvalKind: "exec", decision: "allow-once", }, }, { label: "Review", action: { type: "url", url: "https://example.com/approve/id" }, }, { label: "Open app", action: { type: "web-app", url: "https://example.com/app" }, }, ], }, ], }); expect(presentationToInteractiveReply(normalized!)).toEqual({ blocks: [ { type: "buttons", buttons: [ { label: "Plugins", action: { type: "command", command: "/codex plugins menu" }, value: "/codex plugins menu", }, { label: "Approve", action: { type: "callback", value: "/approve req allow-once" }, value: "/approve req allow-once", }, { label: "Allow once", action: { type: "approval", approvalId: "approval/😀", approvalKind: "exec", decision: "allow-once", }, }, { label: "Review", action: { type: "url", url: "https://example.com/approve/id" }, url: "https://example.com/approve/id", }, { label: "Open app", action: { type: "web-app", url: "https://example.com/app" }, webApp: { url: "https://example.com/app" }, }, ], }, ], }); }); it("requires a web-app target and preserves hosted widget ids", () => { const normalized = normalizeMessagePresentation({ blocks: [ { type: "buttons", buttons: [ { label: "Hosted widget", action: { type: "web-app", widgetId: " AAAAAAAAAAAAAAAAAAAAAA " }, }, { label: "Hosted fallback", action: { type: "web-app", widgetId: "BBBBBBBBBBBBBBBBBBBBBB", url: " https://example.com/app ", }, }, ], }, ], }); expect(normalized).toEqual({ blocks: [ { type: "buttons", buttons: [ { label: "Hosted widget", action: { type: "web-app", widgetId: "AAAAAAAAAAAAAAAAAAAAAA" }, }, { label: "Hosted fallback", action: { type: "web-app", widgetId: "BBBBBBBBBBBBBBBBBBBBBB", url: "https://example.com/app", }, }, ], }, ], }); const interactive = presentationToInteractiveReply(normalized!); expect(interactive?.blocks[0]).toMatchObject({ type: "buttons", buttons: [ { label: "Hosted widget", action: { type: "web-app", widgetId: "AAAAAAAAAAAAAAAAAAAAAA" }, }, { label: "Hosted fallback", action: { type: "web-app", widgetId: "BBBBBBBBBBBBBBBBBBBBBB", url: "https://example.com/app", }, webApp: { url: "https://example.com/app" }, }, ], }); expect(interactive?.blocks[0]).not.toHaveProperty("buttons.0.webApp"); expect(renderMessagePresentationFallbackText({ presentation: normalized })).toBe( "- Hosted widget\n- Hosted fallback: https://example.com/app", ); expect( resolveMessagePresentationButtonAction({ // Boundary input missing both url and widgetId; the union forbids this statically. action: { type: "web-app" } as unknown as MessagePresentationAction, }), ).toBeUndefined(); expect( normalizeMessagePresentation({ blocks: [ { type: "buttons", buttons: [{ label: "Missing", action: { type: "web-app" } }], }, ], }), ).toBeUndefined(); }); it("normalizes every model-picker intent and preserves typed bridges", () => { const base = { type: "model-picker", version: 1, snapshotToken: "snapshot_1" } as const; const model = { providerToken: "p", modelToken: "m" } as const; const actions = [ { ...base, intent: "show-providers", cursor: "page_2" }, { ...base, intent: "show-models", providerToken: "p", cursor: "page_3" }, { ...base, intent: "show-recents" }, { ...base, ...model, intent: "choose-model" }, { ...base, ...model, intent: "choose-runtime", runtimeToken: "r" }, { ...base, intent: "reset" }, { ...base, intent: "cancel" }, ] satisfies ModelPickerAction[]; for (const action of actions) { expect(resolveMessagePresentationOptionAction({ action }, { modelPicker: true })).toEqual( action, ); } const action = actions[3]!; expect(resolveMessagePresentationButtonAction({ action })).toBeUndefined(); const presentation = normalizeMessagePresentation({ blocks: [ { type: "buttons", buttons: [{ label: "Model", action }] }, { type: "select", options: [{ label: "Model", action }] }, ], }); const bridged = presentationToInteractiveReply(presentation!); expect(bridged?.blocks[0]).toMatchObject({ buttons: [{ action }] }); expect(bridged?.blocks[1]).toMatchObject({ options: [{ action }] }); expect(presentationToInteractiveControlsReply(presentation!)).toEqual(bridged); expect(resolveMessagePresentationControlValue({ action })).toBeUndefined(); const invalid = [ { ...base, version: 2, intent: "cancel" }, { ...base, intent: "submit" }, { type: "model-picker", version: 1, intent: "cancel" }, { ...base, intent: "choose-model", providerToken: "p", modelToken: "openai/gpt" }, { ...base, intent: "show-models" }, { ...base, intent: "choose-runtime", providerToken: "p", modelToken: "m" }, { ...base, snapshotToken: "x".repeat(129), intent: "cancel" }, ]; for (const invalidAction of invalid) { const malformed = { action: invalidAction } as never; expect( resolveMessagePresentationButtonAction(malformed, { modelPicker: true }), ).toBeUndefined(); } }); it("resolves deprecated button inputs without overriding a canonical action", () => { expect( resolveMessagePresentationButtonAction({ action: { type: "approval", approvalId: "approval:1", approvalKind: "plugin", decision: "deny", }, value: "legacy", url: "https://ignored.example", }), ).toEqual({ type: "approval", approvalId: "approval:1", approvalKind: "plugin", decision: "deny", }); expect( resolveMessagePresentationButtonAction({ value: "legacy", url: "https://example.com", webApp: { url: "https://app.example.com" }, }), ).toEqual({ type: "url", url: "https://example.com" }); expect( resolveMessagePresentationButtonAction({ value: "legacy", web_app: { url: "https://app.example.com" }, }), ).toEqual({ type: "web-app", url: "https://app.example.com" }); expect(resolveMessagePresentationButtonAction({ value: "legacy" })).toEqual({ type: "callback", value: "legacy", }); expect(resolveMessagePresentationOptionAction({ value: "option" })).toEqual({ type: "callback", value: "option", }); const invalidButton = { action: null, value: "legacy", url: "https://legacy.example", } as unknown as Parameters[0]; const invalidControl = { action: null, value: "legacy", } as unknown as Parameters[0]; const invalidOption = { action: null, value: "legacy", } as unknown as Parameters[0]; expect(resolveMessagePresentationButtonAction(invalidButton)).toBeUndefined(); expect(resolveMessagePresentationControlValue(invalidControl)).toBeUndefined(); expect(resolveMessagePresentationOptionAction(invalidOption)).toBeUndefined(); }); it("does not restore deprecated select values behind invalid explicit actions", () => { const presentation = { blocks: [ { type: "select", options: [{ label: "Invalid", action: null, value: "legacy" }], }, ], } as unknown as Parameters[0]; const interactive = presentationToInteractiveReply(presentation); expect(interactive?.blocks[0]).toMatchObject({ type: "select", options: [{ label: "Invalid" }], }); expect(interactive?.blocks[0]).not.toHaveProperty("options.0.value"); }); it("never exposes approval data through generic scalar resolution or fallback text", () => { const action = { type: "approval" as const, approvalId: "approval:secret-transport-id", approvalKind: "plugin" as const, decision: "allow-always" as const, }; expect(resolveMessagePresentationControlValue({ action, value: "legacy-shadow" })).toBe( undefined, ); expect( renderMessagePresentationFallbackText({ presentation: { blocks: [ { type: "buttons", buttons: [ { label: "Approve", action, value: "legacy-shadow", url: "https://ignored.example", }, ], }, ], }, }), ).toBe("- Approve"); expect( presentationToInteractiveReply({ blocks: [ { type: "buttons", buttons: [ { label: "Approve", action, value: "legacy-shadow", url: "https://ignored.example", }, ], }, ], }), ).toEqual({ blocks: [{ type: "buttons", buttons: [{ label: "Approve", action }] }], }); }); it("normalizes question actions without exposing their transport data", () => { const action = { type: "question" as const, questionId: "ask_0123456789abcdef0123456789abcdef", optionValue: "Production", }; const presentation = normalizeMessagePresentation({ blocks: [{ type: "buttons", buttons: [{ label: "Production", action }] }], }); expect(presentation).toEqual({ blocks: [{ type: "buttons", buttons: [{ label: "Production", action }] }], }); expect(resolveMessagePresentationControlValue({ action })).toBeUndefined(); expect(renderMessagePresentationFallbackText({ presentation: presentation ?? undefined })).toBe( "- Production", ); expect(presentationToInteractiveReply(presentation ?? { blocks: [] })).toEqual({ blocks: [{ type: "buttons", buttons: [{ label: "Production", action }] }], }); }); it.each([ { type: "Question", questionId: "ask_1", optionValue: "Yes" }, { type: "question", questionId: "", optionValue: "Yes" }, { type: "question", questionId: "ask_\ud800", optionValue: "Yes" }, { type: "question", questionId: "ask_1", optionValue: " " }, ])("rejects malformed question action %#", (action) => { expect( normalizeMessagePresentation({ blocks: [{ type: "buttons", buttons: [{ label: "Yes", action }] }], }), ).toBeUndefined(); }); it("rejects malformed canonical actions instead of falling back to legacy fields", () => { expect( normalizeMessagePresentation({ blocks: [ { type: "buttons", buttons: [ { label: "Approve", action: { type: "approval", approvalId: "approval:1", approvalKind: "exec", decision: "yes", }, value: "legacy", }, ], }, ], }), ).toBeUndefined(); expect( normalizeMessagePresentation({ blocks: [ { type: "buttons", buttons: [ { label: "Approve", action: { type: "approval", approvalId: "\ud800", approvalKind: "exec", decision: "allow-once", }, }, ], }, ], }), ).toBeUndefined(); expect( normalizeMessagePresentation({ blocks: [ { type: "buttons", buttons: [ { label: "Approve", action: { type: " APPROVAL ", approvalId: " approval:1 ", approvalKind: " EXEC ", decision: " ALLOW-ONCE ", }, value: "legacy", }, ], }, ], }), ).toBeUndefined(); expect( normalizeMessagePresentation({ blocks: [ { type: "select", options: [ { label: "Approve", action: { type: "approval", approvalId: "approval:1", approvalKind: "exec", decision: "allow-once", }, value: "legacy", }, ], }, ], }), ).toBeUndefined(); }); it("preserves protocol-valid boundary whitespace in typed approval actions", () => { const approvalId = "\uFEFF"; expect( normalizeMessagePresentation({ blocks: [ { type: "buttons", buttons: [ { label: "Deny", action: { type: "approval", approvalId, approvalKind: "exec", decision: "deny", }, }, ], }, ], }), ).toMatchObject({ blocks: [ { buttons: [ { action: { type: "approval", approvalId, approvalKind: "exec", decision: "deny" }, }, ], }, ], }); }); it("converts only presentation controls for native component renderers", () => { const presentation = { title: "Deploy approval", blocks: [ { type: "text" as const, text: "Canary is ready." }, { type: "divider" as const }, { type: "buttons" as const, buttons: [ { label: "Approve", value: "approve", style: "success" as const, reusable: true, }, ], }, { type: "select" as const, placeholder: "Rollback target", options: [{ label: "Previous", value: "previous" }], }, ], }; expect(presentationToInteractiveReply(presentation)).toEqual({ blocks: [ { type: "text", text: "Deploy approval" }, { type: "text", text: "Canary is ready." }, { type: "buttons", buttons: [{ label: "Approve", value: "approve", style: "success", reusable: true }], }, { type: "select", placeholder: "Rollback target", options: [{ label: "Previous", value: "previous" }], }, ], }); expect(presentationToInteractiveControlsReply(presentation)).toEqual({ blocks: [ { type: "buttons", buttons: [{ label: "Approve", value: "approve", style: "success", reusable: true }], }, { type: "select", placeholder: "Rollback target", options: [{ label: "Previous", value: "previous" }], }, ], }); }); it("preserves command values in button fallback text while keeping callback values private", () => { const presentation = { blocks: [ { type: "buttons" as const, buttons: [ { label: "Approve", value: "/approve req_1 allow-once" }, { label: "Deny", action: { type: "command" as const, command: "/approve req_1 deny" } }, { label: "Ignore", action: { type: "callback" as const, value: "ignore_123" } }, { label: "Docs", url: "https://example.com/docs" }, { label: "Legacy link override", action: { type: "command" as const, command: "/approve req_1" }, url: "https://example.com/review", }, { label: "Disabled", disabled: true }, { label: "DisabledCmd", disabled: true, action: { type: "command" as const, command: "/test" }, }, ], }, ], }; expect(renderMessagePresentationFallbackText({ presentation })).toBe( [ "- Approve", "- Deny: `/approve req_1 deny`", "- Ignore", "- Docs: https://example.com/docs", "- Legacy link override: `/approve req_1`", "- Disabled", "- DisabledCmd", ].join("\n"), ); }); it("keeps divider-only fallback empty unless a send transport fallback is requested", () => { const presentation = { blocks: [{ type: "divider" as const }], }; expect(renderMessagePresentationFallbackText({ presentation })).toBe(""); expect( renderMessagePresentationFallbackText({ presentation, emptyFallback: "---", }), ).toBe("---"); }); it("normalizes chart data and renders deterministic accessible fallback text", () => { const presentation = normalizeMessagePresentation({ blocks: [ { type: "chart", chartType: "pie", title: "Requests by region", segments: [ { label: "Americas", value: 52 }, { label: "Europe", value: 31 }, ], }, { type: "chart", chartType: "line", title: "Weekly latency", categories: ["Mon", "Tue"], series: [ { name: "p50", values: [120, 110] }, { name: "p95", values: [250, 230] }, ], xLabel: "Day", yLabel: "Milliseconds", }, ], }); expect(presentation).toEqual({ blocks: [ { type: "chart", chartType: "pie", title: "Requests by region", segments: [ { label: "Americas", value: 52 }, { label: "Europe", value: 31 }, ], }, { type: "chart", chartType: "line", title: "Weekly latency", categories: ["Mon", "Tue"], series: [ { name: "p50", values: [120, 110] }, { name: "p95", values: [250, 230] }, ], xLabel: "Day", yLabel: "Milliseconds", }, ], }); expect(renderMessagePresentationFallbackText({ presentation })).toBe( [ "Requests by region (pie chart)", "- Americas: 52", "- Europe: 31", "", "Weekly latency (line chart)", "X axis: Day", "Y axis: Milliseconds", "- p50: Mon: 120; Tue: 110", "- p95: Mon: 250; Tue: 230", ].join("\n"), ); expect(presentationToInteractiveReply(presentation!)).toEqual({ blocks: [ { type: "text", text: "Requests by region (pie chart)\n- Americas: 52\n- Europe: 31", }, { type: "text", text: [ "Weekly latency (line chart)", "X axis: Day", "Y axis: Milliseconds", "- p50: Mon: 120; Tue: 110", "- p95: Mon: 250; Tue: 230", ].join("\n"), }, ], }); }); it.each([ { name: "non-positive pie values", block: { type: "chart", chartType: "pie", title: "Invalid", segments: [{ label: "Zero", value: 0 }], }, }, { name: "duplicate categories", block: { type: "chart", chartType: "bar", title: "Invalid", categories: ["Q1", "Q1"], series: [{ name: "Revenue", values: [1, 2] }], }, }, { name: "mismatched series values", block: { type: "chart", chartType: "area", title: "Invalid", categories: ["Q1", "Q2"], series: [{ name: "Revenue", values: [1] }], }, }, { name: "duplicate series names", block: { type: "chart", chartType: "line", title: "Invalid", categories: ["Q1"], series: [ { name: "Revenue", values: [1] }, { name: "Revenue", values: [2] }, ], }, }, ])("drops chart blocks with $name instead of changing their data", ({ block }) => { expect(normalizeMessagePresentation({ blocks: [block] })).toBeUndefined(); }); it("normalizes tables and renders deterministic linear fallback text", () => { const presentation = normalizeMessagePresentation({ blocks: [ { type: "table", caption: " Pipeline report ", headers: [" Account ", "Stage", "ARR"], rows: [ [" Acme\nCorp ", "Won", 125000], ["Globex", "Review", 82000], ], rowHeaderColumnIndex: 0, }, ], }); expect(presentation).toEqual({ blocks: [ { type: "table", caption: "Pipeline report", headers: ["Account", "Stage", "ARR"], rows: [ ["Acme\nCorp", "Won", 125000], ["Globex", "Review", 82000], ], rowHeaderColumnIndex: 0, }, ], }); const fallback = [ "Pipeline report (table)", "- Account: Acme Corp; Stage: Won; ARR: 125000", "- Account: Globex; Stage: Review; ARR: 82000", ].join("\n"); expect(renderMessagePresentationFallbackText({ presentation })).toBe(fallback); expect(presentationToInteractiveReply(presentation!)).toEqual({ blocks: [{ type: "text", text: fallback }], }); }); it.each([ { name: "missing caption", block: { type: "table", headers: ["Name"], rows: [["Acme"]] }, }, { name: "empty headers", block: { type: "table", caption: "Report", headers: [], rows: [["Acme"]] }, }, { name: "duplicate headers", block: { type: "table", caption: "Report", headers: ["Name", "Name"], rows: [["Acme", "Won"]], }, }, { name: "empty rows", block: { type: "table", caption: "Report", headers: ["Name"], rows: [] }, }, { name: "mismatched row width", block: { type: "table", caption: "Report", headers: ["Name", "Stage"], rows: [["Acme"]], }, }, { name: "empty string cell", block: { type: "table", caption: "Report", headers: ["Name"], rows: [[" "]] }, }, { name: "non-finite numeric cell", block: { type: "table", caption: "Report", headers: ["ARR"], rows: [[Infinity]] }, }, { name: "non-scalar cell", block: { type: "table", caption: "Report", headers: ["Name"], rows: [[true]] }, }, { name: "out-of-range row header column", block: { type: "table", caption: "Report", headers: ["Name"], rows: [["Acme"]], rowHeaderColumnIndex: 1, }, }, ])("drops table blocks with $name instead of repairing their data", ({ block }) => { expect(normalizeMessagePresentation({ blocks: [block] })).toBeUndefined(); }); });