diff --git a/docs/concepts/progress-drafts.md b/docs/concepts/progress-drafts.md index ee13f79e95f..d1c47edbe5a 100644 --- a/docs/concepts/progress-drafts.md +++ b/docs/concepts/progress-drafts.md @@ -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. diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index c83004a7ecd..6ff3d080e6b 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -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(); }); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index c9ec233d228..414226ab79d 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -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 () => { diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index f80a92eaf26..a8278f4ce8c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -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" }); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index ecea69ab64c..d648629442d 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -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, + }), + ); }, }; }; diff --git a/extensions/msteams/src/reply-dispatcher.test.ts b/extensions/msteams/src/reply-dispatcher.test.ts index 425c599eea7..877d3888179 100644 --- a/extensions/msteams/src/reply-dispatcher.test.ts +++ b/extensions/msteams/src/reply-dispatcher.test.ts @@ -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", ); }); diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 9af66b02f66..c4600510974 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -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; + }) => { 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, + }), ); }, } 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 bde86baa398..bbdfd043ef0 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 @@ -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, diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 4b3f9225680..20c8abddbb1 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -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, + }), ); }, }, diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index f64e1f50395..e21b40a537c 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -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(" { 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: diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 2d482df1150..26c7c430a56 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -96,6 +96,7 @@ export type GetReplyOptions = { status?: string; summary?: string; progressText?: string; + meta?: string; approvalId?: string; approvalSlug?: string; }) => Promise | void; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index c865aa1a1a9..73d3eeb8de0 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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), }); diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index da4cf534c44..b83dd8c1ec0 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -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 () => { diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index bbb80b4d25e..e5209c87b84 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -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; + } + | { + 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 | 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): 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; 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"); }