fix(channels): unify progress draft line formatting

This commit is contained in:
Peter Steinberger
2026-05-04 00:47:37 +01:00
parent df5c453625
commit 36c047c026
15 changed files with 532 additions and 79 deletions

View File

@@ -51,15 +51,16 @@ progress chatter for that turn.
A progress draft has two parts:
| Part | Purpose |
| -------------- | ----------------------------------------------------------------- |
| Label | A short title such as `Thinking...` or `Shelling...`. |
| Progress lines | Compact run updates such as tool calls, task steps, or approvals. |
| Part | Purpose |
| -------------- | --------------------------------------------------------------------------- |
| Label | A short title such as `Thinking...` or `Shelling...`. |
| Progress lines | Compact run updates using the same tool labels and icons as verbose output. |
The label appears after the agent starts meaningful work and either remains busy
for five seconds or emits a second work event. Plain text-only replies do not
show a progress draft. Progress lines are added only when the agent emits useful
work updates. The final answer replaces the draft when possible; otherwise
work updates, for example `🛠️ Exec`, `🔎 Web Search`, or `✍️ Write: to /tmp/file`.
The final answer replaces the draft when possible; otherwise
OpenClaw sends the final answer normally and cleans up or stops updating the
draft according to the channel's transport.

View File

@@ -1524,7 +1524,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: exec\n• exec done");
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• exec done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(editMessageDiscord).toHaveBeenCalledWith(
"c1",
@@ -1557,7 +1557,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: first\n• tool: second");
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🧩 First\n🧩 Second");
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
});

View File

@@ -11,7 +11,10 @@ import {
createChannelReplyPipeline,
resolveChannelSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-reply-pipeline";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
import {
formatChannelProgressDraftLine,
resolveChannelStreamingBlockEnabled,
} from "openclaw/plugin-sdk/channel-streaming";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import {
hasFinalInboundReplyDispatch,
@@ -665,13 +668,28 @@ export async function processDiscordMessage(
await maybeBindStatusReactionsToToolReaction(payload);
await statusReactions.setTool(payload.name);
await draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
formatChannelProgressDraftLine({
event: "tool",
name: payload.name,
phase: payload.phase,
args: payload.args,
}),
{ toolName: payload.name },
);
},
onItemEvent: async (payload) => {
await draftPreview.pushToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
formatChannelProgressDraftLine({
event: "item",
itemKind: payload.kind,
title: payload.title,
name: payload.name,
phase: payload.phase,
status: payload.status,
summary: payload.summary,
progressText: payload.progressText,
meta: payload.meta,
}),
);
},
onPlanUpdate: async (payload) => {
@@ -679,7 +697,13 @@ export async function processDiscordMessage(
return;
}
await draftPreview.pushToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
formatChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
explanation: payload.explanation,
steps: payload.steps,
}),
);
},
onApprovalEvent: async (payload) => {
@@ -687,7 +711,14 @@ export async function processDiscordMessage(
return;
}
await draftPreview.pushToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
formatChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
command: payload.command,
reason: payload.reason,
message: payload.message,
}),
);
},
onCommandOutput: async (payload) => {
@@ -695,9 +726,14 @@ export async function processDiscordMessage(
return;
}
await draftPreview.pushToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
formatChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
name: payload.name,
status: payload.status,
exitCode: payload.exitCode,
}),
);
},
onPatchSummary: async (payload) => {
@@ -705,7 +741,16 @@ export async function processDiscordMessage(
return;
}
await draftPreview.pushToolProgress(
payload.summary ?? payload.title ?? "patch applied",
formatChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,
name: payload.name,
added: payload.added,
modified: payload.modified,
deleted: payload.deleted,
summary: payload.summary,
}),
);
},
onCompactionStart: async () => {

View File

@@ -2705,7 +2705,7 @@ describe("matrix monitor handler draft streaming", () => {
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toMatch(/\n- `tool: read_file`$/);
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toMatch(/\n`🧩 Read File`$/);
await deliver({ text: "Done" }, { kind: "final" });

View File

@@ -1,5 +1,6 @@
import {
createChannelProgressDraftGate,
formatChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
@@ -376,23 +377,6 @@ function formatMatrixToolProgressMarkdownCode(text: string): string {
return `\`${safe}\``;
}
function formatMatrixCommandOutputToolProgress(payload: {
exitCode?: number | null;
name?: string;
title?: string;
}) {
if (!payload.name) {
return payload.title;
}
if (payload.exitCode === 0) {
return `${payload.name} ok`;
}
if (payload.exitCode != null) {
return `${payload.name} (exit ${payload.exitCode})`;
}
return payload.name;
}
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
const {
client,
@@ -1595,40 +1579,91 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
...options,
onToolStart: async (payload) => {
const toolName = payload.name?.trim();
await pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running", {
toolName,
});
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
event: "tool",
name: toolName,
phase: payload.phase,
args: payload.args,
}),
{ toolName },
);
},
onItemEvent: async (payload) => {
await pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
formatChannelProgressDraftLine({
event: "item",
itemKind: payload.kind,
title: payload.title,
name: payload.name,
phase: payload.phase,
status: payload.status,
summary: payload.summary,
progressText: payload.progressText,
meta: payload.meta,
}),
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
await pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
explanation: payload.explanation,
steps: payload.steps,
}),
);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
await pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
formatChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
command: payload.command,
reason: payload.reason,
message: payload.message,
}),
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
await pushPreviewToolProgress(formatMatrixCommandOutputToolProgress(payload));
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
name: payload.name,
status: payload.status,
exitCode: payload.exitCode,
}),
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
await pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,
name: payload.name,
added: payload.added,
modified: payload.modified,
deleted: payload.deleted,
summary: payload.summary,
}),
);
},
};
};

