Skip to content

Commit 3928973

Browse files
committed
fix(channels): delay progress drafts until work is visible
1 parent 88b983a commit 3928973

16 files changed

Lines changed: 594 additions & 217 deletions

docs/concepts/progress-drafts.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ Progress drafts make long-running agent turns feel alive in chat without turning
1212
the conversation into a stack of temporary status replies.
1313

1414
When progress drafts are enabled, OpenClaw creates one visible work-in-progress
15-
message, updates it while the agent reads, plans, calls tools, or waits for
16-
approval, and then turns that draft into the final answer when the channel can
17-
do that safely.
15+
message only after the turn proves it is doing real work, updates it while the
16+
agent reads, plans, calls tools, or waits for approval, and then turns that draft
17+
into the final answer when the channel can do that safely.
1818

1919
```text
2020
Shelling...
@@ -42,9 +42,10 @@ Enable progress drafts per channel with `streaming.mode: "progress"`:
4242
}
4343
```
4444

45-
That is usually enough. OpenClaw will pick an automatic one-word label, add
46-
compact progress lines while useful work happens, and suppress duplicate
47-
standalone progress chatter for that turn.
45+
That is usually enough. OpenClaw will pick an automatic one-word label, wait
46+
until work lasts at least five seconds or emits a second work event, add compact
47+
progress lines while useful work happens, and suppress duplicate standalone
48+
progress chatter for that turn.
4849

4950
## What Users See
5051

@@ -55,10 +56,12 @@ A progress draft has two parts:
5556
| Label | A short title such as `Thinking...` or `Shelling...`. |
5657
| Progress lines | Compact run updates such as tool calls, task steps, or approvals. |
5758

58-
The label appears immediately when the agent starts replying. Progress lines are
59-
added only when the agent emits useful work updates. The final answer replaces
60-
the draft when possible; otherwise OpenClaw sends the final answer normally and
61-
cleans up or stops updating the draft according to the channel's transport.
59+
The label appears after the agent starts meaningful work and either remains busy
60+
for five seconds or emits a second work event. Plain text-only replies do not
61+
show a progress draft. Progress lines are added only when the agent emits useful
62+
work updates. The final answer replaces the draft when possible; otherwise
63+
OpenClaw sends the final answer normally and cleans up or stops updating the
64+
draft according to the channel's transport.
6265

6366
## Choose A Mode
6467

