fix(slack): preserve table formatting in edits and previews (#130979)

This commit is contained in:
Peter Steinberger
2026-08-27 07:53:22 -07:00
committed by GitHub
parent dd2d66c26f
commit dc0b41c0db
9 changed files with 215 additions and 16 deletions
+77 -1
View File
@@ -1,6 +1,13 @@
// Slack tests cover actions.blocks plugin behavior.
import { describe, expect, it } from "vitest";
import {
createTestRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createSlackEditTestClient, createSlackSendTestClient } from "./blocks.test-helpers.js";
import { slackSetupPlugin } from "./channel.setup.js";
import { countSlackTextUtf8Bytes } from "./truncate.js";
const { editSlackMessage, editSlackRenderedMessage, sendSlackMessage } =
@@ -69,6 +76,75 @@ describe("sendSlackMessage blocks", () => {
});
describe("editSlackMessage blocks", () => {
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackSetupPlugin }]),
);
});
afterEach(() => resetPluginRuntimeStateForTest());
const table = "| Name | Value |\n| --- | --- |\n| Beta | 2 |";
const codeTable = "```\n| Name | Value |\n| ---- | ----- |\n| Beta | 2 |\n```";
const bulletTable = "*Beta*\n• Value: 2";
it.each<{
name: string;
channelMode?: MarkdownTableMode;
accountMode?: MarkdownTableMode;
useDefaultAccount?: boolean;
expected: string;
}>([
{ name: "default code tables", expected: codeTable },
{ name: "channel code tables", channelMode: "code", expected: codeTable },
{ name: "channel bullet tables", channelMode: "bullets", expected: bulletTable },
{ name: "disabled tables", channelMode: "off", expected: table },
{
name: "account bullet override",
channelMode: "off",
accountMode: "bullets",
expected: bulletTable,
},
{
name: "account disabled override",
channelMode: "code",
accountMode: "off",
expected: table,
},
{
name: "configured default account override",
channelMode: "off",
accountMode: "bullets",
useDefaultAccount: true,
expected: bulletTable,
},
])(
"preserves $name when editing authored Markdown",
async ({ channelMode, accountMode, useDefaultAccount, expected }) => {
const client = createSlackEditTestClient();
await editSlackMessage("C123", "171234.567", table, {
token: "xoxb-test",
client,
accountId: useDefaultAccount ? undefined : "work",
cfg: {
channels: {
slack: {
defaultAccount: "work",
markdown: { tables: channelMode },
accounts: { work: { markdown: { tables: accountMode } } },
},
},
},
});
expect(client.chat.update).toHaveBeenCalledExactlyOnceWith({
channel: "C123",
ts: "171234.567",
text: expected,
});
},
);
it("renders authored Markdown using the same mrkdwn dialect as sends", async () => {
const client = createSlackEditTestClient();
+7 -2
View File
@@ -2,11 +2,12 @@
import type { Block, KnownBlock, WebClient } from "@slack/web-api";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import { resolveSlackAccount } from "./accounts.js";
import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js";
import { SLACK_PRIVATE_ACTION_DELIVERY_RESULT } from "./action-threading.js";
import type { SlackAuthoredTextPlacement } from "./authored-text.js";
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
@@ -389,7 +390,11 @@ export async function editSlackMessage(
content: string,
opts: SlackActionClientOpts & { blocks?: (Block | KnownBlock)[] } = {},
) {
await editSlackRenderedMessage(channelId, messageId, normalizeSlackOutboundText(content), opts);
const accountId =
opts.accountId ?? (opts.cfg ? resolveDefaultSlackAccountId(opts.cfg) : undefined);
const tableMode = resolveMarkdownTableMode({ cfg: opts.cfg, channel: "slack", accountId });
const text = normalizeSlackOutboundText(content, { tableMode });
await editSlackRenderedMessage(channelId, messageId, text, opts);
}
// Finalized previews already contain Slack mrkdwn; a second Markdown render changes its meaning.
+5
View File
@@ -56,6 +56,11 @@ describe("chunkSlackMrkdwnText", () => {
});
describe("normalizeSlackOutboundText", () => {
it("leaves table parsing off for callers without an authored-text table mode", () => {
const table = "| Name | Value |\n| --- | --- |\n| Beta | 2 |";
expect(normalizeSlackOutboundText(table)).toBe(table);
});
it("marks assistant-authored transcript role headers after parsing Markdown", () => {
expect(normalizeSlackOutboundText("**user**[Thu 2026-07-02] question")).toBe(
"`user[Thu 2026-07-02]` question",
+7 -6
View File
@@ -425,7 +425,10 @@ function buildSlackRenderOptions() {
};
}
function markdownToSlackMrkdwn(markdown: string, options: SlackMarkdownOptions = {}): string {
export function normalizeSlackOutboundText(
markdown: string,
options: SlackMarkdownOptions = {},
): string {
const ir = makeSlackEmphasisStylesSafe(
markdownToIR(markdown ?? "", {
assistantTranscriptRoleHeaders: true,
@@ -436,11 +439,9 @@ function markdownToSlackMrkdwn(markdown: string, options: SlackMarkdownOptions =
tableMode: options.tableMode,
}),
);
return renderMarkdownWithMarkers(ir, buildSlackRenderOptions(), SLACK_FORMAT_PROFILE);
}
export function normalizeSlackOutboundText(markdown: string): string {
return protectSlackAssistantTranscriptRoleHeaders(markdownToSlackMrkdwn(markdown ?? ""));
return protectSlackAssistantTranscriptRoleHeaders(
renderMarkdownWithMarkers(ir, buildSlackRenderOptions(), SLACK_FORMAT_PROFILE),
);
}
/** Chunk already-rendered Slack mrkdwn without splitting entities or code markers. */
@@ -1,5 +1,11 @@
// Slack tests cover message action dispatch plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
createTestRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/channel-test-helpers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { slackSetupPlugin } from "./channel.setup.js";
import { handleSlackMessageAction } from "./message-action-dispatch.js";
import { extractSlackToolSend } from "./message-actions.js";
import { renderSlackMessagePresentationFallbackText } from "./presentation-fallback.js";
@@ -83,7 +89,20 @@ function largeTablePresentation() {
};
}
const paddedMarkdownTable = [
`| ${"H".repeat(200)} | Value |`,
"| --- | --- |",
...Array.from({ length: 20 }, () => "| x | 2 |"),
].join("\n");
describe("handleSlackMessageAction", () => {
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackSetupPlugin }]),
);
});
afterEach(() => resetPluginRuntimeStateForTest());
it("defaults reactions to the current inbound Slack message", async () => {
const invoke = createInvokeSpy();
const toolContext = { currentMessageId: "171234.567" };
@@ -267,6 +286,10 @@ describe("handleSlackMessageAction", () => {
{ name: "ASCII", message: `${"x".repeat(4_000)}TAIL` },
{ name: "multibyte", message: `${"😀".repeat(1_000)}TAIL` },
{ name: "expanded Slack markdown", message: `${"&".repeat(801)}TAIL` },
{
name: "padded Markdown table",
message: paddedMarkdownTable,
},
])("rejects oversized $name text-only edits before sending", async ({ message }) => {
const invoke = createInvokeSpy();
@@ -285,6 +308,34 @@ describe("handleSlackMessageAction", () => {
expect(invoke).not.toHaveBeenCalled();
});
it.each(["off", "bullets"] as const)(
"measures the account's %s table rendering before rejecting an edit",
async (tables) => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "edit",
cfg: {
channels: {
slack: {
defaultAccount: "work",
markdown: { tables: "code" },
accounts: { work: { markdown: { tables } } },
},
},
},
params: { channelId: "C1", messageId: "171234.567", message: paddedMarkdownTable },
} as never,
invoke: invoke as never,
});
expect(firstAction(invoke)).toMatchObject({
action: "editMessage",
content: paddedMarkdownTable,
});
},
);
it("accepts a text-only edit at Slack's exact byte limit", async () => {
const invoke = createInvokeSpy();
const message = "x".repeat(4_000);
@@ -8,6 +8,7 @@ import {
normalizeLegacyInteractiveReply,
normalizeMessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
import {
normalizeOptionalLowercaseString,
@@ -232,9 +233,14 @@ export async function handleSlackMessageAction(params: {
const accessibleContent = renderedPresentation.usesPresentationTextFallback
? renderSlackMessagePresentationFallbackText({ text: content, presentation })
: resolveSlackPresentationText(content, presentation);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: accountId ?? resolveDefaultSlackAccountId(cfg),
});
if (
!blocks &&
countSlackTextUtf8Bytes(normalizeSlackOutboundText(accessibleContent)) >
countSlackTextUtf8Bytes(normalizeSlackOutboundText(accessibleContent, { tableMode })) >
SLACK_EDIT_TEXT_MAX_BYTES
) {
const editSubject = renderedPresentation.usesPresentationTextFallback
@@ -1,7 +1,13 @@
import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
// Slack tests cover dispatch.preview fallback plugin behavior.
import {
createTestRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { slackSetupPlugin } from "../../channel.setup.js";
const FINAL_REPLY_TEXT = "final answer";
const THREAD_TS = "thread-1";
@@ -1131,6 +1137,9 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
});
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackSetupPlugin }]),
);
createSlackDraftStreamMock.mockReset();
deliverRepliesMock.mockReset();
finalizeSlackPreviewEditMock.mockReset();
@@ -1184,6 +1193,8 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
emitSlackMessageSentHooksMock.mockClear();
});
afterEach(() => resetPluginRuntimeStateForTest());
it("forwards durable ingress ownership into reply options", async () => {
const turnAdoptionLifecycle = {
admission: "exclusive",
@@ -1635,6 +1646,41 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expect(draftStream.clear).not.toHaveBeenCalled();
});
it.each([
["code", "```\n| Name | Value |\n| ---- | ----- |\n| Beta | 2 |\n```"],
["bullets", "*Beta*\n• Value: 2"],
["off", "| Name | Value |\n| --- | --- |\n| Beta | 2 |"],
] as const)(
"preserves %s table mode when finalizing authored preview text",
async (tables, expected) => {
const { normalizeSlackOutboundText } =
await vi.importActual<typeof import("../../format.js")>("../../format.js");
normalizeSlackOutboundTextMock.mockImplementation(normalizeSlackOutboundText);
try {
finalizeSlackPreviewEditMock.mockResolvedValueOnce(undefined);
mockedDispatchSequence = [
{ kind: "final", payload: { text: "| Name | Value |\n| --- | --- |\n| Beta | 2 |" } },
];
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
cfg: { channels: { slack: { markdown: { tables } } } },
}),
);
expect(finalizeSlackPreviewEditMock).toHaveBeenCalledOnce();
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "table preview edit", {
channelId: "C123",
messageId: "171234.567",
text: expected,
});
expect(deliverRepliesMock).not.toHaveBeenCalled();
} finally {
normalizeSlackOutboundTextMock.mockImplementation((value: string) => value.trim());
}
},
);
it("finalizes native chart blocks without re-escaping accessible preview text", async () => {
const draftStream = createDraftStreamStub();
const accessibleText =
@@ -1732,7 +1778,9 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
expect(normalizeSlackOutboundTextMock).toHaveBeenCalledTimes(1);
expect(normalizeSlackOutboundTextMock).toHaveBeenCalledWith("**Summary**");
expect(normalizeSlackOutboundTextMock).toHaveBeenCalledWith("**Summary**", {
tableMode: "code",
});
expectMockCallArgFields(finalizeSlackPreviewEditMock, 0, "block preview edit params", {
text: "**Summary**",
});
@@ -12,6 +12,7 @@ import {
deliverWithFinalizableLivePreviewAdapter,
} from "openclaw/plugin-sdk/channel-outbound";
import { toErrorObject } from "openclaw/plugin-sdk/error-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import {
buildTtsSupplementMediaPayload,
getReplyPayloadTtsSupplement,
@@ -233,7 +234,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const previewFinalText =
replyRenderPlan.mode === "single" && replyRenderPlan.textIsSlackMrkdwn
? trimmedFinalText
: normalizeSlackOutboundText((replySourceText ?? "").trim());
: normalizeSlackOutboundText((replySourceText ?? "").trim(), {
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: account.accountId,
}),
});
const previewFinalTextFitsEdit =
countSlackTextUtf8Bytes(previewFinalText) <= SLACK_EDIT_TEXT_MAX_BYTES;
const shouldRestoreTtsSupplementTextForPreviewFallback =
+1 -1
View File
@@ -121,7 +121,7 @@ export function resolveSlackReplyDeliveryMessages(params: {
});
}
if (outsideText) {
messages.push({ text: outsideText });
messages.push({ text: outsideText, authoredTextPlacement: "outside-blocks" });
}
return messages;
}