View File

@@ -351,7 +351,7 @@ describe("createMSTeamsReplyDispatcher", () => {
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledWith(
"Working\n- tool: web_search\n- tool: exec",
"Working\n🔎 Web Search\n🛠 Exec",
);
});

View File

@@ -1,4 +1,5 @@
import {
formatChannelProgressDraftLine,
resolveChannelPreviewStreamMode,
resolveChannelStreamingBlockEnabled,
} from "openclaw/plugin-sdk/channel-streaming";
@@ -376,24 +377,48 @@ export function createMSTeamsReplyDispatcher(params: {
: {}),
...(streamController.shouldStreamPreviewToolProgress()
? {
onToolStart: async (payload: { name?: string; phase?: string }) => {
onToolStart: async (payload: {
name?: string;
phase?: string;
args?: Record<string, unknown>;
}) => {
await streamController.pushProgressLine(
payload.name ? `tool: ${payload.name}` : (payload.phase ?? "tool running"),
formatChannelProgressDraftLine({
event: "tool",
name: payload.name,
phase: payload.phase,
args: payload.args,
}),
{ toolName: payload.name },
);
},
onItemEvent: async (payload: {
kind?: string;
progressText?: string;
meta?: string;
summary?: string;
title?: string;
name?: string;
phase?: string;
status?: string;
}) => {
await streamController.pushProgressLine(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
formatChannelProgressDraftLine({
event: "item",
itemKind: payload.kind,
title: payload.title,
name: payload.name,
phase: payload.phase,
status: payload.status,
summary: payload.summary,
progressText: payload.progressText,
meta: payload.meta,
}),
);
},
onPlanUpdate: async (payload: {
phase?: string;
title?: string;
explanation?: string;
steps?: string[];
}) => {
@@ -401,33 +426,80 @@ export function createMSTeamsReplyDispatcher(params: {
return;
}
await streamController.pushProgressLine(
payload.explanation ?? payload.steps?.[0] ?? "planning",
formatChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
explanation: payload.explanation,
steps: payload.steps,
}),
);
},
onApprovalEvent: async (payload: { phase?: string; command?: string }) => {
onApprovalEvent: async (payload: {
phase?: string;
title?: string;
command?: string;
reason?: string;
message?: string;
}) => {
if (payload.phase !== "requested") {
return;
}
await streamController.pushProgressLine(
payload.command ? `approval: ${payload.command}` : "approval requested",
formatChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
command: payload.command,
reason: payload.reason,
message: payload.message,
}),
);
},
onCommandOutput: async (payload: { phase?: string; summary?: string }) => {
if (payload.phase !== "end") {
return;
}
await streamController.pushProgressLine(payload.summary ?? "command output ready");
},
onPatchSummary: async (payload: {
onCommandOutput: async (payload: {
phase?: string;
summary?: string;
title?: string;
name?: string;
status?: string;
exitCode?: number | null;
}) => {
if (payload.phase !== "end") {
return;
}
await streamController.pushProgressLine(
payload.summary ?? payload.title ?? "patch applied",
formatChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
name: payload.name,
status: payload.status,
exitCode: payload.exitCode,
}),
);
},
onPatchSummary: async (payload: {
phase?: string;
summary?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
}) => {
if (payload.phase !== "end") {
return;
}
await streamController.pushProgressLine(
formatChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,
name: payload.name,
added: payload.added,
modified: payload.modified,
deleted: payload.deleted,
summary: payload.summary,
}),
);
},
}

