diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 87473f744623..49329f839dff 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -717,10 +717,9 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior - `off` disables Discord preview edits. - `partial` edits a single preview message as tokens arrive. - `block` emits draft-sized chunks; tune size and breakpoints with `streaming.preview.chunk` (`minChars`, `maxChars`, `breakPreference`), clamped to `textChunkLimit`. When block streaming is explicitly enabled, OpenClaw skips the preview stream to avoid double-streaming. - - `progress` keeps one editable status draft until final delivery. By default it shows one line of the agent's latest preamble or narration, with no generated label, spacer, or tool rows. + - `progress` keeps one editable status draft until final delivery. It shows the agent's latest preamble or narration as a status headline, with the compact tool rows underneath and no generated label. - Media, error, and explicit-reply finals cancel pending preview edits. - - `streaming.preview.toolProgress` defaults to `true` in `partial`/`block` mode. Discord progress mode defaults to no tool rows; set `streaming.progress.toolProgress: true` to opt in. - - Set `streaming.progress.toolProgress: true` to add compact tool/progress rows such as `🛠️ Bash: run tests` or `🔎 Web Search: for "query"`. For compatibility, an existing `progress.label` or `progress.labels` configuration retains the prior tool-row default; set `toolProgress: false` for a custom label without rows. + - `streaming.preview.toolProgress` and `streaming.progress.toolProgress` both default to `true` in every mode. Tool rows such as `🛠️ Bash: run tests` or `🔎 Web Search: for "query"` appear without config; set either key to `false` to keep the status headline only. - `streaming.progress.commentary` (default `false`) opts into raw assistant commentary in the temporary progress draft. The default preamble/narration status line is independent of this option. Commentary is cleaned before display, stays transient, and does not change final answer delivery. - `streaming.progress.maxLineChars` controls the per-line progress preview budget. Prose is shortened on word boundaries; command and path details keep useful suffixes. - `streaming.preview.commandText` / `streaming.progress.commandText` controls command/exec detail in compact progress lines: `raw` (default) or `status` (tool label only). diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 719d808d2fd4..5a8f78af8521 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -320,7 +320,7 @@ curl "https://api.telegram.org/bot/getUpdates" OpenClaw streams partial replies in real time in direct chats, groups, and topics: send a preview message, then `editMessageText` repeatedly, finalizing in place. - - `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`) + - `channels.telegram.streaming` is `off | partial | block | progress` (default: `progress`); set `mode: "partial"` to stream answer text into the preview instead of a status draft - short initial answer previews are debounced, then materialized after a bounded delay if the run is still active - `progress` keeps one editable status draft for tool progress, shows the stable status label when answer activity arrives before tool progress, clears it at completion, and sends the final answer as a normal message - `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active) diff --git a/docs/concepts/progress-drafts.md b/docs/concepts/progress-drafts.md index 6ee0e6fd31c7..eb28cb286d01 100644 --- a/docs/concepts/progress-drafts.md +++ b/docs/concepts/progress-drafts.md @@ -22,11 +22,11 @@ Working... ``` - Discord already defaults to `streaming.mode: "progress"` when - `channels.discord.streaming` is unset, so progress drafts - show up there without any config. Every other channel defaults to `partial` - or `off`; see [Streaming and chunking](/concepts/streaming#channel-mapping) - for the full per-channel default table. + Discord and Telegram default to `streaming.mode: "progress"`, so progress + drafts show up there without any config. Set `mode: "partial"` on either to + stream answer text instead. Every other channel defaults to `partial` or + `off`; see [Streaming and chunking](/concepts/streaming#channel-mapping) for + the full per-channel default table. ## Quick start @@ -43,7 +43,7 @@ Working... } ``` -Defaults from here: a start delay of 5 seconds, compact progress lines while +Defaults from here: a start delay of 1.5 seconds, compact progress lines while useful work happens, and suppression of the older standalone progress messages for that turn. Raw tool-line drafts use an automatic one-word label; a status headline omits that redundant title @@ -61,11 +61,14 @@ migration, see [Streaming and chunking](/concepts/streaming). | Label | Optional starter/status line such as `Working`. | | Progress lines | Compact run updates using the same tool icons and detail formatter as `/verbose`. | +The status headline sits above the rolling progress lines and both stay visible, +so one message answers what the agent is doing and how far it has got. + For raw tool progress, the label appears once the agent starts meaningful work and stays busy for the initial delay. It sits at the top of the rolling progress-line list, so it scrolls away once -enough concrete work lines appear. A status headline shows only the agent's -plain-language status unless a label is configured explicitly. Plain text-only +enough concrete work lines appear. The implicit label is hidden while a status +headline is present unless you configure one explicitly. Plain text-only replies never show a progress draft; a line appears only for real work updates, for example `🛠️ Bash: run tests`, `🔎 Web Search: for "discord edit message"`, or `✍️ Write: to /tmp/file`. @@ -158,7 +161,9 @@ Hide the label and show only progress lines: Progress lines come from real run events: tool starts, item updates, task plans, approvals, command output, patch summaries, and similar agent activity. -They are enabled by default (`progress.toolProgress`, default `true`). +They are enabled by default (`progress.toolProgress`, default `true`) and stay +visible underneath the status headline. Set `progress.toolProgress: false` to +keep the headline alone. Tools can also emit typed progress while a single call is still running. That is how a slow fetch or search updates the visible draft before the tool diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index ab043c5b97b1..34f388e87da5 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -177,13 +177,16 @@ instead of being overwritten in one editable draft. ### Channel mapping -| Channel | `off` | `partial` | `block` | `progress` | -| ---------- | ----- | --------- | ------- | ----------------------- | -| Telegram | Yes | Yes | Yes | editable progress draft | -| Discord | Yes | Yes | Yes | editable progress draft | -| Slack | Yes | Yes | Yes | Yes | -| Mattermost | Yes | Yes | Yes | Yes | -| MS Teams | Yes | Yes | Yes | native progress stream | +Discord and Telegram default to `progress` when `streaming` is unset; Slack, +Mattermost, and MS Teams default to `partial`. + +| Channel | `off` | `partial` | `block` | `progress` | +| ---------- | ----- | --------- | ------- | --------------------------------- | +| Telegram | Yes | Yes | Yes | editable progress draft (default) | +| Discord | Yes | Yes | Yes | editable progress draft (default) | +| Slack | Yes | Yes | Yes | Yes | +| Mattermost | Yes | Yes | Yes | Yes | +| MS Teams | Yes | Yes | Yes | native progress stream | Preview chunk config (`streaming.preview.chunk.*`, e.g. under `channels.discord.streaming` or `channels.telegram.streaming`) defaults to @@ -325,8 +328,9 @@ Supported surfaces: messages, while approval prompts, media payloads, and errors still route normally. - To keep preview streaming but hide tool-progress lines, set - `streaming.preview.toolProgress` to `false` for that channel (default - `true`). To keep tool-progress lines visible while hiding command/exec text, + `streaming.preview.toolProgress` or `streaming.progress.toolProgress` to + `false` for that channel (both default `true`, and both are honored in every + mode). To keep tool-progress lines visible while hiding command/exec text, set `streaming.preview.commandText` to `"status"` or `streaming.progress.commandText` to `"status"`; the default is `"raw"` to preserve released behavior. This policy is shared by draft/progress channels diff --git a/extensions/discord/src/monitor/message-handler.draft-preview.ts b/extensions/discord/src/monitor/message-handler.draft-preview.ts index b771d1eeb4b3..dd71ab88f513 100644 --- a/extensions/discord/src/monitor/message-handler.draft-preview.ts +++ b/extensions/discord/src/monitor/message-handler.draft-preview.ts @@ -3,7 +3,6 @@ import { type AgentPlanStep, type ChannelProgressDraftLine, createChannelProgressDraftCompositor, - resolveChannelProgressDraftConfig, resolveChannelStreamingBlockEnabled, resolveChannelStreamingPreviewCommandText, resolveChannelStreamingPreviewToolProgress, @@ -93,18 +92,8 @@ export function createDiscordDraftPreviewController(params: { let progressDraftStartedBeforeFinal = false; let progressDraftCollapsed = false; let progressNarratorLifecycle: { beginTurn: () => void; stopTurn: () => void } | undefined; - const progressConfig = resolveChannelProgressDraftConfig(params.discordConfig); - const progressHasExplicitLabel = - progressConfig.label !== undefined || progressConfig.labels !== undefined; - // Discord defaults to progress mode even when `streaming.mode` is omitted, - // so pass that resolved mode into the shared default through this fallback. - const progressToolDefault = progressConfig.toolProgress ?? progressHasExplicitLabel; const previewToolProgressEnabled = - Boolean(draftStream) && - resolveChannelStreamingPreviewToolProgress( - params.discordConfig, - discordStreamMode === "progress" ? progressToolDefault : true, - ); + Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig); const narrationProgressEnabled = Boolean(draftStream) && discordStreamMode === "progress" && diff --git a/extensions/discord/src/monitor/message-handler.process.draft-final.test.ts b/extensions/discord/src/monitor/message-handler.process.draft-final.test.ts index 94c1a3b3e577..4b7d2a69bc29 100644 --- a/extensions/discord/src/monitor/message-handler.process.draft-final.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.draft-final.test.ts @@ -354,7 +354,7 @@ describe("processDiscordMessage draft streaming final delivery", () => { await runProcessDiscordMessage(ctx); const updates = draftStream.update.mock.calls.map((call) => call[0]); - expect(updates).toContain("Reading the gateway config and restarting agents."); + expect(updates).toContain("Reading the gateway config and restarting agents.\n\n🛠️ Exec"); expectFinalWithProgressReceipt("done", "🛠️ 1 tool call"); }); diff --git a/extensions/discord/src/monitor/message-handler.process.draft-recovery.test.ts b/extensions/discord/src/monitor/message-handler.process.draft-recovery.test.ts index 82fa54e33de1..2dd8137e7a8f 100644 --- a/extensions/discord/src/monitor/message-handler.process.draft-recovery.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.draft-recovery.test.ts @@ -490,7 +490,7 @@ describe("processDiscordMessage draft streaming recovery", () => { expect(firstDispatchParams().replyOptions?.disableBlockStreaming).toBe(true); }); - it("shows only the agent status in the default Discord progress draft", async () => { + it("shows the agent status above the tool lines in the default Discord progress draft", async () => { const elapseProgressDraftStartDelay = useProgressDraftStartDelay(); const draftStream = createMockDraftStreamForTest(); @@ -519,9 +519,10 @@ describe("processDiscordMessage draft streaming recovery", () => { expect(draftStream.update).toHaveBeenCalledTimes(1); expect(draftStream.update).toHaveBeenCalledWith( - "Claiming my square footage. Tastefully, but with claws.", + "Claiming my square footage. Tastefully, but with claws.\n\n🛠️ Exec\n• exec done", ); - expect(String(draftStream.update.mock.calls[0]?.[0])).not.toMatch(/Working|Exec|\n\n/); + // No config, so the implicit label stays hidden under the status headline. + expect(String(draftStream.update.mock.calls[0]?.[0])).not.toMatch(/Working/); expect(draftStream.flush).toHaveBeenCalledTimes(1); expect( requireRecord(firstDispatchParams().replyOptions, "dispatch reply options") @@ -559,7 +560,7 @@ describe("processDiscordMessage draft streaming recovery", () => { await runProcessDiscordMessage(ctx); expect(draftStream.update).toHaveBeenLastCalledWith( - "Checking private context before replying.", + "Checking private context before replying.\n\n🛠️ Exec", ); expectFinalWithProgressReceipt("done", "🛠️ 1 tool call"); expect(getDeliveredFinalTexts()[0]).not.toContain("💬"); diff --git a/extensions/discord/src/monitor/message-handler.process.test-helpers.ts b/extensions/discord/src/monitor/message-handler.process.test-helpers.ts index 0f5f775038ec..7c76458c22f1 100644 --- a/extensions/discord/src/monitor/message-handler.process.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.process.test-helpers.ts @@ -167,7 +167,8 @@ export function expectFreshFinalText(text: string) { export function useProgressDraftStartDelay() { vi.useFakeTimers(); return async () => { - await vi.advanceTimersByTimeAsync(5_000); + // Mirrors core's DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS. + await vi.advanceTimersByTimeAsync(1_500); }; } diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index c18f8a181e07..616e1b229b31 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -26,6 +26,10 @@ import { import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; +// Core owns the shared gate (DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS); plugins +// cannot import it, so mirror the value here for start-boundary assertions. +const PROGRESS_DRAFT_START_DELAY_MS = 1_500; + const sendMessageMatrixMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })), ); @@ -3886,7 +3890,9 @@ describe("matrix monitor handler draft streaming", () => { await opts.onQueuedFollowupAdmitted?.(); await opts.onToolStart?.({ name: "exec" }); - await vi.advanceTimersByTimeAsync(4_999); + // Mirrors DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS: the followup draft must + // wait out a fresh gate instead of inheriting the primary turn's timer. + await vi.advanceTimersByTimeAsync(PROGRESS_DRAFT_START_DELAY_MS - 1); expect(sendSingleTextMessageMatrixMock).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); 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 51c7028a16d8..0b4859d14367 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 @@ -3806,7 +3806,9 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expect(capturedReplyOptions?.commentaryProgressEnabled).toBeUndefined(); expect(capturedReplyOptions?.onVerboseProgressVisibility).toBeUndefined(); expect(capturedReplyOptions?.progressPreambleEnabled).toBe(true); - expect(draftStream.update).toHaveBeenLastCalledWith("Keeping the released behavior"); + expect(draftStream.update).toHaveBeenLastCalledWith( + "Keeping the released behavior\n\n• pnpm test", + ); }); it("preserves Slack preamble previews outside progress mode", async () => { diff --git a/extensions/slack/src/progress-blocks.test.ts b/extensions/slack/src/progress-blocks.test.ts index efb1ab3166dc..aa4a33671c83 100644 --- a/extensions/slack/src/progress-blocks.test.ts +++ b/extensions/slack/src/progress-blocks.test.ts @@ -81,11 +81,11 @@ function expectTaskUpdate(task: unknown, fields: { id: unknown; title: string; s } describe("buildSlackProgressDraftBlocks", () => { - it("keeps a typed checklist below Slack status draft text", () => { + it("keeps a typed checklist below Slack status draft text and work lines", () => { expect( formatChannelProgressDraftText({ entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, - lines: [toolLine("hidden while status exists")], + lines: [toolLine("read the config")], narration: "Implementing the change.", plan: [ { step: "Inspect", status: "completed" }, @@ -93,7 +93,9 @@ describe("buildSlackProgressDraftBlocks", () => { { step: "Test", status: "pending" }, ], }), - ).toBe("Shelling\n\nImplementing the change.\n\n✅ Inspect\n▸ Patch\n▢ Test"); + ).toBe( + "Shelling\n\nImplementing the change.\n\n🛠️ read the config\n✅ Inspect\n▸ Patch\n▢ Test", + ); }); it("keeps legacy rich draft rendering as section field blocks", () => { diff --git a/extensions/telegram/src/bot-message-dispatch-progress.ts b/extensions/telegram/src/bot-message-dispatch-progress.ts index fb85ea02492e..61022cfe3e75 100644 --- a/extensions/telegram/src/bot-message-dispatch-progress.ts +++ b/extensions/telegram/src/bot-message-dispatch-progress.ts @@ -83,6 +83,9 @@ export function createTelegramProgressController(params: { commentaryLinePrefix: "💬 ", commentaryItalics: false, updateOnLineChange: true, + // renderTelegramProgressDraftPreview draws the work lines from `lines` in + // headline/checklist mode, so they must not also arrive inside the text. + rendersRollingLinesNatively: true, update: async (streamText, options) => { draftEverRendered = true; await params.draft.prepareAnswerLaneForToolProgress(); diff --git a/extensions/telegram/src/bot.helpers.test.ts b/extensions/telegram/src/bot.helpers.test.ts index aa4326bbcb2d..750fb59d47cf 100644 --- a/extensions/telegram/src/bot.helpers.test.ts +++ b/extensions/telegram/src/bot.helpers.test.ts @@ -5,9 +5,11 @@ import { resolveTelegramGroupAllowFromContext, resolveTelegramStreamMode } from import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; describe("resolveTelegramStreamMode", () => { - it("defaults to partial when telegram streaming is unset", () => { - expect(resolveTelegramStreamMode(undefined)).toBe("partial"); - expect(resolveTelegramStreamMode({})).toBe("partial"); + it("defaults to progress when telegram streaming is unset", () => { + expect(resolveTelegramStreamMode(undefined)).toBe("progress"); + expect(resolveTelegramStreamMode({})).toBe("progress"); + // An explicit mode still wins, including the previous default. + expect(resolveTelegramStreamMode({ streaming: { mode: "partial" } })).toBe("partial"); }); it("resolves nested streaming.mode values", () => { diff --git a/extensions/telegram/src/preview-streaming.ts b/extensions/telegram/src/preview-streaming.ts index 81c2e12ac32e..9d20fc6dad34 100644 --- a/extensions/telegram/src/preview-streaming.ts +++ b/extensions/telegram/src/preview-streaming.ts @@ -9,5 +9,8 @@ export function resolveTelegramPreviewStreamMode( streaming?: unknown; } = {}, ): StreamingMode { - return resolveChannelPreviewStreamMode(params, "partial"); + // Telegram defaults to the progress draft like Discord: on tool-heavy turns a + // status draft answers "is it working?", which streamed answer text cannot. + // Operators who prefer streamed answer text set `streaming.mode: "partial"`. + return resolveChannelPreviewStreamMode(params, "progress"); } diff --git a/src/channels/progress-draft-compositor.test.ts b/src/channels/progress-draft-compositor.test.ts index 693a7f978d9d..0e715f7a113f 100644 --- a/src/channels/progress-draft-compositor.test.ts +++ b/src/channels/progress-draft-compositor.test.ts @@ -457,7 +457,7 @@ describe("createChannelProgressDraftCompositor", () => { }); }); - it("replaces tool lines with narration and drops redundant edits", async () => { + it("keeps tool lines under narration and drops redundant edits", async () => { const update = vi.fn(); const progress = createChannelProgressDraftCompositor({ entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, @@ -470,24 +470,26 @@ describe("createChannelProgressDraftCompositor", () => { await progress.pushToolProgress("🛠️ Exec", { startImmediately: true }); await progress.pushNarrationProgress("Updating the config file now."); expect(update).toHaveBeenLastCalledWith( - "Shelling\n\nUpdating the config file now.", + "Shelling\n\nUpdating the config file now.\n\n🛠️ Exec", expect.anything(), ); - // Tool events keep accumulating underneath without editing the message. - const callsAfterNarration = update.mock.calls.length; + // Tool events stay visible under the headline, so each new line edits. await progress.pushToolProgress("🛠️ Wc", { startImmediately: true }); - expect(update.mock.calls.length).toBe(callsAfterNarration); + expect(update).toHaveBeenLastCalledWith( + "Shelling\n\nUpdating the config file now.\n\n🛠️ Exec\n🛠️ Wc", + expect.anything(), + ); // Identical narration is dropped; changed narration edits once. expect(await progress.pushNarrationProgress("Updating the config file now.")).toBe(false); await progress.pushNarrationProgress("Restarting the gateway."); expect(update).toHaveBeenLastCalledWith( - "Shelling\n\nRestarting the gateway.", + "Shelling\n\nRestarting the gateway.\n\n🛠️ Exec\n🛠️ Wc", expect.anything(), ); - // Narration stopping (empty update) falls back to the raw tool lines. + // Narration stopping (empty update) leaves the raw tool lines. await progress.pushNarrationProgress(""); expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🛠️ Wc", expect.anything()); }); @@ -545,7 +547,7 @@ describe("createChannelProgressDraftCompositor", () => { await progress.pushToolProgress("🛠️ Exec one", { startImmediately: true }); await progress.pushToolProgress("🛠️ Exec two", { startImmediately: true }); - expect(update).toHaveBeenLastCalledWith("Reading the workspace.", { + expect(update).toHaveBeenLastCalledWith("Reading the workspace.\n\n🛠️ Exec one\n🛠️ Exec two", { lines: ["🛠️ Exec one", "🛠️ Exec two"], }); }); @@ -829,7 +831,7 @@ describe("createChannelProgressDraftCompositor", () => { expect(update).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - expect(update).toHaveBeenCalledWith("Reading the gateway config.", { + expect(update).toHaveBeenCalledWith("Reading the gateway config.\n\n🛠️ Exec", { flush: true, lines: ["🛠️ Exec"], }); diff --git a/src/channels/progress-draft-compositor.ts b/src/channels/progress-draft-compositor.ts index ae699010da94..b85bcffe9cc4 100644 --- a/src/channels/progress-draft-compositor.ts +++ b/src/channels/progress-draft-compositor.ts @@ -132,6 +132,11 @@ export function createChannelProgressDraftCompositor(params: { tryNativeUpdate?: (text: string) => Promise | boolean; /** Publish when structured lines change even if the rendered text does not. */ updateOnLineChange?: boolean; + /** + * Set when the channel renders `update`'s structured `lines` itself, so the + * composed text carries only the status block (label, headline, checklist). + */ + rendersRollingLinesNatively?: boolean; formatLine?: (line: string) => string; isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean; shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean; @@ -210,15 +215,21 @@ export function createChannelProgressDraftCompositor(params: { : effectiveNarration; }; - const formatDraftText = (draftLines = lines, options?: { formatted?: boolean }) => - formatChannelProgressDraftText({ + const formatDraftText = (draftLines = lines, options?: { formatted?: boolean }) => { + const narration = resolveStatusText() || undefined; + // Channels that render the rolling lines themselves (from `update`'s + // `lines`) would print them twice if they also appeared in this text. + const linesRenderedByChannel = + params.rendersRollingLinesNatively === true && Boolean(narration || planSteps?.length); + return formatChannelProgressDraftText({ entry: params.entry, - lines: draftLines, + lines: linesRenderedByChannel ? [] : draftLines, seed: params.seed, formatLine: options?.formatted === false ? undefined : params.formatLine, - narration: resolveStatusText() || undefined, + narration, plan: planSteps, }); + }; const getSnapshot = (): ChannelProgressDraftCompositorSnapshot => { const statusHeadline = resolveStatusText(); diff --git a/src/channels/streaming.test.ts b/src/channels/streaming.test.ts index 48f689ec6464..d0defb9d7520 100644 --- a/src/channels/streaming.test.ts +++ b/src/channels/streaming.test.ts @@ -168,11 +168,11 @@ describe("progress narration", () => { expect( formatChannelProgressDraftText({ entry: { streaming: { mode: "progress", progress: { label: false } } }, - lines: ["🛠️ hidden"], + lines: ["🛠️ Exec"], narration: "Working through the plan.", plan, }), - ).toBe("Working through the plan.\n\n✅ Inspect\n▸ Patch\n▢ Test"); + ).toBe("Working through the plan.\n\n🛠️ Exec\n✅ Inspect\n▸ Patch\n▢ Test"); }); it("summarizes overflowing plans and prioritizes unfinished steps", () => { @@ -226,7 +226,7 @@ describe("progress narration", () => { narration: "Counting lines in the workspace files.", }); - expect(text).toBe("Counting lines in the workspace files."); + expect(text).toBe("Counting lines in the workspace files.\n\n🛠️ Exec"); }); it("keeps an explicitly configured automatic label above narration", () => { @@ -241,17 +241,27 @@ describe("progress narration", () => { narration: "Counting lines in the workspace files.", }); - expect(text).toBe("Clawing\n\nCounting lines in the workspace files."); + expect(text).toBe("Clawing\n\nCounting lines in the workspace files.\n\n🛠️ Exec"); }); - it("renders narration instead of tool lines", () => { + it("keeps tool lines visible under the narration headline", () => { const text = formatChannelProgressDraftText({ entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, lines: ["🛠️ Exec", "🛠️ Wc"], narration: "Counting lines in the workspace files.", }); - expect(text).toBe("Shelling\n\nCounting lines in the workspace files."); + expect(text).toBe("Shelling\n\nCounting lines in the workspace files.\n\n🛠️ Exec\n🛠️ Wc"); + }); + + it("renders the narration headline alone when no work lines exist yet", () => { + const text = formatChannelProgressDraftText({ + entry: { streaming: { mode: "progress", progress: { label: false } } }, + lines: [], + narration: "Counting lines in the workspace files.", + }); + + expect(text).toBe("Counting lines in the workspace files."); }); it("compacts narration at a word boundary instead of line width", () => { diff --git a/src/channels/streaming.ts b/src/channels/streaming.ts index 8884b8536f0d..dde5389edb23 100644 --- a/src/channels/streaming.ts +++ b/src/channels/streaming.ts @@ -77,7 +77,10 @@ function asCommandTextMode(value: unknown): ChannelStreamingCommandTextMode | un export const DEFAULT_PROGRESS_DRAFT_LABELS = SHARED_PROGRESS_DRAFT_LABELS; -export const DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS = 5_000; +// Short enough that a multi-tool turn is never silent, long enough that a +// quick answer posts no draft at all: the gate only creates the draft when the +// timer fires, and finalize cancels it. +export const DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS = 1_500; const DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS = 120; // Narration is a short paragraph, not a compact tool line; it gets its own // budget so the utility-model text is not mid-word truncated at line width. @@ -801,7 +804,10 @@ export function resolveChannelStreamingPreviewToolProgress( defaultValue = true, ): boolean { const config = getChannelStreamingConfigObject(entry); - if (resolveChannelPreviewStreamMode(entry, "partial") === "progress") { + // An unset `streaming.mode` means the channel's own default applies, and the + // progress-draft channels (Discord, Telegram) default to "progress". Guessing + // "partial" here dropped their explicit `progress.toolProgress` opt-out. + if (resolveChannelPreviewStreamMode(entry, "progress") === "progress") { return ( asBoolean(config?.progress?.toolProgress) ?? asBoolean(config?.preview?.toolProgress) ?? @@ -877,7 +883,7 @@ export function resolveChannelStreamingNativeTransport( export function resolveChannelPreviewStreamMode( entry: StreamingCompatEntry | null | undefined, - defaultMode: "off" | "partial", + defaultMode: StreamingMode, ): StreamingMode { return parsePreviewStreamingMode(getChannelStreamingConfigObject(entry)?.mode) ?? defaultMode; } @@ -1274,11 +1280,9 @@ export function formatChannelProgressDraftText(params: { seed: params.seed, random: params.random, }); - if (narration) { - const formatted = formatLine(narration); - const status = resolvedLabel ? `${resolvedLabel}\n\n${formatted}` : formatted; - return planLines.length > 0 ? `${status}\n\n${planLines.join("\n")}` : status; - } + // The status headline sits above the rolling lines instead of replacing them: + // a headline-only draft reads as "the agent is quiet" even while tools run. + const statusHeadline = narration ? formatLine(narration) : ""; const bullet = params.bullet ?? "•"; const toolLineBudget = planLines.length > 0 ? Math.max(0, maxLines - planLines.length) : maxLines; const visibleToolLines = @@ -1323,9 +1327,11 @@ export function formatChannelProgressDraftText(params: { if (planLines.length > 0) { renderedLines.push(...planLines); } - if (renderedLines.length > 1 && lines[0]?.isLabelLine) { - return `${renderedLines[0]}\n\n${renderedLines.slice(1).join("\n")}`; - } - return renderedLines.join("\n"); + // The label keeps its own block above the headline, and stays inside the + // rolling list so it still scrolls away once enough work lines accumulate. + const hasLabelBlock = lines[0]?.isLabelLine === true; + const labelBlock = hasLabelBlock ? renderedLines[0] : undefined; + const rollingBlock = (hasLabelBlock ? renderedLines.slice(1) : renderedLines).join("\n"); + return [labelBlock, statusHeadline, rollingBlock].filter(Boolean).join("\n\n"); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index c16c6dcf60ab..b60271569796 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildChannelProgressDraftLine, createChannelProgressDraftGate, + DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS, DEFAULT_PROGRESS_DRAFT_LABELS, formatChannelProgressDraftLine, formatChannelProgressDraftLineForEntry, @@ -620,7 +621,7 @@ describe("channel-streaming", () => { expect(recoveredUpdated[0]).not.toHaveProperty("detail"); }); - it("starts progress drafts after five seconds", async () => { + it("starts progress drafts after the initial delay", async () => { vi.useFakeTimers(); const onStart = vi.fn(async () => {}); const gate = createChannelProgressDraftGate({ onStart }); @@ -628,7 +629,7 @@ describe("channel-streaming", () => { await expect(gate.noteWork()).resolves.toBe(false); expect(onStart).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(4_999); + await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS - 1); expect(onStart).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); @@ -646,7 +647,7 @@ describe("channel-streaming", () => { expect(gate.workEvents).toBe(2); expect(onStart).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(4_999); + await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS - 1); expect(onStart).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1);