mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 21:20:43 +00:00
fix: make channel progress labels rolling
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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() ?? "";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user