fix(slack): preserve long legacy interactive text (#127994)

* fix(slack): preserve long interactive and presentation text

* fix(slack): retain every outbound delivery receipt

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* test(slack): narrow presentation regression fixtures

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* fix(slack): preserve single-message presentation edits

Co-authored-by: Peter Steinberger <steipete@gmail.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Daniel Peng
2026-08-26 01:28:31 +08:00
committed by GitHub
parent a6810bb00e
commit a2ef21e433
11 changed files with 392 additions and 71 deletions
+7 -19
View File
@@ -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") {
+120 -2
View File
@@ -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", () => {
+53 -1
View File
@@ -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 = `<https://example.com/${"x".repeat(3_000)}>`;
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(
+21 -16
View File
@@ -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();
@@ -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,
@@ -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 {
+13 -1
View File
@@ -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 () => {
+31 -3
View File
@@ -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<ReturnType<SlackSendFn>>[] = [];
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<ReturnType<SlackSendFn>> | 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,
},
}
: {}),
};
},
}),
),
+29 -19
View File
@@ -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<ReplyPayload["presentation"]>;
}>)("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: {
+25
View File
@@ -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: [
@@ -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)} &amp;🚀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("&amp;🚀"))).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)} &amp;🚀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(