mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
feat: add rich Slack progress drafts
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
91
extensions/slack/src/progress-blocks.test.ts
Normal file
91
extensions/slack/src/progress-blocks.test.ts
Normal 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
65
extensions/slack/src/progress-blocks.ts
Normal file
65
extensions/slack/src/progress-blocks.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user