diff --git a/CHANGELOG.md b/CHANGELOG.md index cb4c53bb873..4588e6bd95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index a1fc40d2b4d..495458bbf69 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -662,7 +662,7 @@ Default slash command settings: - 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. diff --git a/docs/concepts/progress-drafts.md b/docs/concepts/progress-drafts.md index f73f1eabc62..4ce3440015b 100644 --- a/docs/concepts/progress-drafts.md +++ b/docs/concepts/progress-drafts.md @@ -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. diff --git a/extensions/discord/src/monitor/message-handler.draft-preview.ts b/extensions/discord/src/monitor/message-handler.draft-preview.ts index 88167e0b894..de8f8215b74 100644 --- a/extensions/discord/src/monitor/message-handler.draft-preview.ts +++ b/extensions/discord/src/monitor/message-handler.draft-preview.ts @@ -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(); -} diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 5fbbbf77ac0..06327368b4c 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -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, diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 316e0f059ea..3d4ebe9b4fb 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -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 () => { diff --git a/extensions/msteams/src/reply-stream-controller.ts b/extensions/msteams/src/reply-stream-controller.ts index bd0113554cb..e0c89d49409 100644 --- a/extensions/msteams/src/reply-stream-controller.ts +++ b/extensions/msteams/src/reply-stream-controller.ts @@ -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 = []; let lastInformativeText = ""; let pendingFinalize: Promise | undefined; let liveState: LiveMessageState = createLiveMessageState({ @@ -125,7 +126,7 @@ export function createTeamsReplyStreamController(params: { }; const pushProgressLine = async ( - line?: string, + line?: string | ChannelProgressDraftLine, options?: { toolName?: string }, ): Promise => { 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 { + async pushProgressLine( + line?: string | ChannelProgressDraftLine, + options?: { toolName?: string }, + ): Promise { 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() ?? ""; +} diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index d4a2ff8e5f2..47bacecb818 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -331,17 +331,32 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({ }, formatChannelProgressDraftText: (params: { entry?: { streaming?: { progress?: { label?: string | false; maxLines?: number } } }; - lines: Array; + 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"); }); diff --git a/extensions/slack/src/progress-blocks.test.ts b/extensions/slack/src/progress-blocks.test.ts index 5aa6875293e..0766770eb23 100644 --- a/extensions/slack/src/progress-blocks.test.ts +++ b/extensions/slack/src/progress-blocks.test.ts @@ -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", diff --git a/extensions/slack/src/progress-blocks.ts b/extensions/slack/src/progress-blocks.ts index 8b1ba33a9c1..23c59a95242 100644 --- a/extensions/slack/src/progress-blocks.ts +++ b/extensions/slack/src/progress-blocks.ts @@ -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; } diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index bf515dc20ad..12caa55fb09 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -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); }); diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index afc652ece2c..687b0c21420 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -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 = - labelPlacement === "line" && label ? [{ draftLabel: label }, ...params.lines] : params.lines; + const rawLines: Array = 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"); }