extensions/discord/src/monitor/message-handler.draft-preview.ts

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
22
import {
3+
createChannelProgressDraftGate,
34
formatChannelProgressDraftText,
5+
isChannelProgressDraftWorkToolName,
46
resolveChannelProgressDraftMaxLines,
57
resolveChannelStreamingBlockEnabled,
68
resolveChannelStreamingPreviewToolProgress,
@@ -70,7 +72,6 @@ export function createDiscordDraftPreviewController(params: {
7072
let hasStreamedMessage = false;
7173
let finalizedViaPreviewMessage = false;
7274
let finalDeliveryHandled = false;
73-
let progressDraftStarted = false;
7475
const previewToolProgressEnabled =
7576
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
7677
const suppressDefaultToolProgressMessages =
@@ -83,6 +84,32 @@ export function createDiscordDraftPreviewController(params: {
8384
let previewToolProgressLines: string[] = [];
8485
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
8586

87+
const renderProgressDraft = async (options?: { flush?: boolean }) => {
88+
if (!draftStream || discordStreamMode !== "progress") {
89+
return;
90+
}
91+
const previewText = formatChannelProgressDraftText({
92+
entry: params.discordConfig,
93+
lines: previewToolProgressLines,
94+
seed: progressSeed,
95+
});
96+
if (!previewText || previewText === lastPartialText) {
97+
return;
98+
}
99+
lastPartialText = previewText;
100+
draftText = previewText;
101+
hasStreamedMessage = true;
102+
draftChunker?.reset();
103+
draftStream.update(previewText);
104+
if (options?.flush) {
105+
await draftStream.flush();
106+
}
107+
};
108+
109+
const progressDraftGate = createChannelProgressDraftGate({
110+
onStart: () => renderProgressDraft({ flush: true }),
111+
});
112+
86113
const resetProgressState = () => {
87114
lastPartialText = "";
88115
draftText = "";
@@ -106,6 +133,9 @@ export function createDiscordDraftPreviewController(params: {
106133
get isProgressMode() {
107134
return discordStreamMode === "progress";
108135
},
136+
get hasProgressDraftStarted() {
137+
return progressDraftGate.hasStarted;
138+
},
109139
get finalizedViaPreviewMessage() {
110140
return finalizedViaPreviewMessage;
111141
},
@@ -120,50 +150,55 @@ export function createDiscordDraftPreviewController(params: {
120150
if (!draftStream || discordStreamMode !== "progress") {
121151
return;
122152
}
123-
if (progressDraftStarted) {
124-
return;
125-
}
126-
const previewText = formatChannelProgressDraftText({
127-
entry: params.discordConfig,
128-
lines: [],
129-
seed: progressSeed,
130-
});
131-
if (!previewText || previewText === lastPartialText) {
153+
await progressDraftGate.startNow();
154+
},
155+
async pushToolProgress(line?: string, options?: { toolName?: string }) {
156+
if (!draftStream) {
132157
return;
133158
}
134-
progressDraftStarted = true;
135-
lastPartialText = previewText;
136-
draftText = previewText;
137-
hasStreamedMessage = true;
138-
draftChunker?.reset();
139-
draftStream.update(previewText);
140-
await draftStream.flush();
141-
},
142-
pushToolProgress(line?: string) {
143-
if (!draftStream || !previewToolProgressEnabled || previewToolProgressSuppressed) {
159+
if (
160+
options?.toolName !== undefined &&
161+
!isChannelProgressDraftWorkToolName(options.toolName)
162+
) {
144163
return;
145164
}
146165
const normalized = line?.replace(/\s+/g, " ").trim();
147-
if (!normalized) {
166+
if (discordStreamMode !== "progress") {
167+
if (!previewToolProgressEnabled || previewToolProgressSuppressed || !normalized) {
168+
return;
169+
}
170+
const previous = previewToolProgressLines.at(-1);
171+
if (previous === normalized) {
172+
return;
173+
}
174+
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
175+
-resolveChannelProgressDraftMaxLines(params.discordConfig),
176+
);
177+
const previewText = formatChannelProgressDraftText({
178+
entry: params.discordConfig,
179+
lines: previewToolProgressLines,
180+
seed: progressSeed,
181+
});
182+
lastPartialText = previewText;
183+
draftText = previewText;
184+
hasStreamedMessage = true;
185+
draftChunker?.reset();
186+
draftStream.update(previewText);
148187
return;
149188
}
150-
const previous = previewToolProgressLines.at(-1);
151-
if (previous === normalized) {
152-
return;
189+
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
190+
const previous = previewToolProgressLines.at(-1);
191+
if (previous !== normalized) {
192+
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
193+
-resolveChannelProgressDraftMaxLines(params.discordConfig),
194+
);
195+
}
196+
}
197+
const alreadyStarted = progressDraftGate.hasStarted;
198+
await progressDraftGate.noteWork();
199+
if (alreadyStarted && progressDraftGate.hasStarted) {
200+
await renderProgressDraft();
153201
}
154-
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
155-
-resolveChannelProgressDraftMaxLines(params.discordConfig),
156-
);
157-
const previewText = formatChannelProgressDraftText({
158-
entry: params.discordConfig,
159-
lines: previewToolProgressLines,
160-
seed: progressSeed,
161-
});
162-
lastPartialText = previewText;
163-
draftText = previewText;
164-
hasStreamedMessage = true;
165-
draftChunker?.reset();
166-
draftStream.update(previewText);
167202
},
168203
resolvePreviewFinalText(text?: string) {
169204
if (typeof text !== "string") {
@@ -281,6 +316,7 @@ export function createDiscordDraftPreviewController(params: {
281316
},
282317
async cleanup() {
283318
try {
319+
progressDraftGate.cancel();
284320
if (!finalDeliveryHandled) {
285321
await draftStream?.discardPending();
286322
}

extensions/discord/src/monitor/message-handler.process.test.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,7 @@ describe("processDiscordMessage draft streaming", () => {
14521452
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
14531453
await params?.replyOptions?.onReplyStart?.();
14541454
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
1455+
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
14551456
return createNoQueuedDispatchResult();
14561457
});
14571458

@@ -1477,7 +1478,7 @@ describe("processDiscordMessage draft streaming", () => {
14771478
});
14781479
});
14791480

1480-
it("starts Discord progress drafts when accepted turns dispatch", async () => {
1481+
it("does not start Discord progress drafts for text-only accepted turns", async () => {
14811482
const draftStream = createMockDraftStreamForTest();
14821483

14831484
dispatchInboundMessage.mockImplementationOnce(async () => createNoQueuedDispatchResult());
@@ -1495,17 +1496,17 @@ describe("processDiscordMessage draft streaming", () => {
14951496

14961497
await runProcessDiscordMessage(ctx);
14971498

1498-
expect(draftStream.update).toHaveBeenCalledTimes(1);
1499-
expect(draftStream.update).toHaveBeenCalledWith("Shelling");
1500-
expect(draftStream.flush).toHaveBeenCalledTimes(1);
1499+
expect(draftStream.update).not.toHaveBeenCalled();
1500+
expect(draftStream.flush).not.toHaveBeenCalled();
15011501
});
15021502

1503-
it("keeps Discord progress drafts instead of delivering text-only interim blocks", async () => {
1503+
it("keeps Discord progress drafts instead of delivering text-only interim blocks after work expands", async () => {
15041504
const draftStream = createMockDraftStreamForTest();
15051505

15061506
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
15071507
await params?.dispatcher.sendBlockReply({ text: "on it" });
15081508
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
1509+
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
15091510
await params?.dispatcher.sendFinalReply({ text: "done" });
15101511
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 1 } };
15111512
});
@@ -1523,8 +1524,7 @@ describe("processDiscordMessage draft streaming", () => {
15231524

15241525
await runProcessDiscordMessage(ctx);
15251526

1526-
expect(draftStream.update).toHaveBeenCalledWith("Shelling");
1527-
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: exec");
1527+
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: exec\n• exec done");
15281528
expect(deliverDiscordReply).not.toHaveBeenCalled();
15291529
expect(editMessageDiscord).toHaveBeenCalledWith(
15301530
"c1",
@@ -1557,7 +1557,6 @@ describe("processDiscordMessage draft streaming", () => {
15571557

15581558
await runProcessDiscordMessage(ctx);
15591559

1560-
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: first");
15611560
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n• tool: first\n• tool: second");
15621561
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
15631562
});

extensions/discord/src/monitor/message-handler.process.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,11 @@ export async function processDiscordMessage(
436436
return;
437437
}
438438
}
439-
if (draftStream && isFinal) {
439+
if (
440+
draftStream &&
441+
isFinal &&
442+
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted)
443+
) {
440444
draftPreview.markFinalDeliveryHandled();
441445
const reply = resolveSendableOutboundReplyParts(payload);
442446
const hasMedia = reply.hasMedia;
@@ -571,7 +575,6 @@ export async function processDiscordMessage(
571575
}
572576
await replyPipeline.typingCallbacks?.onReplyStart();
573577
await statusReactions.setThinking();
574-
await draftPreview.startProgressDraft();
575578
},
576579
});
577580

@@ -625,7 +628,6 @@ export async function processDiscordMessage(
625628
},
626629
onPreDispatchFailure: settleDispatchBeforeStart,
627630
runDispatch: async () => {
628-
await draftPreview.startProgressDraft();
629631
return await dispatchInboundMessage({
630632
ctx: ctxPayload,
631633
cfg,
@@ -662,36 +664,37 @@ export async function processDiscordMessage(
662664
}
663665
await maybeBindStatusReactionsToToolReaction(payload);
664666
await statusReactions.setTool(payload.name);
665-
draftPreview.pushToolProgress(
667+
await draftPreview.pushToolProgress(
666668
payload.name ? `tool: ${payload.name}` : "tool running",
669+
{ toolName: payload.name },
667670
);
668671
},
669672
onItemEvent: async (payload) => {
670-
draftPreview.pushToolProgress(
673+
await draftPreview.pushToolProgress(
671674
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
672675
);
673676
},
674677
onPlanUpdate: async (payload) => {
675678
if (payload.phase !== "update") {
676679
return;
677680
}
678-
draftPreview.pushToolProgress(
681+
await draftPreview.pushToolProgress(
679682
payload.explanation ?? payload.steps?.[0] ?? "planning",
680683
);
681684
},
682685
onApprovalEvent: async (payload) => {
683686
if (payload.phase !== "requested") {
684687
return;
685688
}
686-
draftPreview.pushToolProgress(
689+
await draftPreview.pushToolProgress(
687690
payload.command ? `approval: ${payload.command}` : "approval requested",
688691
);
689692
},
690693
onCommandOutput: async (payload) => {
691694
if (payload.phase !== "end") {
692695
return;
693696
}
694-
draftPreview.pushToolProgress(
697+
await draftPreview.pushToolProgress(
695698
payload.name
696699
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
697700
: payload.title,
@@ -701,7 +704,7 @@ export async function processDiscordMessage(
701704
if (payload.phase !== "end") {
702705
return;
703706
}
704-
draftPreview.pushToolProgress(
707+
await draftPreview.pushToolProgress(
705708
payload.summary ?? payload.title ?? "patch applied",
706709
);
707710
},

extensions/matrix/src/matrix/monitor/handler.test.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2745,14 +2745,7 @@ describe("matrix monitor handler draft streaming", () => {
27452745
await vi.waitFor(() => {
27462746
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
27472747
});
2748-
await vi.waitFor(() => {
2749-
expect(editMessageMatrixMock).toHaveBeenCalledWith(
2750-
"!room:example.org",
2751-
"$draft1",
2752-
"Pearling\n- `second`",
2753-
expect.anything(),
2754-
);
2755-
});
2748+
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Pearling\n- `second`");
27562749
await finish();
27572750
});
27582751

0 commit comments

Comments
 (0)