View File

@@ -267,6 +267,12 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({
.filter((line): line is string => Boolean(line))
.join("\n");
},
formatChannelProgressDraftLine: (params: {
progressText?: string;
summary?: string;
title?: string;
name?: string;
}) => params.progressText ?? params.summary ?? params.title ?? params.name,
resolveChannelProgressDraftMaxLines: (entry?: {
streaming?: { progress?: { maxLines?: number } };
}) => entry?.streaming?.progress?.maxLines ?? 8,

View File

@@ -14,6 +14,7 @@ import {
} from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
createChannelProgressDraftGate,
formatChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
@@ -1083,13 +1084,28 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
await statusReactions.setTool(payload.name);
}
await pushPreviewToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
formatChannelProgressDraftLine({
event: "tool",
name: payload.name,
phase: payload.phase,
args: payload.args,
}),
{ toolName: payload.name },
);
},
onItemEvent: async (payload) => {
await pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
formatChannelProgressDraftLine({
event: "item",
itemKind: payload.kind,
title: payload.title,
name: payload.name,
phase: payload.phase,
status: payload.status,
summary: payload.summary,
progressText: payload.progressText,
meta: payload.meta,
}),
);
},
onPlanUpdate: async (payload) => {
@@ -1097,7 +1113,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
await pushPreviewToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
formatChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
explanation: payload.explanation,
steps: payload.steps,
}),
);
},
onApprovalEvent: async (payload) => {
@@ -1105,7 +1127,14 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
await pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
formatChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
command: payload.command,
reason: payload.reason,
message: payload.message,
}),
);
},
onCommandOutput: async (payload) => {
@@ -1113,9 +1142,14 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
await pushPreviewToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
formatChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
name: payload.name,
status: payload.status,
exitCode: payload.exitCode,
}),
);
},
onPatchSummary: async (payload) => {
@@ -1123,7 +1157,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
await pushPreviewToolProgress(
payload.summary ?? payload.title ?? "patch applied",
formatChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,
name: payload.name,
added: payload.added,
modified: payload.modified,
deleted: payload.deleted,
summary: payload.summary,
}),
);
},
},

View File

