fix(channels): delay progress drafts until work is visible

This commit is contained in:
Peter Steinberger
2026-05-04 00:18:12 +01:00
parent 88b983a713
commit 392897304c
16 changed files with 596 additions and 219 deletions

View File

@@ -12,9 +12,9 @@ Progress drafts make long-running agent turns feel alive in chat without turning
the conversation into a stack of temporary status replies.
When progress drafts are enabled, OpenClaw creates one visible work-in-progress
message, updates it while the agent reads, plans, calls tools, or waits for
approval, and then turns that draft into the final answer when the channel can
do that safely.
message only after the turn proves it is doing real work, updates it while the
agent reads, plans, calls tools, or waits for approval, and then turns that draft
into the final answer when the channel can do that safely.
```text
Shelling...
@@ -42,9 +42,10 @@ Enable progress drafts per channel with `streaming.mode: "progress"`:
}
```
That is usually enough. OpenClaw will pick an automatic one-word label, add
compact progress lines while useful work happens, and suppress duplicate
standalone progress chatter for that turn.
That is usually enough. OpenClaw will pick an automatic one-word label, wait
until work lasts at least five seconds or emits a second work event, add compact
progress lines while useful work happens, and suppress duplicate standalone
progress chatter for that turn.
## What Users See
@@ -55,10 +56,12 @@ A progress draft has two parts:
| Label | A short title such as `Thinking...` or `Shelling...`. |
| Progress lines | Compact run updates such as tool calls, task steps, or approvals. |
The label appears immediately when the agent starts replying. Progress lines are
added only when the agent emits useful work updates. 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.
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
OpenClaw sends the final answer normally and cleans up or stops updating the
draft according to the channel's transport.
## Choose A Mode

View File

