fix: make channel progress labels rolling

This commit is contained in:
Peter Steinberger
2026-05-08 01:30:07 +01:00
parent ef29c85a48
commit accf774591
12 changed files with 106 additions and 94 deletions

View File

@@ -7,7 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev.
- Discord/streaming: make progress draft labels scroll away with other progress lines, render tool rows as compact emoji/details, and skip empty apply-patch starts until a patch summary exists. (#79146)
- Channels/streaming: make progress draft labels scroll away with other progress lines, render structured tool rows as compact emoji/details, and skip empty Discord apply-patch starts until a patch summary exists. (#79146)
- Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus.
- Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
- Discord/voice: include a bounded one-line STT transcript preview in verbose voice logs so live voice debugging shows what speakers said before the agent reply.

View File

@@ -662,7 +662,7 @@ Default slash command settings:
</Accordion>
<Accordion title="Live stream preview">
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` | `partial` | `block` | `progress` (default). `progress` keeps one editable status draft and updates it with tool progress until final delivery; the starter label is a rolling line, so it scrolls away like the rest once enough work appears. `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key.
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` | `partial` | `block` | `progress` (default). `progress` keeps one editable status draft and updates it with tool progress until final delivery; the shared starter label is a rolling line, so it scrolls away like the rest once enough work appears. `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key.
Set `channels.discord.streaming.mode` to `off` to disable Discord preview edits. If Discord block streaming is explicitly enabled, OpenClaw skips the preview stream to avoid double-streaming.

View File

@@ -57,14 +57,14 @@ A progress draft has two parts:
| Progress lines | Compact run updates using the same tool icons and detail formatter as verbose output. |
The label appears after the agent starts meaningful work and either remains busy
for five seconds or emits a second work event. Channels can render it as a fixed
header or as the first rolling line; Discord uses a rolling line so the starter
status scrolls away once enough concrete work appears. Plain text-only replies do
not show a progress draft. Progress lines are added only when the agent emits
useful work updates, for example `🛠️ run tests`, `🔎 for "discord edit message"`,
or `✍️ to /tmp/file`. By default they use the same compact explain mode as
`/verbose`; set `agents.defaults.toolProgressDetail: "raw"` when debugging and
you also want raw commands/details appended.
for five seconds or emits a second work event. It is part of the rolling progress
line list, so the starter status scrolls away once enough concrete work appears.
Plain text-only replies do not show a progress draft. Progress lines are added
only when the agent emits useful work updates, for example `🛠️ run tests`,
`🔎 for "discord edit message"`, or `✍️ to /tmp/file`. By default they use the
same compact explain mode as `/verbose`; set
`agents.defaults.toolProgressDetail: "raw"` when debugging and you also want raw
commands/details appended.
The final answer replaces the draft when possible; otherwise
OpenClaw sends the final answer normally and cleans up or stops updating the
draft according to the channel's transport.

View File

@@ -95,8 +95,6 @@ export function createDiscordDraftPreviewController(params: {
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
labelPlacement: "line",
formatStructuredLine: formatDiscordProgressDraftLine,
});
if (!previewText || previewText === lastPartialText) {
return;
@@ -196,8 +194,6 @@ export function createDiscordDraftPreviewController(params: {
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
labelPlacement: "line",
formatStructuredLine: formatDiscordProgressDraftLine,
});
lastPartialText = previewText;
draftText = previewText;
@@ -429,22 +425,3 @@ function shouldStartDiscordProgressDraftNow(
): boolean {
return typeof line === "object" && line?.kind === "patch" && Boolean(line.detail);
}
function formatDiscordProgressDraftLine(line: ChannelProgressDraftLine): string {
const icon = line.icon?.trim();
const prefix = icon ? `${icon} ` : "";
const detail = line.detail?.trim();
if (detail) {
return `${prefix}${detail}`;
}
const status = line.status?.trim();
if (status) {
return `${prefix}${status}`;
}
const text = line.text.trim();
const label = line.label.trim();
if (!icon && text && text !== label) {
return text;
}
return `${prefix}${label}`.trim();
}

View File

@@ -1,6 +1,6 @@
import {
formatChannelProgressDraftLine,
formatChannelProgressDraftLineForEntry,
buildChannelProgressDraftLine,
buildChannelProgressDraftLineForEntry,
resolveChannelPreviewStreamMode,
resolveChannelStreamingBlockEnabled,
} from "openclaw/plugin-sdk/channel-streaming";
@@ -385,7 +385,7 @@ export function createMSTeamsReplyDispatcher(params: {
detailMode?: "explain" | "raw";
}) => {
await streamController.pushProgressLine(
formatChannelProgressDraftLineForEntry(
buildChannelProgressDraftLineForEntry(
msteamsCfg,
{
event: "tool",
@@ -409,7 +409,7 @@ export function createMSTeamsReplyDispatcher(params: {
status?: string;
}) => {
await streamController.pushProgressLine(
formatChannelProgressDraftLineForEntry(msteamsCfg, {
buildChannelProgressDraftLineForEntry(msteamsCfg, {
event: "item",
itemKind: payload.kind,
title: payload.title,
@@ -432,7 +432,7 @@ export function createMSTeamsReplyDispatcher(params: {
return;
}
await streamController.pushProgressLine(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
@@ -452,7 +452,7 @@ export function createMSTeamsReplyDispatcher(params: {
return;
}
await streamController.pushProgressLine(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
@@ -473,7 +473,7 @@ export function createMSTeamsReplyDispatcher(params: {
return;
}
await streamController.pushProgressLine(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
@@ -496,7 +496,7 @@ export function createMSTeamsReplyDispatcher(params: {
return;
}
await streamController.pushProgressLine(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,

View File

@@ -320,9 +320,7 @@ describe("createTeamsReplyStreamController", () => {
expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true);
expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true);
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith(
"Working\n- tool: exec",
);
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith("- tool: exec");
});
it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => {

View File

@@ -8,6 +8,7 @@ import {
} from "openclaw/plugin-sdk/channel-message";
import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelPreviewStreamMode,
@@ -70,7 +71,7 @@ export function createTeamsReplyStreamController(params: {
let streamReceivedTokens = false;
let informativeUpdateSent = false;
let progressLines: string[] = [];
let progressLines: Array<string | ChannelProgressDraftLine> = [];
let lastInformativeText = "";
let pendingFinalize: Promise<void> | undefined;
let liveState: LiveMessageState<ReplyPayload> = createLiveMessageState({
@@ -125,7 +126,7 @@ export function createTeamsReplyStreamController(params: {
};
const pushProgressLine = async (
line?: string,
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },
): Promise<void> => {
if (!stream || streamMode !== "progress") {
@@ -135,11 +136,13 @@ export function createTeamsReplyStreamController(params: {
return;
}
if (shouldStreamPreviewToolProgress) {
const normalized = line?.replace(/\s+/g, " ").trim();
const normalized = normalizeProgressLineIdentity(line);
if (normalized) {
const previous = progressLines.at(-1);
const previous = normalizeProgressLineIdentity(progressLines.at(-1));
if (previous !== normalized) {
progressLines = [...progressLines, normalized].slice(
const progressLine: string | ChannelProgressDraftLine =
typeof line === "object" && line !== undefined ? line : normalized;
progressLines = [...progressLines, progressLine].slice(
-resolveChannelProgressDraftMaxLines(params.msteamsConfig),
);
}
@@ -230,7 +233,10 @@ export function createTeamsReplyStreamController(params: {
stream.update(payload.text);
},
async pushProgressLine(line?: string, options?: { toolName?: string }): Promise<void> {
async pushProgressLine(
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },
): Promise<void> {
await pushProgressLine(line, options);
},
@@ -327,3 +333,10 @@ export function createTeamsReplyStreamController(params: {
},
};
}
function normalizeProgressLineIdentity(
line: string | ChannelProgressDraftLine | undefined,
): string {
const text = typeof line === "string" ? line : line?.text;
return text?.replace(/\s+/g, " ").trim() ?? "";
}

View File

@@ -331,17 +331,32 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({
},
formatChannelProgressDraftText: (params: {
entry?: { streaming?: { progress?: { label?: string | false; maxLines?: number } } };
lines: Array<string | { text: string }>;
lines: Array<
string | { text: string; icon?: string; detail?: string; status?: string; label: string }
>;
formatLine?: (line: string) => string;
}) => {
const label = params.entry?.streaming?.progress?.label;
const maxLines = params.entry?.streaming?.progress?.maxLines ?? 8;
const formatLine = params.formatLine ?? ((line: string) => line);
return [
const lines = [
label === false ? undefined : (label ?? "Thinking"),
...params.lines.map((line) => `${formatLine(typeof line === "string" ? line : line.text)}`),
...params.lines.map((line) => {
const text =
typeof line === "string"
? line
: line.detail
? `${line.icon ?? ""} ${line.detail}`.trim()
: line.status
? `${line.icon ?? ""} ${line.status}`.trim()
: line.text;
const formatted = formatLine(text);
return /^\p{Extended_Pictographic}/u.test(text) ? formatted : ` ${formatted}`;
}),
]
.filter((line): line is string => Boolean(line))
.join("\n");
.slice(-maxLines);
return lines.join("\n");
},
formatChannelProgressDraftLine: (params: {
progressText?: string;
@@ -797,7 +812,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expect(draftStream.update).toHaveBeenLastCalledWith(
[
"Shelling",
"• step 1",
"• step 2",
"• step 3",
@@ -859,7 +873,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
}),
);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• done");
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠 Exec\n• done");
expect(draftStream.update.mock.calls.flat().join("\n")).not.toContain("pnpm test");
});

View File

@@ -72,11 +72,7 @@ describe("buildSlackProgressDraftBlocks", () => {
expect(blocksWithLabel).toHaveLength(50);
expect(blocksWithLabel?.[0]).toMatchObject({
type: "section",
text: { text: "*Shelling...*" },
});
expect(blocksWithLabel?.[1]).toMatchObject({
type: "section",
fields: [{ text: "🛠️ *Exec 11*" }, { text: "run 11" }],
fields: [{ text: "🛠️ *Exec 10*" }, { text: "run 10" }],
});
expect(blocksWithLabel?.at(-1)).toMatchObject({
type: "section",

View File

@@ -46,20 +46,21 @@ 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(-availableLineBlocks)) {
blocks.push({
const renderedBlocks: (Block | KnownBlock)[] = [
...(label
? [
{
type: "section" as const,
text: field(`*${escapeSlackMrkdwn(label)}*`),
},
]
: []),
...params.lines.map((line) => ({
type: "section",
fields: [field(lineTitle(line)), field(lineDetail(line))],
});
}
})),
].slice(-SLACK_MAX_BLOCKS);
const blocks: (Block | KnownBlock)[] = renderedBlocks;
return blocks.length ? blocks : undefined;
}

View File

@@ -210,28 +210,27 @@ describe("channel-streaming", () => {
lines: [" tool: read ", "patch applied", "tests done"],
formatLine: (line) => `\`${line}\``,
}),
).toBe("Shelling\n• `patch applied`\n• `tests done`");
).toBe("• `patch applied`\n• `tests done`");
expect(
formatChannelProgressDraftText({
entry,
lines: ["🛠️ Exec", "plain update"],
}),
).toBe("Shelling\n🛠️ Exec\n• plain update");
).toBe("🛠️ Exec\n• plain update");
});
it("can render progress labels as rolling lines", () => {
it("renders progress labels as rolling lines", () => {
const entry = { streaming: { progress: { label: "Shelling", maxLines: 3 } } };
expect(
formatChannelProgressDraftText({
entry,
labelPlacement: "line",
lines: ["🛠️ Exec", "📖 Read", "🩹 Patch"],
}),
).toBe("🛠️ Exec\n📖 Read\n🩹 Patch");
});
it("lets channels render structured progress lines", () => {
it("renders structured progress lines with compact details", () => {
const line = buildChannelProgressDraftLine({
event: "patch",
summary: "1 modified",
@@ -242,8 +241,6 @@ describe("channel-streaming", () => {
formatChannelProgressDraftText({
entry: { streaming: { progress: { label: false } } },
lines: line ? [line] : [],
formatStructuredLine: (entry) =>
entry.detail ? `${entry.icon ?? ""} ${entry.detail}`.trim() : entry.text,
}),
).toBe("🩹 1 modified; extensions/discord/src/monitor/message-handler.draft-prev…");
});
@@ -259,7 +256,7 @@ describe("channel-streaming", () => {
});
it("keeps compacted raw progress lines from leaking unmatched markdown backticks", () => {
const line = formatChannelProgressDraftLine(
const line = buildChannelProgressDraftLine(
{
event: "tool",
name: "exec",
@@ -273,10 +270,12 @@ describe("channel-streaming", () => {
const text = formatChannelProgressDraftText({
entry: { streaming: { progress: { label: "Shelling" } } },
lines: [line ?? ""],
lines: line ? [line] : [],
});
expect(text).toBe("Shelling\n🛠 Exec: run node script…that/keeps/going/and/going/index…");
expect(text).toBe(
"Shelling\n🛠 run node script scripts/check-something-with-a-very-long-path, node…",
);
expect(text.match(/`/g) ?? []).toHaveLength(0);
});

View File

@@ -749,7 +749,25 @@ function compactChannelProgressDraftLine(line: string, maxChars: number): string
}
function getProgressDraftLineText(line: string | ChannelProgressDraftLine): string {
return typeof line === "string" ? line : line.text;
if (typeof line === "string") {
return line;
}
const icon = line.icon?.trim();
const prefix = icon ? `${icon} ` : "";
const detail = line.detail?.trim();
if (detail) {
return `${prefix}${detail}`;
}
const status = line.status?.trim();
if (status) {
return `${prefix}${status}`;
}
const text = line.text.trim();
const label = line.label.trim();
if (!icon && text && text !== label) {
return text;
}
return `${prefix}${label}`.trim();
}
export function formatChannelProgressDraftText(params: {
@@ -758,8 +776,6 @@ export function formatChannelProgressDraftText(params: {
seed?: string;
random?: () => number;
formatLine?: (line: string) => string;
formatStructuredLine?: (line: ChannelProgressDraftLine) => string;
labelPlacement?: "header" | "line";
bullet?: string;
}): string {
const label = resolveChannelProgressDraftLabel({
@@ -770,9 +786,9 @@ export function formatChannelProgressDraftText(params: {
const maxLines = resolveChannelProgressDraftMaxLines(params.entry);
const formatLine = params.formatLine ?? ((line: string) => line);
const bullet = params.bullet ?? "•";
const labelPlacement = params.labelPlacement ?? "header";
const rawLines: Array<string | ChannelProgressDraftLine | { draftLabel: string }> =
labelPlacement === "line" && label ? [{ draftLabel: label }, ...params.lines] : params.lines;
const rawLines: Array<string | ChannelProgressDraftLine | { draftLabel: string }> = label
? [{ draftLabel: label }, ...params.lines]
: params.lines;
const lines = rawLines
.map((line) => {
const isLabelLine = typeof line === "object" && line !== null && "draftLabel" in line;
@@ -780,17 +796,15 @@ export function formatChannelProgressDraftText(params: {
? line.draftLabel
: typeof line === "string"
? line
: (params.formatStructuredLine?.(line) ?? getProgressDraftLineText(line));
: getProgressDraftLineText(line);
const text = compactChannelProgressDraftLine(rawText, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS);
return text ? { text, isLabelLine } : undefined;
})
.filter((line): line is { text: string; isLabelLine: boolean } => Boolean(line))
.slice(-maxLines)
.map(({ text, isLabelLine }) => {
const formatted = formatLine(text);
const formatted = isLabelLine ? text : formatLine(text);
return !isLabelLine && shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted;
});
return [labelPlacement === "header" ? label : undefined, ...lines]
.filter((line): line is string => Boolean(line))
.join("\n");
return lines.filter((line): line is string => Boolean(line)).join("\n");
}