feat: add rich Slack progress drafts

This commit is contained in:
Peter Steinberger
2026-05-04 05:38:51 +01:00
parent 654b70dde8
commit b5d408cd69
15 changed files with 440 additions and 56 deletions

View File

@@ -129,6 +129,10 @@ export const slackChannelConfigUiHints = {
label: "Slack Progress Max Lines",
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
},
"streaming.progress.render": {
label: "Slack Progress Renderer",
help: 'Progress draft renderer: "text" uses one portable text body; "rich" renders structured Slack Block Kit fields with the same text fallback.',
},
"streaming.progress.toolProgress": {
label: "Slack Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",

View File

@@ -59,6 +59,28 @@ describe("createSlackDraftStream", () => {
});
});
it("sends and edits rich draft blocks with text fallback", async () => {
const { stream, send, edit } = createDraftStreamHarness();
const blocks = [{ type: "divider" }] as const;
stream.update({ text: "fallback", blocks: [...blocks] });
await stream.flush();
stream.update({ text: "updated fallback", blocks: [...blocks] });
await stream.flush();
expect(send).toHaveBeenCalledWith(
"channel:C123",
"fallback",
expect.objectContaining({ blocks: [...blocks] }),
);
expect(edit).toHaveBeenCalledWith(
"C123",
"111.222",
"updated fallback",
expect.objectContaining({ blocks: [...blocks] }),
);
});
it("does not send duplicate text", async () => {
const { stream, send, edit } = createDraftStreamHarness();

View File

@@ -1,3 +1,4 @@
import type { Block, KnownBlock } from "@slack/web-api";
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
@@ -8,7 +9,7 @@ import { sendMessageSlack } from "./send.js";
const DEFAULT_THROTTLE_MS = 1000;
type SlackDraftStream = {
update: (text: string) => void;
update: (update: SlackDraftStreamUpdate) => void;
flush: () => Promise<void>;
clear: () => Promise<void>;
discardPending: () => Promise<void>;
@@ -19,6 +20,13 @@ type SlackDraftStream = {
channelId: () => string | undefined;
};
export type SlackDraftStreamUpdate =
| string
| {
text: string;
blocks?: (Block | KnownBlock)[];
};
export function createSlackDraftStream(params: {
target: string;
cfg: OpenClawConfig;
@@ -42,9 +50,13 @@ export function createSlackDraftStream(params: {
let streamMessageId: string | undefined;
let streamChannelId: string | undefined;
let lastSentText = "";
let lastSentKey = "";
let pendingUpdate: SlackDraftStreamUpdate | undefined;
let stopped = false;
const normalizeUpdate = (update: SlackDraftStreamUpdate) =>
typeof update === "string" ? { text: update } : update;
const sendOrEditStreamMessage = async (text: string) => {
if (stopped) {
return;
@@ -58,16 +70,20 @@ export function createSlackDraftStream(params: {
params.warn?.(`slack stream preview stopped (text length ${trimmed.length} > ${maxChars})`);
return;
}
if (trimmed === lastSentText) {
const update = normalizeUpdate(pendingUpdate ?? text);
const blocks = update.text === text ? update.blocks : undefined;
const sentKey = `${trimmed}\n${blocks ? JSON.stringify(blocks) : ""}`;
if (sentKey === lastSentKey) {
return;
}
lastSentText = trimmed;
lastSentKey = sentKey;
try {
if (streamChannelId && streamMessageId) {
await edit(streamChannelId, streamMessageId, trimmed, {
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
...(blocks ? { blocks } : {}),
});
return;
}
@@ -76,6 +92,7 @@ export function createSlackDraftStream(params: {
token: params.token,
accountId: params.accountId,
threadTs: params.resolveThreadTs?.(),
...(blocks ? { blocks } : {}),
});
streamChannelId = sent.channelId || streamChannelId;
streamMessageId = sent.messageId || streamMessageId;
@@ -112,7 +129,8 @@ export function createSlackDraftStream(params: {
const messageId = streamMessageId;
streamChannelId = undefined;
streamMessageId = undefined;
lastSentText = "";
lastSentKey = "";
pendingUpdate = undefined;
if (!channelId || !messageId) {
return;
}
@@ -129,14 +147,19 @@ export function createSlackDraftStream(params: {
const forceNewMessage = () => {
streamMessageId = undefined;
streamChannelId = undefined;
lastSentText = "";
lastSentKey = "";
pendingUpdate = undefined;
loop.resetPending();
};
params.log?.(`slack stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
update: loop.update,
update: (update: SlackDraftStreamUpdate) => {
const normalized = normalizeUpdate(update);
pendingUpdate = update;
loop.update(normalized.text);
},
flush: loop.flush,
clear,
discardPending,

View File

@@ -13,15 +13,18 @@ import {
resolveChannelSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
buildChannelProgressDraftLine,
createChannelProgressDraftGate,
formatChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
resolveChannelProgressDraftLabel,
resolveChannelProgressDraftRender,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingNativeTransport,
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
type ChannelProgressDraftLine,
} from "openclaw/plugin-sdk/channel-streaming";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
@@ -44,6 +47,7 @@ import {
isSlackInteractiveRepliesEnabled,
} from "../../interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "../../limits.js";
import { buildSlackProgressDraftBlocks } from "../../progress-blocks.js";
import { recordSlackThreadParticipation } from "../../sent-thread-cache.js";
import { applyAppendOnlyStreamUpdate, resolveSlackStreamingConfig } from "../../stream-mode.js";
import type { SlackStreamSession } from "../../streaming.js";
@@ -882,11 +886,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
previewStreamingEnabled,
});
let previewToolProgressSuppressed = false;
let previewToolProgressLines: string[] = [];
let previewToolProgressLines: ChannelProgressDraftLine[] = [];
let appendRenderedText = "";
let appendSourceText = "";
let statusUpdateCount = 0;
const progressSeed = `${account.accountId}:${message.channel}`;
const useRichProgressDraft =
streamMode === "status_final" && resolveChannelProgressDraftRender(account.config) === "rich";
const renderProgressDraft = () => {
if (!draftStream || streamMode !== "status_final") {
@@ -896,35 +902,62 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
entry: account.config,
lines: previewToolProgressLines,
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
});
if (!previewText) {
return;
}
draftStream.update(previewText);
draftStream.update(
useRichProgressDraft
? {
text: previewText,
blocks: buildSlackProgressDraftBlocks({
label: resolveChannelProgressDraftLabel({
entry: account.config,
seed: progressSeed,
}),
lines: previewToolProgressLines,
}),
}
: previewText,
);
hasStreamedMessage = true;
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: renderProgressDraft,
});
const pushPreviewToolProgress = async (line?: string, options?: { toolName?: string }) => {
const pushPreviewToolProgress = async (
line?: ChannelProgressDraftLine,
options?: { toolName?: string },
) => {
if (!draftStream) {
return;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
const normalized = line?.text.replace(/\s+/g, " ").trim();
if (!line || !normalized) {
if (streamMode !== "status_final") {
return;
}
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (alreadyStarted && progressDraftGate.hasStarted) {
renderProgressDraft();
}
return;
}
if (streamMode !== "status_final") {
if (!previewToolProgressEnabled || previewToolProgressSuppressed || !normalized) {
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
return;
}
const escaped = escapeSlackMrkdwn(normalized);
const previous = previewToolProgressLines.at(-1);
if (previous === escaped) {
if (previous?.text === normalized) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, escaped].slice(
previewToolProgressLines = [...previewToolProgressLines, line].slice(
-resolveChannelProgressDraftMaxLines(account.config),
);
draftStream.update(
@@ -932,16 +965,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
entry: account.config,
lines: previewToolProgressLines,
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
}),
);
hasStreamedMessage = true;
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
const escaped = escapeSlackMrkdwn(normalized);
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
const previous = previewToolProgressLines.at(-1);
if (previous !== escaped) {
previewToolProgressLines = [...previewToolProgressLines, escaped].slice(
if (previous?.text !== normalized) {
previewToolProgressLines = [...previewToolProgressLines, line].slice(
-resolveChannelProgressDraftMaxLines(account.config),
);
}
@@ -985,16 +1018,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (statusUpdateCount > 1 && statusUpdateCount % 4 !== 0) {
return;
}
const previewText = formatChannelProgressDraftText({
entry: account.config,
lines: previewToolProgressLines,
seed: progressSeed,
});
if (!previewText) {
return;
}
draftStream?.update(previewText);
hasStreamedMessage = true;
renderProgressDraft();
return;
}
@@ -1084,7 +1108,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
await statusReactions.setTool(payload.name);
}
await pushPreviewToolProgress(
formatChannelProgressDraftLine(
buildChannelProgressDraftLine(
{
event: "tool",
name: payload.name,
@@ -1098,7 +1122,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
},
onItemEvent: async (payload) => {
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "item",
itemKind: payload.kind,
title: payload.title,
@@ -1116,7 +1140,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
@@ -1130,7 +1154,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
@@ -1145,7 +1169,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
@@ -1160,7 +1184,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { buildSlackProgressDraftBlocks } from "./progress-blocks.js";
function progressLine(index: number) {
return {
kind: "tool" as const,
icon: "🛠️",
label: `Exec ${index}`,
detail: `run ${index}`,
text: `🛠️ Exec ${index}: run ${index}`,
};
}
describe("buildSlackProgressDraftBlocks", () => {
it("renders structured progress lines as compact Block Kit fields", () => {
expect(
buildSlackProgressDraftBlocks({
label: "Shelling...",
lines: [
{
kind: "tool",
icon: "🛠️",
label: "Exec",
detail: "run tests",
text: "🛠️ Exec: run tests",
toolName: "exec",
},
],
}),
).toEqual([
{
type: "section",
text: { type: "mrkdwn", text: "*Shelling...*" },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "🛠️ *Exec*" },
{ type: "mrkdwn", text: "run tests" },
],
},
]);
});
it("compacts long rich details independently from the text fallback", () => {
const blocks = buildSlackProgressDraftBlocks({
lines: [
{
kind: "tool",
icon: "🛠️",
label: "Exec",
detail: "run tests in /Users/example/Projects/openclaw/packages/very/deep/path/example",
text: "🛠️ Exec: run tests in /Users/example/Projects/openclaw/packages/very/deep/path/example",
},
],
});
expect(blocks?.[0]).toEqual({
type: "section",
fields: [
{ type: "mrkdwn", text: "🛠️ *Exec*" },
{ type: "mrkdwn", text: "run tests in /Users/ex…es/very/deep/path/example" },
],
});
});
it("caps rich progress blocks to Slack's maximum while leaving caller text fallback independent", () => {
const blocksWithLabel = buildSlackProgressDraftBlocks({
label: "Shelling...",
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
});
expect(blocksWithLabel).toHaveLength(50);
expect(blocksWithLabel?.[0]).toMatchObject({
type: "section",
text: { text: "*Shelling...*" },
});
expect(blocksWithLabel?.at(-1)).toMatchObject({
type: "section",
fields: [{ text: "🛠️ *Exec 48*" }, { text: "run 48" }],
});
const blocksWithoutLabel = buildSlackProgressDraftBlocks({
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
});
expect(blocksWithoutLabel).toHaveLength(50);
expect(blocksWithoutLabel?.at(-1)).toMatchObject({
type: "section",
fields: [{ text: "🛠️ *Exec 49*" }, { text: "run 49" }],
});
});
});

View File

@@ -0,0 +1,65 @@
import type { Block, KnownBlock } from "@slack/web-api";
import type { ChannelProgressDraftLine } from "openclaw/plugin-sdk/channel-streaming";
import { SLACK_MAX_BLOCKS } from "./blocks-input.js";
import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js";
import { truncateSlackText } from "./truncate.js";
const SLACK_PROGRESS_FIELD_MAX = 1800;
const SLACK_PROGRESS_DETAIL_MAX_CHARS = 48;
function field(text: string) {
return {
type: "mrkdwn" as const,
text: truncateSlackText(text, SLACK_PROGRESS_FIELD_MAX),
};
}
function lineTitle(line: ChannelProgressDraftLine): string {
return `${line.icon ?? "•"} *${escapeSlackMrkdwn(line.label)}*`;
}
function compactDetail(value: string): string {
const normalized = value.replace(/\s+/g, " ").trim();
const chars = Array.from(normalized);
if (chars.length <= SLACK_PROGRESS_DETAIL_MAX_CHARS) {
return normalized;
}
const keepStart = Math.ceil((SLACK_PROGRESS_DETAIL_MAX_CHARS - 1) * 0.45);
const keepEnd = SLACK_PROGRESS_DETAIL_MAX_CHARS - keepStart - 1;
return `${chars.slice(0, keepStart).join("").trimEnd()}${chars
.slice(-keepEnd)
.join("")
.trimStart()}`;
}
function lineDetail(line: ChannelProgressDraftLine): string {
const parts = [
line.detail,
line.status && !line.detail?.includes(line.status) ? line.status : undefined,
]
.map((part) => part?.trim())
.filter((part): part is string => Boolean(part));
return parts.length ? escapeSlackMrkdwn(compactDetail(parts.join(" · "))) : " ";
}
export function buildSlackProgressDraftBlocks(params: {
label?: string;
lines: readonly ChannelProgressDraftLine[];
}): (Block | KnownBlock)[] | undefined {
const blocks: (Block | KnownBlock)[] = [];
const label = params.label?.trim();
if (label) {
blocks.push({
type: "section",
text: field(`*${escapeSlackMrkdwn(label)}*`),
});
}
const availableLineBlocks = Math.max(0, SLACK_MAX_BLOCKS - blocks.length);
for (const line of params.lines.slice(0, availableLineBlocks)) {
blocks.push({
type: "section",
fields: [field(lineTitle(line)), field(lineDetail(line))],
});
}
return blocks.length ? blocks : undefined;
}