@@ -827,7 +827,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
expect(draftStream.update).toHaveBeenCalledWith(
expect.stringMatching(/\n• `tool: exec`\n• `exec ls ~\/Desktop`$/),
expect.stringMatching(/\n`🛠️ Exec`\n• `exec ls ~\/Desktop`$/),
);
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
@@ -897,7 +897,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
const lastPreviewText = draftStream.update.mock.calls.at(-1)?.[0];
expect(lastPreviewText).toMatch(/\n• `tool: exec`\n• `read \[label\]\(tg:\/\/user\?id=123\)`$/);
expect(lastPreviewText).toMatch(/\n`🛠️ Exec`\n• `read \[label\]\(tg:\/\/user\?id=123\)`$/);
expect(renderTelegramHtmlText(lastPreviewText ?? "")).not.toContain("<a ");
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -8,6 +8,7 @@ import {
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
createChannelProgressDraftGate,
formatChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
@@ -1172,13 +1173,29 @@ export const dispatchTelegramMessage = async ({
if (statusReactionController && toolName) {
await statusReactionController.setTool(toolName);
}
await pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running", {
toolName,
});
await pushPreviewToolProgress(
formatChannelProgressDraftLine({
event: "tool",
name: toolName,
phase: payload.phase,
args: payload.args,
}),
{ toolName },
);
},
onItemEvent: async (payload) => {
await pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
formatChannelProgressDraftLine({
event: "item",
itemKind: payload.kind,
title: payload.title,
name: payload.name,
phase: payload.phase,
status: payload.status,
summary: payload.summary,
progressText: payload.progressText,
meta: payload.meta,
}),
);
},
onPlanUpdate: async (payload) => {
@@ -1186,7 +1203,13 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushPreviewToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
formatChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
explanation: payload.explanation,
steps: payload.steps,
}),
);
},
onApprovalEvent: async (payload) => {
@@ -1194,7 +1217,14 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
formatChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
command: payload.command,
reason: payload.reason,
message: payload.message,
}),
);
},
onCommandOutput: async (payload) => {
@@ -1202,9 +1232,14 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushPreviewToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
formatChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
name: payload.name,
status: payload.status,
exitCode: payload.exitCode,
}),
);
},
onPatchSummary: async (payload) => {
@@ -1212,7 +1247,16 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushPreviewToolProgress(
payload.summary ?? payload.title ?? "patch applied",
formatChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,
name: payload.name,
added: payload.added,
modified: payload.modified,
deleted: payload.deleted,
summary: payload.summary,
}),
);
},
onCompactionStart:

View File

@@ -96,6 +96,7 @@ export type GetReplyOptions = {
status?: string;
summary?: string;
progressText?: string;
meta?: string;
approvalId?: string;
approvalSlug?: string;
}) => Promise<void> | void;

View File

