From b5d408cd690f0ddb38cefcd1510104a7e3c78380 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 05:38:51 +0100 Subject: [PATCH] feat: add rich Slack progress drafts --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 6 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/concepts/progress-drafts.md | 21 ++++ extensions/slack/src/config-ui-hints.ts | 4 + extensions/slack/src/draft-stream.test.ts | 22 ++++ extensions/slack/src/draft-stream.ts | 37 ++++-- .../src/monitor/message-handler/dispatch.ts | 82 ++++++++----- extensions/slack/src/progress-blocks.test.ts | 91 +++++++++++++++ extensions/slack/src/progress-blocks.ts | 65 +++++++++++ ...ndled-channel-config-metadata.generated.ts | 32 +++++ src/config/types.base.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + src/plugin-sdk/channel-streaming.test.ts | 19 ++- src/plugin-sdk/channel-streaming.ts | 109 +++++++++++++++--- 15 files changed, 440 insertions(+), 56 deletions(-) create mode 100644 extensions/slack/src/progress-blocks.test.ts create mode 100644 extensions/slack/src/progress-blocks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c99c2e6c42..b7aaa7db1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Changes - Channels/streaming: add unified `streaming.mode: "progress"` drafts with auto single-word status labels and shared progress configuration across Discord, Telegram, Matrix, Slack, and Microsoft Teams. +- Slack/streaming: add `streaming.progress.render: "rich"` for Block Kit progress drafts backed by structured progress line data. - Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines. - Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output. - Agents/commands: add `/steer ` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 7f5a359b522..c1a81369267 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -ac95b4ab62408454636ce559e6d023df3c29b8b936b3aa4dde37779d29a5a099 config-baseline.json +953aece02c70b8df690b51e865a4aea838b53bbe9d43ef9495f80f719a831e38 config-baseline.json 31ec333df9f8b92c7656ac7107cecd5860dd02e08f7e18c7c674dc47a8811baa config-baseline.core.json -655d1309b70505e73198df20c5088784290b33098efd42027d3c09beeb3704a7 config-baseline.channel.json -9458dc89aa13dd07d83f69d943535099a96e8278eb7ac8ae5cf2f713631592f7 config-baseline.plugin.json +e10ba2f29f25fc665b96c714075af954eed686c56ca12783cf1f49498f86ac98 config-baseline.channel.json +606641569764473005f8343f4550500dcbe99cf54e1dc21960018cf455912196 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 0cb30730876..2fcf47d7d31 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -701356478634a8f3e71f941ed21a00e0456d947d287edcafb56231013b27a057 plugin-sdk-api-baseline.json -ed17426dd5e9db4b83db77162e7490eee3c0439170c1a9d1e84c01d7027d580c plugin-sdk-api-baseline.jsonl +2943ada651fd9a07c9e715a90ad4a76f725a1b60fa142dcfd504ba6d6c202ed4 plugin-sdk-api-baseline.json +ff31408a26bcad4c54dc0c897d0103ca3d7dc91b3394a3ab65e7dade0c3f6ff5 plugin-sdk-api-baseline.jsonl diff --git a/docs/concepts/progress-drafts.md b/docs/concepts/progress-drafts.md index 1df12e76bc4..8dd453100c2 100644 --- a/docs/concepts/progress-drafts.md +++ b/docs/concepts/progress-drafts.md @@ -223,6 +223,27 @@ OpenClaw truncates long progress lines by default so repeated draft edits do not wrap differently on every update. The prefix stays readable, and long details such as paths or raw commands are shortened with an ellipsis. +Slack can render progress lines as structured Block Kit fields instead of a +single text body: + +```json5 +{ + channels: { + slack: { + streaming: { + mode: "progress", + progress: { + render: "rich", + }, + }, + }, + }, +} +``` + +Rich rendering keeps the same plain-text fallback so channels and clients that +do not support the richer shape can still show the compact progress text. + Keep the single progress draft but hide tool and task lines: ```json5 diff --git a/extensions/slack/src/config-ui-hints.ts b/extensions/slack/src/config-ui-hints.ts index 15406d3cb40..bc762892084 100644 --- a/extensions/slack/src/config-ui-hints.ts +++ b/extensions/slack/src/config-ui-hints.ts @@ -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.", diff --git a/extensions/slack/src/draft-stream.test.ts b/extensions/slack/src/draft-stream.test.ts index 55f8a237e0f..8186f15a7f5 100644 --- a/extensions/slack/src/draft-stream.test.ts +++ b/extensions/slack/src/draft-stream.test.ts @@ -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(); diff --git a/extensions/slack/src/draft-stream.ts b/extensions/slack/src/draft-stream.ts index 694f85635cc..8e824138202 100644 --- a/extensions/slack/src/draft-stream.ts +++ b/extensions/slack/src/draft-stream.ts @@ -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; clear: () => Promise; discardPending: () => Promise; @@ -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, diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 4f9700af265..f1ae73404ae 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -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, diff --git a/extensions/slack/src/progress-blocks.test.ts b/extensions/slack/src/progress-blocks.test.ts new file mode 100644 index 00000000000..854401a8c87 --- /dev/null +++ b/extensions/slack/src/progress-blocks.test.ts @@ -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" }], + }); + }); +}); diff --git a/extensions/slack/src/progress-blocks.ts b/extensions/slack/src/progress-blocks.ts new file mode 100644 index 00000000000..b1339a6848e --- /dev/null +++ b/extensions/slack/src/progress-blocks.ts @@ -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; +} diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index eef56414c39..0f23efdb754 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -958,6 +958,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + render: { + type: "string", + enum: ["text", "rich"], + }, toolProgress: { type: "boolean", }, @@ -2392,6 +2396,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + render: { + type: "string", + enum: ["text", "rich"], + }, toolProgress: { type: "boolean", }, @@ -9139,6 +9147,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + render: { + type: "string", + enum: ["text", "rich"], + }, toolProgress: { type: "boolean", }, @@ -12365,6 +12377,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + render: { + type: "string", + enum: ["text", "rich"], + }, toolProgress: { type: "boolean", }, @@ -13327,6 +13343,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + render: { + type: "string", + enum: ["text", "rich"], + }, toolProgress: { type: "boolean", }, @@ -13898,6 +13918,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ 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.", @@ -14694,6 +14718,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + render: { + type: "string", + enum: ["text", "rich"], + }, toolProgress: { type: "boolean", }, @@ -15794,6 +15822,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 9007199254740991, }, + render: { + type: "string", + enum: ["text", "rich"], + }, toolProgress: { type: "boolean", }, diff --git a/src/config/types.base.ts b/src/config/types.base.ts index bb38356f3d3..11ca024d6b9 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -41,6 +41,8 @@ export type ChannelStreamingProgressConfig = { labels?: string[]; /** Maximum number of progress lines to keep below the label. Default: 8. */ maxLines?: number; + /** Progress draft renderer. "text" is the portable fallback; "rich" lets supported channels use structured UI. */ + render?: "text" | "rich"; /** Include compact tool/task progress in the draft. Default: true. */ toolProgress?: boolean; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index a03ff2f1420..e24e6151023 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -91,6 +91,7 @@ const ChannelStreamingProgressSchema = z label: z.union([z.string(), z.literal(false)]).optional(), labels: z.array(z.string()).optional(), maxLines: z.number().int().positive().optional(), + render: z.enum(["text", "rich"]).optional(), toolProgress: z.boolean().optional(), }) .strict(); diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index 8b5522783ba..160b87135ce 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + buildChannelProgressDraftLine, createChannelProgressDraftGate, DEFAULT_PROGRESS_DRAFT_LABELS, formatChannelProgressDraftLine, @@ -9,6 +10,7 @@ import { resolveChannelPreviewStreamMode, resolveChannelProgressDraftLabel, resolveChannelProgressDraftMaxLines, + resolveChannelProgressDraftRender, resolveChannelStreamingBlockCoalesce, resolveChannelStreamingBlockEnabled, resolveChannelStreamingChunkMode, @@ -195,8 +197,9 @@ describe("channel-streaming", () => { }); it("formats bounded progress draft text", () => { - const entry = { streaming: { progress: { label: "Shelling", maxLines: 2 } } }; + const entry = { streaming: { progress: { label: "Shelling", maxLines: 2, render: "rich" } } }; expect(resolveChannelProgressDraftMaxLines(entry)).toBe(2); + expect(resolveChannelProgressDraftRender(entry)).toBe("rich"); expect( formatChannelProgressDraftText({ entry, @@ -245,6 +248,20 @@ describe("channel-streaming", () => { }); it("formats progress draft lines with shared tool display labels", () => { + expect( + buildChannelProgressDraftLine({ + event: "tool", + name: "write", + args: { path: "/tmp/demo/index.html" }, + }), + ).toMatchObject({ + kind: "tool", + icon: "✍️", + label: "Write", + detail: "to /tmp/demo/index.html", + text: "✍️ Write: to /tmp/demo/index.html", + toolName: "write", + }); expect( formatChannelProgressDraftLine({ event: "tool", diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index 14ed7d43b50..eb76de7d88f 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -132,6 +132,8 @@ type ChannelProgressLineOptions = { detailMode?: "explain" | "raw"; }; +export type ChannelProgressDraftRenderMode = "text" | "rich"; + const EMOJI_PREFIX_RE = /^\p{Extended_Pictographic}/u; export type ChannelProgressDraftLineInput = @@ -186,6 +188,18 @@ export type ChannelProgressDraftLineInput = summary?: string; }; +export type ChannelProgressDraftLineKind = ChannelProgressDraftLineInput["event"]; + +export type ChannelProgressDraftLine = { + kind: ChannelProgressDraftLineKind; + text: string; + label: string; + icon?: string; + detail?: string; + status?: string; + toolName?: string; +}; + function compactStrings(values: readonly (string | undefined | null)[]): string[] { return values.map((value) => value?.replace(/\s+/g, " ").trim()).filter(Boolean) as string[]; } @@ -201,16 +215,32 @@ function inferToolMeta( return formatToolDetail(resolveToolDisplay({ name, args, detailMode })); } -function formatNamedProgressLine( +function buildNamedProgressLine( + kind: ChannelProgressDraftLineKind, name: string | undefined, metas: readonly (string | undefined | null)[] | undefined, options?: ChannelProgressLineOptions, -): string | undefined { + fields?: { + status?: string; + }, +): ChannelProgressDraftLine | undefined { const normalizedName = name?.trim() || "tool_call"; const compactMetas = compactStrings(metas ?? []); - return formatToolAggregate(normalizedName, compactMetas.length ? compactMetas : undefined, { + const text = formatToolAggregate(normalizedName, compactMetas.length ? compactMetas : undefined, { markdown: options?.markdown, }); + const display = resolveToolDisplay({ name: normalizedName }); + const prefix = `${display.emoji} ${display.label}`; + const detail = text.startsWith(`${prefix}: `) ? text.slice(prefix.length + 2).trim() : undefined; + return { + kind, + text, + label: display.label, + icon: display.emoji, + ...(detail ? { detail } : {}), + ...(fields?.status ? { status: fields.status } : {}), + toolName: display.name, + }; } function itemKindToToolName(kind: string | undefined): string | undefined { @@ -237,13 +267,14 @@ function shouldPrefixProgressLine(line: string): boolean { return !EMOJI_PREFIX_RE.test(line); } -export function formatChannelProgressDraftLine( +export function buildChannelProgressDraftLine( input: ChannelProgressDraftLineInput, options?: ChannelProgressLineOptions, -): string | undefined { +): ChannelProgressDraftLine | undefined { switch (input.event) { case "tool": { - return formatNamedProgressLine( + return buildNamedProgressLine( + input.event, input.name, [ inferToolMeta(input.name, input.args, options?.detailMode), @@ -256,15 +287,26 @@ export function formatChannelProgressDraftLine( const name = input.name ?? itemKindToToolName(input.itemKind); const meta = input.meta ?? input.progressText ?? input.summary; if (name) { - return formatNamedProgressLine(name, [meta], options); + return buildNamedProgressLine(input.event, name, [meta], options, { + status: input.status, + }); } - return compactStrings([meta, input.title]).at(0); + const text = compactStrings([meta, input.title]).at(0); + return text + ? { + kind: input.event, + text, + label: input.title?.trim() || input.itemKind?.trim() || "Update", + ...(input.status ? { status: input.status } : {}), + } + : undefined; } case "plan": { if (input.phase !== undefined && input.phase !== "update") { return undefined; } - return formatNamedProgressLine( + return buildNamedProgressLine( + input.event, "update_plan", [input.explanation, input.steps?.[0], input.title ?? "planning"], options, @@ -274,10 +316,12 @@ export function formatChannelProgressDraftLine( if (input.phase !== undefined && input.phase !== "requested") { return undefined; } - return formatNamedProgressLine( + return buildNamedProgressLine( + input.event, "approval", [input.command, input.message, input.reason, input.title ?? "approval requested"], options, + { status: "requested" }, ); } case "command-output": { @@ -290,18 +334,38 @@ export function formatChannelProgressDraftLine( : input.exitCode != null ? `exit ${input.exitCode}` : input.status; - return formatNamedProgressLine(input.name ?? "exec", [status, input.title], options); + return buildNamedProgressLine( + input.event, + input.name ?? "exec", + [status, input.title], + options, + { + status, + }, + ); } case "patch": { if (input.phase !== undefined && input.phase !== "end") { return undefined; } - return formatNamedProgressLine(input.name ?? "apply_patch", patchMetas(input), options); + return buildNamedProgressLine( + input.event, + input.name ?? "apply_patch", + patchMetas(input), + options, + ); } } return undefined; } +export function formatChannelProgressDraftLine( + input: ChannelProgressDraftLineInput, + options?: ChannelProgressLineOptions, +): string | undefined { + return buildChannelProgressDraftLine(input, options)?.text; +} + export function createChannelProgressDraftGate(params: { onStart: () => void | Promise; initialDelayMs?: number; @@ -541,6 +605,14 @@ export function resolveChannelProgressDraftMaxLines( return configured && configured > 0 ? configured : defaultValue; } +export function resolveChannelProgressDraftRender( + entry: StreamingCompatEntry | null | undefined, + defaultValue: ChannelProgressDraftRenderMode = "text", +): ChannelProgressDraftRenderMode { + const configured = resolveChannelProgressDraftConfig(entry).render; + return configured === "rich" || configured === "text" ? configured : defaultValue; +} + function sliceCodePoints(value: string, start: number, end?: number): string { return Array.from(value).slice(start, end).join(""); } @@ -596,9 +668,13 @@ function compactChannelProgressDraftLine(line: string, maxChars: number): string ); } +function getProgressDraftLineText(line: string | ChannelProgressDraftLine): string { + return typeof line === "string" ? line : line.text; +} + export function formatChannelProgressDraftText(params: { entry?: StreamingCompatEntry | null; - lines: string[]; + lines: Array; seed?: string; random?: () => number; formatLine?: (line: string) => string; @@ -613,7 +689,12 @@ export function formatChannelProgressDraftText(params: { const formatLine = params.formatLine ?? ((line: string) => line); const bullet = params.bullet ?? "•"; const lines = params.lines - .map((line) => compactChannelProgressDraftLine(line, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS)) + .map((line) => + compactChannelProgressDraftLine( + getProgressDraftLineText(line), + DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS, + ), + ) .filter((line) => line.length > 0) .slice(-maxLines) .map((line) =>