diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b962492290..7f4ce7451a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Docs: https://docs.openclaw.ai - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. - Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev. - 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. -- Channels/streaming: render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord 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/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146) - Workspace/oc-path: add the `oc://` addressing substrate (`src/oc-path/`) — a universal, kind-dispatched path scheme for addressing leaves and nodes inside markdown, jsonc, jsonl, and yaml workspace files, with `parseOcPath`/`formatOcPath`, per-kind `parseXxx`/`emitXxx`, universal `resolveOcPath`/`setOcPath`/`findOcPaths` verbs, the `__OPENCLAW_REDACTED__` sentinel emit guard, and the new `openclaw path resolve|find|set|validate|emit` CLI for shell-level inspection and surgical edits. Implements #78051. (#78678) Thanks @giodl73-repo. - Runtime/performance: avoid full-array sorting while auto-selecting providers, resolving supported thinking levels, picking node last-seen timestamps, and extracting Codex usage-limit messages. Thanks @shakkernerd. - Plugins/doctor: avoid full-array sorting while selecting ClawHub search/archive results and bounded dreaming doctor entries. Thanks @shakkernerd. @@ -185,7 +185,6 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. -- Channels/streaming: keep progress draft labels visible above the last `streaming.progress.maxLines` progress rows instead of counting the label against the rolling line limit. Thanks @shakkernerd. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 41c9fd2fe56..aec9b12eaba 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 shared starter label stays visible while `streaming.progress.maxLines` limits the rolling progress lines below it. `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 0144879d273..ccfbf271fc9 100644 --- a/docs/concepts/progress-drafts.md +++ b/docs/concepts/progress-drafts.md @@ -57,10 +57,9 @@ 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. It stays visible while the agent -is still working; `streaming.progress.maxLines` limits only the rolling progress -lines below the label. In other words, progress drafts render as `label + last N -progress lines`. Plain text-only replies do not show a progress draft. Progress lines are added +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 `🛠️ Bash: run tests`, `🔎 Web Search: for "discord edit message"`, or `✍️ Write: to /tmp/file`. By default they use the same compact explain mode as `/verbose`; set diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 2b39901a1b6..3f738cc3229 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1656,7 +1656,7 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• done"); }); - it("keeps Discord progress labels visible above rolling lines", async () => { + it("keeps Discord progress labels as rolling lines", async () => { const draftStream = createMockDraftStreamForTest(); dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { @@ -1680,7 +1680,7 @@ describe("processDiscordMessage draft streaming", () => { await runProcessDiscordMessage(ctx); - expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n🧩 First\n🧩 Second\n🧩 Third"); + expect(draftStream.update).toHaveBeenCalledWith("🧩 First\n🧩 Second\n🧩 Third"); }); it("skips empty apply_patch starts and renders the patch summary", async () => { 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/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index b06fa2b554d..689ee830b07 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 @@ -354,8 +354,9 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({ const label = params.entry?.streaming?.progress?.label; const maxLines = params.entry?.streaming?.progress?.maxLines ?? 8; const formatLine = params.formatLine ?? ((line: string) => line); - const progressLines = params.lines - .map((line) => { + const lines = [ + label === false ? undefined : (label ?? "Thinking"), + ...params.lines.map((line) => { const text = typeof line === "string" ? line @@ -366,12 +367,10 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({ : line.text; const formatted = formatLine(text); return /^\p{Extended_Pictographic}/u.test(text) ? formatted : `• ${formatted}`; - }) + }), + ] .filter((line): line is string => Boolean(line)) .slice(-maxLines); - const lines = [label === false ? undefined : (label ?? "Thinking"), ...progressLines].filter( - (line): line is string => Boolean(line), - ); return lines.join("\n"); }, formatChannelProgressDraftLine: (params: { @@ -828,7 +827,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(draftStream.update).toHaveBeenLastCalledWith( [ - "Shelling", "• step 1", "• step 2", "• step 3", diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index 6df1a737ab9..dfb86348f31 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -211,28 +211,16 @@ 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("keeps progress labels outside the rolling line limit", () => { - const entry = { streaming: { progress: { label: "Working", maxLines: 1 } } }; - - expect( - formatChannelProgressDraftText({ - entry, - lines: ["tool: search", "tool: exec"], - bullet: "-", - }), - ).toBe("Working\n- tool: exec"); - }); - - it("keeps progress labels visible with bounded rolling lines", () => { + it("renders progress labels as rolling lines", () => { const entry = { streaming: { progress: { label: "Shelling", maxLines: 3 } } }; expect( @@ -240,7 +228,7 @@ describe("channel-streaming", () => { entry, lines: ["🛠️ Exec", "📖 Read", "🩹 Patch"], }), - ).toBe("Shelling\n🛠️ Exec\n📖 Read\n🩹 Patch"); + ).toBe("🛠️ Exec\n📖 Read\n🩹 Patch"); }); it("renders structured progress lines with compact details", () => { diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index 28bfa8cdfc6..e8d01ae3394 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -792,21 +792,25 @@ export function formatChannelProgressDraftText(params: { const maxLines = resolveChannelProgressDraftMaxLines(params.entry); const formatLine = params.formatLine ?? ((line: string) => line); const bullet = params.bullet ?? "•"; - const labelLine = label - ? compactChannelProgressDraftLine(label, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS) - : ""; - const progressLines = params.lines + const rawLines: Array = label + ? [{ draftLabel: label }, ...params.lines] + : params.lines; + const lines = rawLines .map((line) => { - const rawText = typeof line === "string" ? line : getProgressDraftLineText(line); + const isLabelLine = typeof line === "object" && line !== null && "draftLabel" in line; + const rawText = isLabelLine + ? line.draftLabel + : typeof line === "string" + ? line + : getProgressDraftLineText(line); const text = compactChannelProgressDraftLine(rawText, DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS); - return text || undefined; + return text ? { text, isLabelLine } : undefined; }) - .filter((line): line is string => Boolean(line)) + .filter((line): line is { text: string; isLabelLine: boolean } => Boolean(line)) .slice(-maxLines) - .map((text) => { - const formatted = formatLine(text); - return shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; + .map(({ text, isLabelLine }) => { + const formatted = isLabelLine ? text : formatLine(text); + return !isLabelLine && shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted; }); - const lines = labelLine ? [labelLine, ...progressLines] : progressLines; - return lines.join("\n"); + return lines.filter((line): line is string => Boolean(line)).join("\n"); }