diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index eba71fc39f7e..343a9f2d0840 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -29,6 +29,7 @@ import { hasSlackDataVisualizationBlock, SLACK_DATA_VISUALIZATION_BLOCKS_MAX, } from "./data-visualization.js"; +import { chunkSlackMrkdwnText } from "./format.js"; import { renderSlackMessagePresentationChartFallbackText } from "./presentation-fallback.js"; import { SLACK_ACTION_BLOCK_ELEMENTS_MAX, @@ -284,22 +285,12 @@ export function buildSlackPresentationBlocks( if (!text) { continue; } - if (block.type === "context") { - blocks.push({ - type: "context", - elements: [ - { - type: "mrkdwn", - text: truncateSlackText(text, SLACK_SECTION_TEXT_MAX), - verbatim: true, - }, - ], - }); - } else { - blocks.push({ - type: "section", - text: { type: "mrkdwn", text: truncateSlackText(text, SLACK_SECTION_TEXT_MAX) }, - }); + for (const chunk of chunkSlackMrkdwnText(text, SLACK_SECTION_TEXT_MAX)) { + blocks.push( + block.type === "context" + ? { type: "context", elements: [{ type: "mrkdwn", text: chunk, verbatim: true }] } + : { type: "section", text: { type: "mrkdwn", text: chunk } }, + ); } continue; } @@ -451,9 +442,6 @@ export function canRenderSlackPresentation( let dataVisualizationCount = options.dataVisualizationCountOffset ?? 0; for (const block of presentation.blocks) { if (block.type === "text" || block.type === "context") { - if (!isWithinSlackLimit(block.text.trim(), SLACK_SECTION_TEXT_MAX)) { - return false; - } continue; } if (block.type === "buttons") { diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 1a1abf06a547..502a7da18872 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,5 +1,6 @@ -import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; // Slack tests cover channel plugin behavior. +import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound"; +import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,6 +8,7 @@ import { slackPlugin } from "./channel.js"; import { registerSlackInstallationState } from "./installation-identity-state.js"; import { slackOutbound } from "./outbound-adapter.js"; import * as probeModule from "./probe.js"; +import { SLACK_QUESTION_FINALIZATION_BLOCKS } from "./reply-action-ids.js"; import type { OpenClawConfig } from "./runtime-api.js"; import { setSlackRuntime } from "./runtime.js"; @@ -1605,7 +1607,11 @@ describe("slackPlugin outbound", () => { }, }, ]); - expect(result).toEqual({ channel: "slack", messageId: "m-final" }); + expect(result).toMatchObject({ + channel: "slack", + messageId: "m-final", + receipt: { platformMessageIds: ["m-media-1", "m-media-2", "m-final"] }, + }); }); it("renders shared interactive payloads into Slack Block Kit via plugin outbound", async () => { @@ -1671,6 +1677,118 @@ describe("slackPlugin outbound", () => { expectRecordFields(options[1], "production option", { value: "production" }); expect(result).toEqual({ channel: "slack", messageId: "m-interactive" }); }); + + it.each([ + { surface: "interactive", type: "text" }, + { surface: "presentation", type: "text" }, + { surface: "presentation", type: "context" }, + ] as const)( + "delivers oversized $surface $type in order across the real Slack outbound adapter", + async ({ surface, type }) => { + const sendSlack = vi + .fn() + .mockResolvedValueOnce({ messageId: "m-chunk-1" }) + .mockResolvedValueOnce({ messageId: "m-chunk-2" }); + const text = "x".repeat(3_000 * 50 + 1); + const buttons = { + type: "buttons" as const, + buttons: [{ label: "Continue", value: "continue" }], + }; + const presentationTextBlock = + type === "context" ? { type: "context" as const, text } : { type: "text" as const, text }; + const payload = + surface === "interactive" + ? { text: "", interactive: { blocks: [{ type: "text" as const, text }, buttons] } } + : { text: "", presentation: { blocks: [presentationTextBlock, buttons] } }; + + const result = await requireSlackSendPayload()({ + cfg, + to: "channel:C123", + text: "", + payload, + accountId: "default", + deps: { sendSlack }, + }); + const batches = sendSlack.mock.calls.map((_call, index) => + requireArray(requireMockCallArg(sendSlack, index, 2).blocks, "Slack blocks"), + ); + const delivered = batches.flat().flatMap((entry) => { + const block = requireRecord(entry, "Slack block"); + const textObject = + block.type === "context" + ? requireArray(block.elements, "context elements")[0] + : block.type === "section" + ? block.text + : undefined; + return textObject ? [String(requireRecord(textObject, "Slack text").text)] : []; + }); + + expect(batches.map((blocks) => blocks.length)).toEqual([50, 2]); + expect(delivered.join("")).toBe(text); + expect(batches[1]?.[1]).toMatchObject({ type: "actions" }); + expect(result).toMatchObject({ + channel: "slack", + messageId: "m-chunk-2", + receipt: { platformMessageIds: ["m-chunk-1", "m-chunk-2"] }, + }); + }, + ); + + it("retains media and every reply receipt without losing an earlier question card", async () => { + const questionId = "ask_0123456789abcdef0123456789abcdef"; + const questionMeta = { + slackQuestionActionIds: ["openclaw:question_button:1:1"], + [SLACK_QUESTION_FINALIZATION_BLOCKS]: [{ type: "divider" as const }], + }; + const createResult = (messageId: string, kind: "media" | "card") => ({ + messageId, + channelId: "C123", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "slack", messageId }], + kind, + }), + }); + const sendSlack = vi + .fn() + .mockResolvedValueOnce(createResult("m-upload", "media")) + .mockResolvedValueOnce({ ...createResult("m-question", "card"), meta: questionMeta }) + .mockResolvedValueOnce(createResult("m-final", "card")); + + const result = await requireSlackSendPayload()({ + cfg, + to: "channel:C123", + text: "", + accountId: "default", + deps: { sendSlack }, + payload: { + text: "", + mediaUrls: ["https://example.com/context.png"], + channelData: { + slack: { blocks: Array.from({ length: 48 }, () => ({ type: "divider" as const })) }, + }, + presentation: { + blocks: [ + { + type: "buttons", + buttons: [ + { + label: "Answer", + action: { type: "question", questionId, optionValue: "one" }, + }, + ], + }, + { type: "text", text: "x".repeat(3_001) }, + ], + }, + }, + }); + + expect(sendSlack).toHaveBeenCalledTimes(3); + expect(result.messageId).toBe("m-final"); + expect(result.receipt?.platformMessageIds).toEqual(["m-upload", "m-question", "m-final"]); + expect(result.receipt?.parts.map((part) => part.index)).toEqual([0, 1, 2]); + expect(result.meta).toEqual({ ...questionMeta, slackQuestionMessageId: "m-question" }); + }); }); describe("slackPlugin directory", () => { diff --git a/extensions/slack/src/format.test.ts b/extensions/slack/src/format.test.ts index 6e3e19036bd6..c003e0e527a7 100644 --- a/extensions/slack/src/format.test.ts +++ b/extensions/slack/src/format.test.ts @@ -1,8 +1,60 @@ // Slack tests cover format plugin behavior. import { describe, expect, it } from "vitest"; -import { markdownToSlackMrkdwnChunks, normalizeSlackOutboundText } from "./format.js"; +import { + chunkSlackMrkdwnText, + markdownToSlackMrkdwnChunks, + normalizeSlackOutboundText, +} from "./format.js"; import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; +describe("chunkSlackMrkdwnText", () => { + it("preserves ordinary whitespace at Slack section boundaries", () => { + const text = `${"x".repeat(2_998)} tail`; + const chunks = chunkSlackMrkdwnText(text, 3_000); + + expect(chunks.join("")).toBe(text); + expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true); + }); + + it("keeps short inline-code spans together until the actual section boundary", () => { + const text = `${"a`b`".repeat(750)}x`; + const chunks = chunkSlackMrkdwnText(text, 3_000); + + expect(chunks).toHaveLength(2); + expect(chunks.join("")).toBe(text); + expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true); + }); + + it.each(["`", "```"])("balances long %s code sections without losing their content", (marker) => { + const content = "x".repeat(3_100); + const chunks = chunkSlackMrkdwnText(`${marker}${content}${marker}`, 3_000); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.startsWith(marker) && chunk.endsWith(marker))).toBe(true); + expect(chunks.map((chunk) => chunk.slice(marker.length, -marker.length)).join("")).toBe( + content, + ); + expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true); + }); + + it.each([ + ["inline", "`", [1, 2]], + ["fenced", "```", [1, 5, 6]], + ] as const)("preserves content when a %s code wrapper cannot fit", (_name, marker, limits) => { + for (const limit of limits) { + expect(chunkSlackMrkdwnText(`${marker}x${marker}`, limit)).toEqual(["x"]); + } + }); + + it.each(["`", "```"])("does not emit marker-only sections for oversized %s links", (marker) => { + const token = ``; + const chunks = chunkSlackMrkdwnText(`${marker}${token}${marker}`, 3_000); + + expect(chunks.every((chunk) => chunk.length > marker.length * 2)).toBe(true); + expect(chunks.map((chunk) => chunk.slice(marker.length, -marker.length)).join("")).toBe(token); + }); +}); + describe("normalizeSlackOutboundText", () => { it("marks assistant-authored transcript role headers after parsing Markdown", () => { expect(normalizeSlackOutboundText("**user**[Thu 2026-07-02] question")).toBe( diff --git a/extensions/slack/src/format.ts b/extensions/slack/src/format.ts index aec634760847..4934c87d1f35 100644 --- a/extensions/slack/src/format.ts +++ b/extensions/slack/src/format.ts @@ -456,36 +456,44 @@ export function chunkSlackMrkdwnText(text: string, limit: number): string[] { (text.match(/<[^>\n]+>/gu)?.some(isAllowedSlackAngleToken) ?? false) || /\\[\s\S]/u.test(text); if (!hasProtectedToken) { - return chunkTextForOutbound(text, limit); + return chunkTextForOutbound(text, limit, { preserveWhitespace: true }); } const chunks: string[] = []; let activeMarker: SlackCodeMarker | undefined; let content = ""; - const wrapper = () => - activeMarker && limit > activeMarker.length * 2 ? activeMarker : undefined; - const capacity = () => limit - (wrapper()?.length ?? 0) * 2; + const wrapper = (marker: SlackCodeMarker | undefined) => + marker && limit > marker.length * 2 ? marker : undefined; + const capacity = (marker: SlackCodeMarker | undefined) => limit - (wrapper(marker)?.length ?? 0); const flush = () => { - if (!content) { - return; + const marker = wrapper(activeMarker); + if (content && content !== marker) { + chunks.push(marker ? `${content}${marker}` : content); } - const marker = wrapper(); - chunks.push(marker ? `${marker}${content}${marker}` : content); content = ""; }; for (const token of tokenizeSlackMrkdwn(text)) { const transition = resolveSlackCodeMarkerTransition(activeMarker, token); - if (transition !== null) { - flush(); - activeMarker = transition; + const nextMarker = transition === null ? activeMarker : transition; + const sourceMarker = token === "`" || token === "```" ? token : undefined; + if (transition !== null && sourceMarker && !wrapper(sourceMarker)) { + activeMarker = nextMarker; continue; } + if (content && content.length + token.length > capacity(nextMarker)) { + flush(); + } + activeMarker = nextMarker; + if (!content && transition === undefined) { + continue; + } + content ||= transition === null ? (wrapper(activeMarker) ?? "") : ""; - const contentLimit = capacity(); + const contentLimit = capacity(activeMarker) - (wrapper(activeMarker)?.length ?? 0); if (token.length > contentLimit) { flush(); - const marker = wrapper(); + const marker = wrapper(activeMarker); if (activeMarker && isAllowedSlackAngleToken(token)) { if (marker) { chunks.push( @@ -509,9 +517,6 @@ export function chunkSlackMrkdwnText(text: string, limit: number): string[] { chunks.push(...(token.length <= limit ? [token] : chunkTextForOutbound(token, limit))); continue; } - if (content && content.length + token.length > contentLimit) { - flush(); - } content += token; } flush(); diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index 059a6142fa8c..d90f283d405c 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -595,7 +595,34 @@ describe("handleSlackMessageAction", () => { expect(firstAction(invoke)).toMatchObject({ content: `- ${label}`, blocks: undefined }); }); - it("rejects presentation fallback edits that overflow after Slack markdown rendering", async () => { + it.each(["text", "context"] as const)( + "keeps a complete oversized %s presentation in one text-only edit", + async (type) => { + const invoke = createInvokeSpy(); + const text = "x".repeat(3_001); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "edit", + cfg: {}, + params: { + channelId: "C1", + messageId: "171234.567", + presentation: { blocks: [{ type, text }] }, + }, + } as never, + invoke: invoke as never, + }); + + expect(firstAction(invoke)).toMatchObject({ content: text, blocks: undefined }); + }, + ); + + it.each([ + { name: "Slack markdown rendering", text: `${"&".repeat(801)}${"x".repeat(2_200)}` }, + { name: "UTF-8 expansion", text: "😀".repeat(1_501) }, + ])("rejects presentation fallback edits that overflow after $name", async ({ text }) => { const invoke = createInvokeSpy(); await expect( @@ -608,7 +635,7 @@ describe("handleSlackMessageAction", () => { channelId: "C1", messageId: "171234.567", presentation: { - blocks: [{ type: "text", text: `${"&".repeat(801)}${"x".repeat(2_200)}` }], + blocks: [{ type: "text", text }], }, }, } as never, diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 3d1f03b41335..3a13764ee005 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -19,6 +19,7 @@ import { buildSlackPresentationBlocks, canRenderSlackPresentation } from "./bloc import { normalizeSlackOutboundText } from "./format.js"; import { SLACK_EDIT_TEXT_MAX_BYTES } from "./limits.js"; import { renderSlackMessagePresentationFallbackText } from "./presentation-fallback.js"; +import { SLACK_SECTION_TEXT_MAX } from "./presentation.js"; import { resolveSlackReplyBlockResolution, resolveSlackReplyDeliveryMessages, @@ -60,9 +61,15 @@ function renderSlackActionPresentation( if (!presentation) { return { usesPresentationTextFallback: false }; } - const renderedBlocks = canRenderSlackPresentation(presentation) - ? buildSlackPresentationBlocks(presentation) - : undefined; + const needsCompleteTextFallback = presentation.blocks.some( + (block) => + (block.type === "text" || block.type === "context") && + block.text.trim().length > SLACK_SECTION_TEXT_MAX, + ); + const renderedBlocks = + !needsCompleteTextFallback && canRenderSlackPresentation(presentation) + ? buildSlackPresentationBlocks(presentation) + : undefined; const usesPresentationTextFallback = !renderedBlocks || renderedBlocks.length > SLACK_MAX_BLOCKS; const blocks = usesPresentationTextFallback ? undefined : renderedBlocks; return { diff --git a/extensions/slack/src/outbound-adapter.test.ts b/extensions/slack/src/outbound-adapter.test.ts index 93f55b60005b..a7af8ddbb99b 100644 --- a/extensions/slack/src/outbound-adapter.test.ts +++ b/extensions/slack/src/outbound-adapter.test.ts @@ -129,7 +129,19 @@ describe("slackOutbound", () => { }, ], }); - expect(result).toEqual({ channel: "slack", messageId: "m-final" }); + expect(result).toMatchObject({ + channel: "slack", + messageId: "m-final", + receipt: { + platformMessageIds: ["m-media-1", "m-media-2", "m-final"], + primaryPlatformMessageId: "m-media-1", + parts: [ + { index: 0, platformMessageId: "m-media-1" }, + { index: 1, platformMessageId: "m-media-2" }, + { index: 2, platformMessageId: "m-final" }, + ], + }, + }); }); it("forwards forced-media intent through the core outbound adapter", async () => { diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 810dacee4802..d19bcdede86e 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -1,7 +1,10 @@ // Slack plugin module implements outbound adapter behavior. import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; -import type { OutboundIdentity } from "openclaw/plugin-sdk/channel-outbound"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound"; +import { + createMessageReceiptFromOutboundResults, + resolveOutboundSendDep, + type OutboundIdentity, +} from "openclaw/plugin-sdk/channel-outbound"; import { attachChannelToResult, type ChannelOutboundAdapter, @@ -316,6 +319,7 @@ export const slackOutbound: ChannelOutboundAdapter = { text: payload.text, }); const useSingleDeliveryMarker = mediaUrls.length === 0 && deliveryMessages.length === 1; + const sentResults: Awaited>[] = []; return attachChannelToResult( "slack", toSlackOutboundResult( @@ -329,6 +333,9 @@ export const slackOutbound: ChannelOutboundAdapter = { mediaUrl, deliveryQueueId: useSingleDeliveryMarker ? ctx.deliveryQueueId : undefined, }), + onResult: (result) => { + sentResults.push(result); + }, finalize: async () => { let lastResult: Awaited> | undefined; for (const message of deliveryMessages) { @@ -345,11 +352,32 @@ export const slackOutbound: ChannelOutboundAdapter = { ...(message.textIsSlackPlainText ? { textIsSlackPlainText: true } : {}), deliveryQueueId: useSingleDeliveryMarker ? ctx.deliveryQueueId : undefined, }); + sentResults.push(lastResult); } if (!lastResult) { throw new Error("Slack rendered presentation produced no deliverable segment"); } - return lastResult; + if (sentResults.length === 1) { + return lastResult; + } + const receipt = createMessageReceiptFromOutboundResults({ results: sentResults }); + receipt.parts = receipt.parts.map((part, index) => ({ ...part, index })); + const questionResult = sentResults.find( + (result) => result.meta?.slackQuestionActionIds.length, + ); + return { + ...lastResult, + receipt, + ...(questionResult?.meta + ? { + meta: { + ...questionResult.meta, + slackQuestionMessageId: + questionResult.meta.slackQuestionMessageId ?? questionResult.messageId, + }, + } + : {}), + }; }, }), ), diff --git a/extensions/slack/src/outbound-payload.test.ts b/extensions/slack/src/outbound-payload.test.ts index 8c25543fbfa7..d6d587d7b4f2 100644 --- a/extensions/slack/src/outbound-payload.test.ts +++ b/extensions/slack/src/outbound-payload.test.ts @@ -618,30 +618,40 @@ describe("slackOutbound sendPayload", () => { expect(linkButton).not.toHaveProperty("value"); }); - it.each([ - { - name: "title", - presentation: { title: "x".repeat(151), blocks: [] }, - }, - { - name: "text block", - presentation: { blocks: [{ type: "text", text: "x".repeat(3001) }] }, - }, - { - name: "context block", - presentation: { blocks: [{ type: "context", text: "x".repeat(3001) }] }, - }, - ] satisfies Array<{ - name: string; - presentation: NonNullable; - }>)("keeps the portable fallback for an oversized $name", async ({ presentation }) => { - const payload: ReplyPayload = { presentation }; - + it("keeps the portable fallback for an oversized title", async () => { + const payload: ReplyPayload = { presentation: { title: "x".repeat(151), blocks: [] } }; const segments = renderedPresentationSegments(await renderPresentation(payload)); expect(segments).toHaveLength(1); expect(segments[0]).toMatchObject({ kind: "text", mrkdwn: false }); }); + it.each(["text", "context"] as const)( + "renders oversized %s blocks as complete bounded native Slack blocks", + async (type) => { + const text = "x".repeat(3_001); + const payload: ReplyPayload = { presentation: { blocks: [{ type, text }] } }; + const segments = renderedPresentationSegments(await renderPresentation(payload)); + const [segment] = segments; + + expect(segments).toHaveLength(1); + expect(segment?.kind).toBe("blocks"); + if (segment?.kind !== "blocks") { + throw new Error("Expected native Slack blocks"); + } + expect(segment.blocks).toHaveLength(2); + const chunks = segment.blocks.flatMap((block) => { + if (block.type === "section" && "text" in block && block.text?.type === "mrkdwn") { + return [block.text.text]; + } + const element = + block.type === "context" && "elements" in block ? block.elements[0] : undefined; + return element?.type === "mrkdwn" ? [element.text] : []; + }); + expect(chunks.join("")).toBe(text); + expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true); + }, + ); + it("starts a new segment when presentation content crosses Slack's block limit", async () => { const payload: ReplyPayload = { channelData: { diff --git a/extensions/slack/src/reply-blocks.test.ts b/extensions/slack/src/reply-blocks.test.ts index 14290e789ead..2717739d3a2d 100644 --- a/extensions/slack/src/reply-blocks.test.ts +++ b/extensions/slack/src/reply-blocks.test.ts @@ -521,6 +521,31 @@ describe("renderSlackMessagePresentationFallbackText", () => { }); }); + it.each(["interactive", "presentation"] as const)( + "preserves %s text across Slack's 50-block message boundary", + (surface) => { + const text = "x".repeat(3_000 * 50 + 1); + const payload = + surface === "interactive" + ? { interactive: { blocks: [{ type: "text" as const, text }] } } + : { presentation: { blocks: [{ type: "text" as const, text }] } }; + const { segments } = resolveSlackReplyBlockResolution(payload); + const blockSegments = segments.flatMap((segment) => + segment.kind === "blocks" ? [segment.blocks] : [], + ); + const delivered = blockSegments.flatMap((blocks) => + blocks.flatMap((block) => + block.type === "section" && "text" in block && block.text?.type === "mrkdwn" + ? [block.text.text] + : [], + ), + ); + + expect(blockSegments.map((blocks) => blocks.length)).toEqual([50, 1]); + expect(delivered.join("")).toBe(text); + }, + ); + it("subtracts exact legacy mirrors for every typed action family", () => { const presentation = { blocks: [ diff --git a/extensions/slack/src/shared-interactive.test.ts b/extensions/slack/src/shared-interactive.test.ts index e58c24703ec0..733ce9a08b47 100644 --- a/extensions/slack/src/shared-interactive.test.ts +++ b/extensions/slack/src/shared-interactive.test.ts @@ -76,7 +76,22 @@ describe("buildSlackInteractiveBlocks", () => { ]); }); - it("truncates Slack render strings to Block Kit limits", () => { + it("preserves long legacy text, whitespace, protected entities, and surrogate pairs", () => { + const text = `${"x".repeat(2_998)} &🚀tail`; + const blocks = buildSlackInteractiveBlocks({ blocks: [{ type: "text", text }] }); + const sections = blocks.map((block) => + block.type === "section" && "text" in block && block.text?.type === "mrkdwn" + ? block.text.text + : "", + ); + + expect(sections.join("")).toBe(text); + expect(sections).toHaveLength(2); + expect(sections.every((section) => section.length <= 3_000)).toBe(true); + expect(sections.some((section) => section.includes("&🚀"))).toBe(true); + }); + + it("keeps Slack sections and interactive controls within Block Kit limits", () => { const long = "x".repeat(120); const blocks = buildSlackInteractiveBlocks({ blocks: [ @@ -85,15 +100,24 @@ describe("buildSlackInteractiveBlocks", () => { { type: "buttons", buttons: [{ label: long, value: long }] }, ], }); - const section = blocks[0] as { text?: { text?: string } }; - const selectBlock = blocks[1] as { + const sections = blocks.flatMap((block) => + block.type === "section" && "text" in block && block.text?.type === "mrkdwn" + ? [block.text.text] + : [], + ); + const selectBlock = blocks.find( + (block) => block.type === "actions" && block.block_id === "openclaw_reply_select_1", + ) as { elements?: Array<{ placeholder?: { text?: string } }>; }; - const buttonBlock = blocks[2] as { + const buttonBlock = blocks.find( + (block) => block.type === "actions" && block.block_id === "openclaw_reply_buttons_1", + ) as { elements?: Array<{ value?: string }>; }; - expect((section.text?.text ?? "").length).toBeLessThanOrEqual(3000); + expect(sections.join("")).toBe("y".repeat(3_100)); + expect(sections.every((section) => section.length <= 3_000)).toBe(true); expect((selectBlock.elements?.[0]?.placeholder?.text ?? "").length).toBeLessThanOrEqual(75); expect(buttonBlock.elements?.[0]?.value).toBe(long); }); @@ -388,6 +412,31 @@ describe("buildSlackInteractiveBlocks", () => { }); describe("buildSlackPresentationBlocks", () => { + it.each(["text", "context"] as const)( + "preserves long %s presentation blocks without truncating their mrkdwn", + (type) => { + const text = `${"x".repeat(2_998)} &🚀tail`; + const presentation: MessagePresentation = { blocks: [{ type, text }] }; + const blocks = buildSlackPresentationBlocks(presentation); + const chunks = blocks.flatMap((block) => { + if (block.type === "section" && "text" in block && block.text?.type === "mrkdwn") { + return [block.text.text]; + } + const element = + block.type === "context" && "elements" in block ? block.elements[0] : undefined; + return element?.type === "mrkdwn" ? [element.text] : []; + }); + + expect(canRenderSlackPresentation(presentation)).toBe(true); + expect(chunks.join("")).toBe(text); + expect(chunks).toHaveLength(2); + expect(chunks.every((chunk) => chunk.length <= 3_000)).toBe(true); + expect( + blocks.every((block) => block.type === (type === "text" ? "section" : "context")), + ).toBe(true); + }, + ); + it("renders question choices with compact private indices", () => { const questionId = "ask_0123456789abcdef0123456789abcdef"; expect(