mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user