@@ -1548,6 +1548,7 @@ export async function runAgentTurnWithFallback(params: {
status: readStringValue(evt.data.status),
summary: readStringValue(evt.data.summary),
progressText: readStringValue(evt.data.progressText),
meta: readStringValue(evt.data.meta),
approvalId: readStringValue(evt.data.approvalId),
approvalSlug: readStringValue(evt.data.approvalSlug),
});

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
createChannelProgressDraftGate,
DEFAULT_PROGRESS_DRAFT_LABELS,
formatChannelProgressDraftLine,
formatChannelProgressDraftText,
getChannelStreamingConfigObject,
isChannelProgressDraftWorkToolName,
@@ -177,6 +178,36 @@ describe("channel-streaming", () => {
formatLine: (line) => `\`${line}\``,
}),
).toBe("Shelling\n• `patch applied`\n• `tests done`");
expect(
formatChannelProgressDraftText({
entry,
lines: ["🛠️ Exec", "plain update"],
}),
).toBe("Shelling\n🛠 Exec\n• plain update");
});
it("formats progress draft lines with shared tool display labels", () => {
expect(
formatChannelProgressDraftLine({
event: "tool",
name: "write",
args: { path: "/tmp/demo/index.html" },
}),
).toBe("✍️ Write: to /tmp/demo/index.html");
expect(
formatChannelProgressDraftLine({
event: "item",
itemKind: "tool",
name: "write",
meta: "/tmp/demo/style.css",
}),
).toBe("✍️ Write: /tmp/demo/style.css");
expect(
formatChannelProgressDraftLine({
event: "patch",
modified: ["/tmp/demo/index.html", "/tmp/demo/style.css"],
}),
).toBe("🩹 Apply Patch: /tmp/demo/{index.html, style.css}");
});
it("starts progress drafts after five seconds or a second work event", async () => {

View File

@@ -1,3 +1,5 @@
import { formatToolDetail, resolveToolDisplay } from "../agents/tool-display.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
@@ -124,6 +126,176 @@ export function isChannelProgressDraftWorkToolName(name: string | null | undefin
return Boolean(normalized && !NON_WORK_PROGRESS_TOOL_NAMES.has(normalized));
}
type ChannelProgressLineOptions = {
markdown?: boolean;
};
const EMOJI_PREFIX_RE = /^\p{Extended_Pictographic}/u;
export type ChannelProgressDraftLineInput =
| {
event: "tool";
name?: string;
phase?: string;
args?: Record<string, unknown>;
}
| {
event: "item";
itemKind?: string;
title?: string;
name?: string;
phase?: string;
status?: string;
summary?: string;
progressText?: string;
meta?: string;
}
| {
event: "plan";
phase?: string;
title?: string;
explanation?: string;
steps?: string[];
}
| {
event: "approval";
phase?: string;
title?: string;
command?: string;
reason?: string;
message?: string;
}
| {
event: "command-output";
phase?: string;
title?: string;
name?: string;
status?: string;
exitCode?: number | null;
}
| {
event: "patch";
phase?: string;
title?: string;
name?: string;
added?: string[];
modified?: string[];
deleted?: string[];
summary?: string;
};
function compactStrings(values: readonly (string | undefined | null)[]): string[] {
return values.map((value) => value?.replace(/\s+/g, " ").trim()).filter(Boolean) as string[];
}
function inferToolMeta(name: string | undefined, args: Record<string, unknown> | undefined) {
if (!name || !args) {
return undefined;
}
return formatToolDetail(resolveToolDisplay({ name, args }));
}
function formatNamedProgressLine(
name: string | undefined,
metas: readonly (string | undefined | null)[] | undefined,
options?: ChannelProgressLineOptions,
): string | undefined {
const normalizedName = name?.trim() || "tool_call";
const compactMetas = compactStrings(metas ?? []);
return formatToolAggregate(normalizedName, compactMetas.length ? compactMetas : undefined, {
markdown: options?.markdown,
});
}
function itemKindToToolName(kind: string | undefined): string | undefined {
switch (normalizeOptionalLowercaseString(kind)) {
case "command":
return "exec";
case "patch":
return "apply_patch";
case "search":
return "web_search";
case "tool":
return "tool_call";
default:
return undefined;
}
}
function patchMetas(input: Extract<ChannelProgressDraftLineInput, { event: "patch" }>): string[] {
const fileMetas = [...(input.added ?? []), ...(input.modified ?? []), ...(input.deleted ?? [])];
return compactStrings([input.summary, ...fileMetas, input.title]);
}
function shouldPrefixProgressLine(line: string): boolean {
return !EMOJI_PREFIX_RE.test(line);
}
export function formatChannelProgressDraftLine(
input: ChannelProgressDraftLineInput,
options?: ChannelProgressLineOptions,
): string | undefined {
switch (input.event) {
case "tool": {
return formatNamedProgressLine(
input.name,
[
inferToolMeta(input.name, input.args),
input.phase && !input.name ? input.phase : undefined,
],
options,
);
}
case "item": {
const name = input.name ?? itemKindToToolName(input.itemKind);
const meta = input.meta ?? input.progressText ?? input.summary;
if (name) {
return formatNamedProgressLine(name, [meta], options);
}
return compactStrings([meta, input.title]).at(0);
}
case "plan": {
if (input.phase !== undefined && input.phase !== "update") {
return undefined;
}
return formatNamedProgressLine(
"update_plan",
[input.explanation, input.steps?.[0], input.title ?? "planning"],
options,
);
}
case "approval": {
if (input.phase !== undefined && input.phase !== "requested") {
return undefined;
}
return formatNamedProgressLine(
"approval",
[input.command, input.message, input.reason, input.title ?? "approval requested"],
options,
);
}
case "command-output": {
if (input.phase !== undefined && input.phase !== "end") {
return undefined;
}
const status =
input.exitCode === 0
? "completed"
: input.exitCode != null
? `exit ${input.exitCode}`
: input.status;
return formatNamedProgressLine(input.name ?? "exec", [status, input.title], options);
}
case "patch": {
if (input.phase !== undefined && input.phase !== "end") {
return undefined;
}
return formatNamedProgressLine(input.name ?? "apply_patch", patchMetas(input), options);
}
}
return undefined;
}
export function createChannelProgressDraftGate(params: {
onStart: () => void | Promise<void>;
initialDelayMs?: number;
@@ -377,6 +549,8 @@ export function formatChannelProgressDraftText(params: {
.map((line) => line.replace(/\s+/g, " ").trim())
.filter((line) => line.length > 0)
.slice(-maxLines)
.map((line) => `${bullet} ${formatLine(line)}`);
.map((line) =>
shouldPrefixProgressLine(line) ? `${bullet} ${formatLine(line)}` : formatLine(line),
);
return [label, ...lines].filter((line): line is string => Boolean(line)).join("\n");
}