@@ -1,6 +1,8 @@
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import {
createChannelProgressDraftGate,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingPreviewToolProgress,
@@ -70,7 +72,6 @@ export function createDiscordDraftPreviewController(params: {
let hasStreamedMessage = false;
let finalizedViaPreviewMessage = false;
let finalDeliveryHandled = false;
let progressDraftStarted = false;
const previewToolProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
const suppressDefaultToolProgressMessages =
@@ -83,6 +84,32 @@ export function createDiscordDraftPreviewController(params: {
let previewToolProgressLines: string[] = [];
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
const renderProgressDraft = async (options?: { flush?: boolean }) => {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
if (!previewText || previewText === lastPartialText) {
return;
}
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
if (options?.flush) {
await draftStream.flush();
}
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: () => renderProgressDraft({ flush: true }),
});
const resetProgressState = () => {
lastPartialText = "";
draftText = "";
@@ -106,6 +133,9 @@ export function createDiscordDraftPreviewController(params: {
get isProgressMode() {
return discordStreamMode === "progress";
},
get hasProgressDraftStarted() {
return progressDraftGate.hasStarted;
},
get finalizedViaPreviewMessage() {
return finalizedViaPreviewMessage;
},
@@ -120,50 +150,55 @@ export function createDiscordDraftPreviewController(params: {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
if (progressDraftStarted) {
return;
}
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: [],
seed: progressSeed,
});
if (!previewText || previewText === lastPartialText) {
return;
}
progressDraftStarted = true;
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
await draftStream.flush();
await progressDraftGate.startNow();
},
pushToolProgress(line?: string) {
if (!draftStream || !previewToolProgressEnabled || previewToolProgressSuppressed) {
async pushToolProgress(line?: string, options?: { toolName?: string }) {
if (!draftStream) {
return;
}
if (
options?.toolName !== undefined &&
!isChannelProgressDraftWorkToolName(options.toolName)
) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (!normalized) {
if (discordStreamMode !== "progress") {
if (!previewToolProgressEnabled || previewToolProgressSuppressed || !normalized) {
return;
}
const previous = previewToolProgressLines.at(-1);
if (previous === normalized) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
return;
}
const previous = previewToolProgressLines.at(-1);
if (previous === normalized) {
return;
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
const previous = previewToolProgressLines.at(-1);
if (previous !== normalized) {
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
}
}
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (alreadyStarted && progressDraftGate.hasStarted) {
await renderProgressDraft();
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
lastPartialText = previewText;
draftText = previewText;
hasStreamedMessage = true;
draftChunker?.reset();
draftStream.update(previewText);
},
resolvePreviewFinalText(text?: string) {
if (typeof text !== "string") {
@@ -281,6 +316,7 @@ export function createDiscordDraftPreviewController(params: {
},
async cleanup() {
try {
progressDraftGate.cancel();
if (!finalDeliveryHandled) {
await draftStream?.discardPending();
}

View File

@@ -1452,6 +1452,7 @@ describe("processDiscordMessage draft streaming", () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReplyStart?.();
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
return createNoQueuedDispatchResult();
});
@@ -1477,7 +1478,7 @@ describe("processDiscordMessage draft streaming", () => {
});
});
it("starts Discord progress drafts when accepted turns dispatch", async () => {
it("does not start Discord progress drafts for text-only accepted turns", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async () => createNoQueuedDispatchResult());
@@ -1495,17 +1496,17 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledTimes(1);
expect(draftStream.update).toHaveBeenCalledWith("Shelling");
expect(draftStream.flush).toHaveBeenCalledTimes(1);
expect(draftStream.update).not.toHaveBeenCalled();
expect(draftStream.flush).not.toHaveBeenCalled();
});
it("keeps Discord progress drafts instead of delivering text-only interim blocks", async () => {
it("keeps Discord progress drafts instead of delivering text-only interim blocks after work expands", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendBlockReply({ text: "on it" });
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
await params?.dispatcher.sendFinalReply({ text: "done" });
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 1 } };
});
@@ -1523,8 +1524,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("Shelling");
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: exec");
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: exec\n• exec done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(editMessageDiscord).toHaveBeenCalledWith(
"c1",
@@ -1557,7 +1557,6 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: first");
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: first\n• tool: second");
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
});

View File

@@ -436,7 +436,11 @@ export async function processDiscordMessage(
return;
}
}
if (draftStream && isFinal) {
if (
draftStream &&
isFinal &&
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted)
) {
draftPreview.markFinalDeliveryHandled();
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
@@ -571,7 +575,6 @@ export async function processDiscordMessage(
}
await replyPipeline.typingCallbacks?.onReplyStart();
await statusReactions.setThinking();
await draftPreview.startProgressDraft();
},
});
@@ -625,7 +628,6 @@ export async function processDiscordMessage(
},
onPreDispatchFailure: settleDispatchBeforeStart,
runDispatch: async () => {
await draftPreview.startProgressDraft();
return await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
@@ -662,12 +664,13 @@ export async function processDiscordMessage(
}
await maybeBindStatusReactionsToToolReaction(payload);
await statusReactions.setTool(payload.name);
draftPreview.pushToolProgress(
await draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
{ toolName: payload.name },
);
},
onItemEvent: async (payload) => {
draftPreview.pushToolProgress(
await draftPreview.pushToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
@@ -675,7 +678,7 @@ export async function processDiscordMessage(
if (payload.phase !== "update") {
return;
}
draftPreview.pushToolProgress(
await draftPreview.pushToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
);
},
@@ -683,7 +686,7 @@ export async function processDiscordMessage(
if (payload.phase !== "requested") {
return;
}
draftPreview.pushToolProgress(
await draftPreview.pushToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
@@ -691,7 +694,7 @@ export async function processDiscordMessage(
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(
await draftPreview.pushToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
@@ -701,7 +704,7 @@ export async function processDiscordMessage(
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(
await draftPreview.pushToolProgress(
payload.summary ?? payload.title ?? "patch applied",
);
},

View File

@@ -2745,14 +2745,7 @@ describe("matrix monitor handler draft streaming", () => {
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
await vi.waitFor(() => {
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Pearling\n- `second`",
expect.anything(),
);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Pearling\n- `second`");
await finish();
});

View File

@@ -1,5 +1,7 @@
import {
createChannelProgressDraftGate,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
} from "openclaw/plugin-sdk/channel-streaming";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
@@ -1498,21 +1500,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
// Set after the first final payload consumes or discards the draft event
// so subsequent finals go through normal delivery.
const pushPreviewToolProgress = (line?: string) => {
if (!draftStream || !shouldStreamPreviewToolProgress || previewToolProgressSuppressed) {
const renderProgressDraft = () => {
if (!draftStream || !progressDraftStreaming) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (!normalized) {
return;
}
const previous = previewToolProgressLines.at(-1);
if (previous === normalized) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(progressConfigEntry),
);
draftStream.update(
formatChannelProgressDraftText({
entry: progressConfigEntry,
@@ -1523,6 +1514,57 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}),
);
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: renderProgressDraft,
});
const pushPreviewToolProgress = async (line?: string, options?: { toolName?: string }) => {
if (!draftStream) {
return;
}
if (
options?.toolName !== undefined &&
!isChannelProgressDraftWorkToolName(options.toolName)
) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (!progressDraftStreaming) {
if (!shouldStreamPreviewToolProgress || previewToolProgressSuppressed || !normalized) {
return;
}
const previous = previewToolProgressLines.at(-1);
if (previous === normalized) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(progressConfigEntry),
);
draftStream.update(
formatChannelProgressDraftText({
entry: progressConfigEntry,
lines: previewToolProgressLines,
seed: progressSeed,
formatLine: formatMatrixToolProgressMarkdownCode,
bullet: "-",
}),
);
return;
}
if (shouldStreamPreviewToolProgress && !previewToolProgressSuppressed && normalized) {
const previous = previewToolProgressLines.at(-1);
if (previous !== normalized) {
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(progressConfigEntry),
);
}
}
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (alreadyStarted && progressDraftGate.hasStarted) {
renderProgressDraft();
}
};
const suppressPreviewToolProgressForAnswerText = (text: string | undefined) => {
if (!text?.trim()) {
@@ -1551,10 +1593,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
...options,
onToolStart: async (payload) => {
const toolName = payload.name?.trim();
pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running");
await pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running", {
toolName,
});
},
onItemEvent: async (payload) => {
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
@@ -1562,13 +1606,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (payload.phase !== "update") {
return;
}
pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
await pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
@@ -1576,13 +1620,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(formatMatrixCommandOutputToolProgress(payload));
await pushPreviewToolProgress(formatMatrixCommandOutputToolProgress(payload));
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
await pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
},
};
};
@@ -1958,20 +2002,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
// for the current assistant block, while block deliveries
// finalize completed blocks into their own preserved events.
disableBlockStreaming: !blockStreamingEnabled,
onReplyStart:
draftStream && progressDraftStreaming
? () => {
draftStream.update(
formatChannelProgressDraftText({
entry: progressConfigEntry,
lines: [],
seed: progressSeed,
formatLine: formatMatrixToolProgressMarkdownCode,
bullet: "-",
}),
);
}
: undefined,
onPartialReply: draftStream
? (payload) => {
if (progressDraftStreaming) {
@@ -2004,6 +2034,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
},
});
} finally {
progressDraftGate.cancel();
markRunComplete();
}
},

View File

@@ -166,11 +166,13 @@ describe("createMSTeamsReplyDispatcher", () => {
lastCreatedDispatcher.replyOptions.onPartialReply?.({ text });
}
it("sends an informative status update on reply start for personal chats", async () => {
createDispatcher("personal");
it("sends an informative status update once work expands in personal chats", async () => {
const dispatcher = createDispatcher("personal", { streaming: { mode: "progress" } });
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
await options.onReplyStart?.();
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
await dispatcher.replyOptions.onItemEvent?.({ progressText: "done" });
expect(streamInstances).toHaveLength(1);
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledTimes(1);
@@ -194,9 +196,7 @@ describe("createMSTeamsReplyDispatcher", () => {
await options.onReplyStart?.();
// Even though we still send the informative update, the opt-out
// disables the typing keepalive.
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledTimes(1);
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
expect(typingCallbacks.onReplyStart).not.toHaveBeenCalled();
});
@@ -314,14 +314,16 @@ describe("createMSTeamsReplyDispatcher", () => {
expect(typingCallbacks.onReplyStart).not.toHaveBeenCalled();
});
it("only sends the informative status update once", async () => {
createDispatcher("personal");
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
it("delays the informative status update until work expands", async () => {
const dispatcher = createDispatcher("personal", { streaming: { mode: "progress" } });
await options.onReplyStart?.();
await options.onReplyStart?.();
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledTimes(1);
await dispatcher.replyOptions.onItemEvent?.({ progressText: "done" });
await dispatcher.replyOptions.onPatchSummary?.({ phase: "end", summary: "patched" });
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledTimes(2);
});
it("forwards partial replies into the Teams stream", async () => {
@@ -344,9 +346,12 @@ describe("createMSTeamsReplyDispatcher", () => {
expect(dispatcher.replyOptions.suppressDefaultToolProgressMessages).toBe(true);
await dispatcher.replyOptions.onToolStart?.({ name: "web_search" });
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledWith(
"Working\n- tool: web_search",
"Working\n- tool: web_search\n- tool: exec",
);
});
@@ -361,8 +366,14 @@ describe("createMSTeamsReplyDispatcher", () => {
});
expect(dispatcher.replyOptions.suppressDefaultToolProgressMessages).toBe(true);
expect(dispatcher.replyOptions.onToolStart).toBeUndefined();
await dispatcher.replyOptions.onToolStart?.({ name: "web_search" });
expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled();
await dispatcher.replyOptions.onToolStart?.({ name: "exec" });
expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledWith(
expect.stringMatching(/^[^\n]+\.\.\.$/),
);
});
it("does not create a stream for channel conversations", async () => {

View File

@@ -343,6 +343,32 @@ export function createMSTeamsReplyDispatcher(params: {
? {
onPartialReply: (payload: { text?: string }) =>
streamController.onPartialReply(payload),
onToolStart: async (payload: { name?: string }) => {
await streamController.noteProgressWork({ toolName: payload.name });
},
onItemEvent: async () => {
await streamController.noteProgressWork();
},
onPlanUpdate: async (payload: { phase?: string }) => {
if (payload.phase === "update") {
await streamController.noteProgressWork();
}
},
onApprovalEvent: async (payload: { phase?: string }) => {
if (payload.phase === "requested") {
await streamController.noteProgressWork();
}
},
onCommandOutput: async (payload: { phase?: string }) => {
if (payload.phase === "end") {
await streamController.noteProgressWork();
}
},
onPatchSummary: async (payload: { phase?: string }) => {
if (payload.phase === "end") {
await streamController.noteProgressWork();
}
},
}
: {}),
...(streamController.shouldSuppressDefaultToolProgressMessages()
@@ -353,6 +379,7 @@ export function createMSTeamsReplyDispatcher(params: {
onToolStart: async (payload: { name?: string; phase?: string }) => {
await streamController.pushProgressLine(
payload.name ? `tool: ${payload.name}` : (payload.phase ?? "tool running"),
{ toolName: payload.name },
);
},
onItemEvent: async (payload: {

View File

@@ -213,7 +213,8 @@ describe("createTeamsReplyStreamController", () => {
log: { debug: vi.fn() } as never,
msteamsConfig: { streaming: { mode: "progress" } } as never,
});
await ctrl.onReplyStart();
await ctrl.noteProgressWork({ toolName: "exec" });
await ctrl.noteProgressWork();
const fullText = "x".repeat(4200);
const result = await ctrl.preparePayload({ text: fullText });

View File

@@ -1,5 +1,7 @@
import {
createChannelProgressDraftGate,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelPreviewStreamMode,
resolveChannelProgressDraftMaxLines,
resolveChannelProgressDraftLabel,
@@ -61,32 +63,67 @@ export function createTeamsReplyStreamController(params: {
let streamReceivedTokens = false;
let informativeUpdateSent = false;
let progressLines: string[] = [];
let lastInformativeText = "";
let pendingFinalize: Promise<void> | undefined;
const pushProgressLine = async (line?: string): Promise<void> => {
if (!stream || !shouldStreamPreviewToolProgress) {
const renderInformativeUpdate = async () => {
if (!stream) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (!normalized) {
const informativeText = formatChannelProgressDraftText({
entry: params.msteamsConfig,
lines: shouldStreamPreviewToolProgress ? progressLines : [],
seed: params.progressSeed,
bullet: "-",
});
if (!informativeText || informativeText === lastInformativeText) {
return;
}
const previous = progressLines.at(-1);
if (previous === normalized) {
return;
}
progressLines = [...progressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(params.msteamsConfig),
);
lastInformativeText = informativeText;
informativeUpdateSent = true;
await stream.sendInformativeUpdate(
formatChannelProgressDraftText({
entry: params.msteamsConfig,
lines: progressLines,
seed: params.progressSeed,
bullet: "-",
}),
);
await stream.sendInformativeUpdate(informativeText);
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: renderInformativeUpdate,
});
const noteProgressWork = async (options?: { toolName?: string }): Promise<void> => {
if (!stream || streamMode !== "progress") {
return;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return;
}
const hadStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (hadStarted && progressDraftGate.hasStarted) {
await renderInformativeUpdate();
}
};
const pushProgressLine = async (
line?: string,
options?: { toolName?: string },
): Promise<void> => {
if (!stream || streamMode !== "progress") {
return;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return;
}
if (shouldStreamPreviewToolProgress) {
const normalized = line?.replace(/\s+/g, " ").trim();
if (normalized) {
const previous = progressLines.at(-1);
if (previous !== normalized) {
progressLines = [...progressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(params.msteamsConfig),
);
}
}
}
await noteProgressWork();
};
const fallbackAfterStreamFailure = (
@@ -109,19 +146,11 @@ export function createTeamsReplyStreamController(params: {
return {
async onReplyStart(): Promise<void> {
if (!stream || informativeUpdateSent) {
return;
}
const informativeText = pickInformativeStatusText({
config: params.msteamsConfig,
seed: params.progressSeed,
random: params.random,
});
if (!informativeText) {
return;
}
informativeUpdateSent = true;
await stream.sendInformativeUpdate(informativeText);
return;
},
async noteProgressWork(options?: { toolName?: string }): Promise<void> {
await noteProgressWork(options);
},
onPartialReply(payload: { text?: string }): void {
@@ -135,8 +164,8 @@ export function createTeamsReplyStreamController(params: {
stream.update(payload.text);
},
async pushProgressLine(line?: string): Promise<void> {
await pushProgressLine(line);
async pushProgressLine(line?: string, options?: { toolName?: string }): Promise<void> {
await pushProgressLine(line, options);
},
shouldSuppressDefaultToolProgressMessages(): boolean {
@@ -191,6 +220,7 @@ export function createTeamsReplyStreamController(params: {
},
async finalize(): Promise<void> {
progressDraftGate.cancel();
await pendingFinalize;
await stream?.finalize();
},

View File

@@ -231,6 +231,30 @@ vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
}));
vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({
createChannelProgressDraftGate: (params: { onStart: () => void | Promise<void> }) => {
let started = false;
let workEvents = 0;
return {
get hasStarted() {
return started;
},
async noteWork() {
workEvents += 1;
if (!started && workEvents > 1) {
started = true;
await params.onStart();
}
return started;
},
async startNow() {
if (!started) {
started = true;
await params.onStart();
}
},
cancel() {},
};
},
formatChannelProgressDraftText: (params: {
entry?: { streaming?: { progress?: { label?: string; maxLines?: number } } };
lines: string[];
@@ -269,6 +293,8 @@ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({
}
return options?.previewToolProgressEnabled ?? true;
},
isChannelProgressDraftWorkToolName: (name?: string) =>
Boolean(name && !["message", "react", "reaction"].includes(name.toLowerCase())),
}));
vi.mock("openclaw/plugin-sdk/outbound-runtime", () => ({

View File

@@ -13,7 +13,9 @@ import {
resolveChannelSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
createChannelProgressDraftGate,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingNativeTransport,
@@ -885,22 +887,10 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
let statusUpdateCount = 0;
const progressSeed = `${account.accountId}:${message.channel}`;
const pushPreviewToolProgress = (line?: string) => {
if (!draftStream || !previewToolProgressEnabled || previewToolProgressSuppressed) {
const renderProgressDraft = () => {
if (!draftStream || streamMode !== "status_final") {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (!normalized) {
return;
}
const escaped = escapeSlackMrkdwn(normalized);
const previous = previewToolProgressLines.at(-1);
if (previous === escaped) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, escaped].slice(
-resolveChannelProgressDraftMaxLines(account.config),
);
draftStream.update(
formatChannelProgressDraftText({
entry: account.config,
@@ -910,6 +900,55 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
);
hasStreamedMessage = true;
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: renderProgressDraft,
});
const pushPreviewToolProgress = async (line?: string, options?: { toolName?: string }) => {
if (!draftStream) {
return;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (streamMode !== "status_final") {
if (!previewToolProgressEnabled || previewToolProgressSuppressed || !normalized) {
return;
}
const escaped = escapeSlackMrkdwn(normalized);
const previous = previewToolProgressLines.at(-1);
if (previous === escaped) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, escaped].slice(
-resolveChannelProgressDraftMaxLines(account.config),
);
draftStream.update(
formatChannelProgressDraftText({
entry: account.config,
lines: previewToolProgressLines,
seed: progressSeed,
}),
);
hasStreamedMessage = true;
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
const escaped = escapeSlackMrkdwn(normalized);
const previous = previewToolProgressLines.at(-1);
if (previous !== escaped) {
previewToolProgressLines = [...previewToolProgressLines, escaped].slice(
-resolveChannelProgressDraftMaxLines(account.config),
);
}
}
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (alreadyStarted && progressDraftGate.hasStarted) {
renderProgressDraft();
}
};
const updateDraftFromPartial = (text?: string) => {
const trimmed = text?.trimEnd();
@@ -936,6 +975,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
}
if (streamMode === "status_final") {
if (!progressDraftGate.hasStarted) {
return;
}
statusUpdateCount += 1;
if (statusUpdateCount > 1 && statusUpdateCount % 4 !== 0) {
return;
@@ -1036,10 +1078,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (statusReactionsEnabled) {
await statusReactions.setTool(payload.name);
}
pushPreviewToolProgress(payload.name ? `tool: ${payload.name}` : "tool running");
await pushPreviewToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
{ toolName: payload.name },
);
},
onItemEvent: async (payload) => {
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
@@ -1047,13 +1092,15 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (payload.phase !== "update") {
return;
}
pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
await pushPreviewToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
@@ -1061,7 +1108,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
@@ -1071,7 +1118,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
await pushPreviewToolProgress(
payload.summary ?? payload.title ?? "patch applied",
);
},
},
}),
@@ -1087,6 +1136,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
} catch (err) {
dispatchError = err;
} finally {
progressDraftGate.cancel();
await draftStream?.discardPending();
if (!dispatchSettledBeforeStart) {
markDispatchIdle();

View File

@@ -752,7 +752,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.update).toHaveBeenCalledWith("HelloWorld");
});
it("reuses the Telegram progress draft for the first assistant final", async () => {
it("does not create a Telegram progress draft for a text-only final", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -770,10 +770,14 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(draftStream.update).toHaveBeenCalledWith("Shelling");
expect(draftStream.update).not.toHaveBeenCalled();
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
expect(editMessageTelegram).toHaveBeenCalledWith(123, 2001, "Final answer", expect.any(Object));
expect(draftStream.clear).not.toHaveBeenCalled();
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Final answer" })],
}),
);
});
it("keeps the Telegram progress draft across post-tool assistant boundaries", async () => {
@@ -784,6 +788,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onItemEvent?.({ progressText: "exec ls ~/Desktop" });
await replyOptions?.onItemEvent?.({ progressText: "tests passed" });
await replyOptions?.onAssistantMessageStart?.();
await dispatcherOptions.deliver({ text: "Final after tool" }, { kind: "final" });
return { queuedFinal: true };
@@ -796,9 +801,8 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(draftStream.update).toHaveBeenCalledWith("Shelling");
expect(draftStream.update).toHaveBeenCalledWith(
expect.stringMatching(/^Shelling\n• `exec ls ~\/Desktop`$/),
expect.stringMatching(/^Shelling\n• `exec ls ~\/Desktop`\n• `tests passed`$/),
);
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
expect(draftStream.materialize).not.toHaveBeenCalled();

View File

@@ -7,7 +7,9 @@ import {
} from "openclaw/plugin-sdk/channel-feedback";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
createChannelProgressDraftGate,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingPreviewToolProgress,
@@ -476,30 +478,72 @@ export const dispatchTelegramMessage = async ({
Boolean(answerLane.stream) && resolveChannelStreamingPreviewToolProgress(telegramCfg);
let previewToolProgressSuppressed = false;
let previewToolProgressLines: string[] = [];
const pushPreviewToolProgress = (line?: string) => {
if (!previewToolProgressEnabled || previewToolProgressSuppressed || !answerLane.stream) {
const renderProgressDraft = async (options?: { flush?: boolean }) => {
if (!answerLane.stream || streamMode !== "progress") {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (!normalized) {
return;
}
const previous = previewToolProgressLines.at(-1);
if (previous === normalized) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(telegramCfg),
);
const previewText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: previewToolProgressLines,
seed: progressSeed,
formatLine: formatProgressAsMarkdownCode,
});
if (!previewText || previewText === answerLane.lastPartialText) {
return;
}
answerLane.lastPartialText = previewText;
answerLane.hasStreamedMessage = true;
answerLane.stream.update(previewText);
if (options?.flush) {
await answerLane.stream.flush();
}
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: () => renderProgressDraft({ flush: true }),
});
const pushPreviewToolProgress = async (line?: string, options?: { toolName?: string }) => {
if (!answerLane.stream) {
return;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return;
}
const normalized = line?.replace(/\s+/g, " ").trim();
if (streamMode !== "progress") {
if (!previewToolProgressEnabled || previewToolProgressSuppressed || !normalized) {
return;
}
const previous = previewToolProgressLines.at(-1);
if (previous === normalized) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(telegramCfg),
);
const previewText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: previewToolProgressLines,
seed: progressSeed,
formatLine: formatProgressAsMarkdownCode,
});
answerLane.lastPartialText = previewText;
answerLane.hasStreamedMessage = true;
answerLane.stream.update(previewText);
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
const previous = previewToolProgressLines.at(-1);
if (previous !== normalized) {
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
-resolveChannelProgressDraftMaxLines(telegramCfg),
);
}
}
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.noteWork();
if (alreadyStarted && progressDraftGate.hasStarted) {
await renderProgressDraft();
}
};
let splitReasoningOnNextStream = false;
let skipNextAnswerMessageStartRotation = false;
@@ -1067,25 +1111,6 @@ export const dispatchTelegramMessage = async ({
replyOptions: {
skillFilter,
disableBlockStreaming,
onReplyStart:
answerLane.stream && streamMode === "progress"
? () =>
enqueueDraftLaneEvent(async () => {
const previewText = formatChannelProgressDraftText({
entry: telegramCfg,
lines: [],
seed: progressSeed,
formatLine: formatProgressAsMarkdownCode,
});
if (!previewText || previewText === answerLane.lastPartialText) {
return;
}
answerLane.lastPartialText = previewText;
answerLane.hasStreamedMessage = true;
answerLane.stream?.update(previewText);
await answerLane.stream?.flush();
})
: undefined,
onPartialReply:
answerLane.stream || reasoningLane.stream
? (payload) =>
@@ -1147,10 +1172,12 @@ export const dispatchTelegramMessage = async ({
if (statusReactionController && toolName) {
await statusReactionController.setTool(toolName);
}
pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running");
await pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running", {
toolName,
});
},
onItemEvent: async (payload) => {
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
@@ -1158,7 +1185,7 @@ export const dispatchTelegramMessage = async ({
if (payload.phase !== "update") {
return;
}
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
);
},
@@ -1166,7 +1193,7 @@ export const dispatchTelegramMessage = async ({
if (payload.phase !== "requested") {
return;
}
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
@@ -1174,7 +1201,7 @@ export const dispatchTelegramMessage = async ({
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(
await pushPreviewToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
@@ -1184,7 +1211,9 @@ export const dispatchTelegramMessage = async ({
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
await pushPreviewToolProgress(
payload.summary ?? payload.title ?? "patch applied",
);
},
onCompactionStart:
statusReactionController || answerLane.stream
@@ -1223,6 +1252,7 @@ export const dispatchTelegramMessage = async ({
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
} finally {
await draftLaneEventQueue;
progressDraftGate.cancel();
if (isDispatchSuperseded()) {
if (answerLane.hasStreamedMessage || typeof answerLane.stream?.messageId() === "number") {
retainPreviewOnCleanupByLane.answer = true;

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createChannelProgressDraftGate,
DEFAULT_PROGRESS_DRAFT_LABELS,
formatChannelProgressDraftText,
getChannelStreamingConfigObject,
isChannelProgressDraftWorkToolName,
resolveChannelPreviewStreamMode,
resolveChannelProgressDraftLabel,
resolveChannelProgressDraftMaxLines,
@@ -16,6 +18,10 @@ import {
} from "./channel-streaming.js";
describe("channel-streaming", () => {
afterEach(() => {
vi.useRealTimers();
});
it("reads canonical nested streaming config first", () => {
const entry = {
streaming: {
@@ -172,4 +178,40 @@ describe("channel-streaming", () => {
}),
).toBe("Shelling\n• `patch applied`\n• `tests done`");
});
it("starts progress drafts after five seconds or a second work event", async () => {
vi.useFakeTimers();
const onStart = vi.fn(async () => {});
const gate = createChannelProgressDraftGate({ onStart });
await expect(gate.noteWork()).resolves.toBe(false);
expect(onStart).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(4_999);
expect(onStart).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(onStart).toHaveBeenCalledTimes(1);
expect(gate.hasStarted).toBe(true);
});
it("starts progress drafts immediately on the second work event", async () => {
vi.useFakeTimers();
const onStart = vi.fn(async () => {});
const gate = createChannelProgressDraftGate({ onStart });
await gate.noteWork();
await expect(gate.noteWork()).resolves.toBe(true);
expect(onStart).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5_000);
expect(onStart).toHaveBeenCalledTimes(1);
});
it("ignores message-like tools for progress draft work", () => {
expect(isChannelProgressDraftWorkToolName("message")).toBe(false);
expect(isChannelProgressDraftWorkToolName("react")).toBe(false);
expect(isChannelProgressDraftWorkToolName("web_search")).toBe(true);
expect(isChannelProgressDraftWorkToolName("exec")).toBe(true);
});
});

View File

@@ -107,6 +107,97 @@ export const DEFAULT_PROGRESS_DRAFT_LABELS = [
"Surfacing...",
] as const;
export const DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS = 5_000;
const NON_WORK_PROGRESS_TOOL_NAMES = new Set([
"message",
"messages",
"reply",
"send",
"reaction",
"react",
"typing",
]);
export function isChannelProgressDraftWorkToolName(name: string | null | undefined): boolean {
const normalized = normalizeOptionalLowercaseString(name);
return Boolean(normalized && !NON_WORK_PROGRESS_TOOL_NAMES.has(normalized));
}
export function createChannelProgressDraftGate(params: {
onStart: () => void | Promise<void>;
initialDelayMs?: number;
setTimeoutFn?: typeof setTimeout;
clearTimeoutFn?: typeof clearTimeout;
}) {
const initialDelayMs = params.initialDelayMs ?? DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS;
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
let started = false;
let disposed = false;
let workEvents = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let startPromise: Promise<void> | undefined;
const clearTimer = () => {
if (timer) {
clearTimeoutFn(timer);
timer = undefined;
}
};
const start = (): Promise<void> => {
if (disposed || started) {
return startPromise ?? Promise.resolve();
}
started = true;
clearTimer();
startPromise = Promise.resolve().then(params.onStart);
return startPromise;
};
const schedule = () => {
if (timer || started || disposed || initialDelayMs < 0) {
return;
}
timer = setTimeoutFn(() => {
timer = undefined;
void start().catch(() => {});
}, initialDelayMs);
};
return {
get hasStarted() {
return started;
},
get workEvents() {
return workEvents;
},
async noteWork(): Promise<boolean> {
if (disposed) {
return false;
}
workEvents += 1;
if (started) {
return true;
}
if (workEvents > 1) {
await start();
return true;
}
schedule();
return false;
},
async startNow(): Promise<void> {
await start();
},
cancel(): void {
disposed = true;
clearTimer();
},
};
}
export function getChannelStreamingConfigObject(
entry: StreamingCompatEntry | null | undefined,
): ChannelStreamingConfig | undefined {