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

@@ -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 <message>` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934)

View File

@@ -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

View File

@@ -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

View File

@@ -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

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;
}

View File

@@ -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",
},

View File

@@ -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;
};

View File

@@ -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();

View File

@@ -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",

View File

@@ -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<void>;
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<string | ChannelProgressDraftLine>;
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) =>