mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(channels): unify progress draft line formatting
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -96,6 +96,7 @@ export type GetReplyOptions = {
|
||||
status?: string;
|
||||
summary?: string;
|
||||
progressText?: string;
|
||||
meta?: string;
|
||||
approvalId?: string;
|
||||
approvalSlug?: string;
|
||||
}) => Promise<void> | void;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user