fix(channels): show tool lines under the progress status headline

A status headline replaced the rolling tool lines instead of sitting above
them, so a default Discord draft showed one preamble sentence for an entire
tool-heavy turn. Operators reached for `/verbose` to see any activity, which
delivers durable per-tool-call messages and floods the channel.

- Render the headline above the lines; both stay visible in one message.
- Shorten the start gate from 5s to 1.5s. The gate only creates the draft
  when the timer fires and finalize cancels it, so quick answers still post
  no draft while a 3s tool turn stops being silent.
- Drop Discord's label-gated tool-progress default so
  resolveChannelStreamingPreviewToolProgress is the single owner. An explicit
  `toolProgress: false` still silences the lines.
- Resolve that toggle against a "progress" mode guess when `streaming.mode`
  is unset, so the progress-draft channels stop ignoring an explicit
  `progress.toolProgress` opt-out.

Telegram now defaults to `streaming.mode: "progress"` like Discord; set
`"partial"` to keep streamed answer text. Its renderer draws work lines from
the compositor's structured lines, so `rendersRollingLinesNatively` keeps
them out of the composed text rather than printing every line twice.
This commit is contained in:
Ayaan Zaidi
2026-07-30 02:34:12 +02:00
parent cfa98bdf00
commit 06aa81a73f
19 changed files with 130 additions and 83 deletions
+2 -3
View File
@@ -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).
+1 -1
View File
@@ -320,7 +320,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
<Accordion title="Live stream preview (message edits)">
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)
+14 -9
View File
@@ -22,11 +22,11 @@ Working...
```
<Note>
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.
</Note>
## 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
+13 -9
View File
@@ -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
@@ -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" &&
@@ -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");
});
@@ -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("💬");
@@ -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);
};
}
@@ -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);
@@ -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 () => {
+5 -3
View File
@@ -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", () => {
@@ -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();
+5 -3
View File
@@ -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", () => {
+4 -1
View File
@@ -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");
}
+11 -9
View File
@@ -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"],
});
+15 -4
View File
@@ -132,6 +132,11 @@ export function createChannelProgressDraftCompositor(params: {
tryNativeUpdate?: (text: string) => Promise<boolean> | 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();
+16 -6
View File
@@ -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", () => {
+18 -12
View File
@@ -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. */
+4 -3
View File
@@ -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);