Skip to content

Commit 26d200c

Browse files
authored
feat(channels): narrated progress drafts + activity receipt on the final answer (#103463)
* feat(channels): narrated progress drafts + activity receipt on the final answer Progress mode replaces raw tool lines with short utility-model narration of what the agent is doing (streaming.progress.narration, default on, requires an explicit utilityModel). On Discord the final answer now carries the -# activity receipt and the working draft is deleted once the answer lands, so busy channels keep no orphaned tool log above the reply. Formatting verified remotely via Testbox oxfmt --check (hook bypassed: no node_modules in this worktree). * fix(channels): keep narration toggle independent of channel-default stream mode Discord resolves its own progress default, so the resolver must not re-derive mode with the generic partial fallback (narration was off for unset config). * fix(auto-reply): honor status-only command text in narration model input streaming.progress.commandText: "status" hides raw exec/bash text from the channel draft; narration input now mirrors that policy so the utility model never receives more command detail than the draft shows (Codex review P2). Formatting verified remotely via Testbox oxfmt --check. * fix(auto-reply): share the draft's command-tool set for narration and regen channel metadata Reuse isCommandToolName (exec|shell|bash) so narration's commandText policy matches the draft formatter exactly, and regenerate bundled channel config metadata for the new streaming.progress.narration key (Codex review round 2). Gates verified on Testbox: config:channels:check, oxfmt --check, 4 test shards. * fix(channels): clear stale narration when the narrator stops mid-turn An empty narration update now falls the draft back to raw tool lines, and the narrator emits that clear when it disables after consecutive failures or the per-turn cap, so drafts never pin stale status text (Codex review round 3). Gates verified on Testbox: oxfmt --check + 4 test shards. * chore(config): regen bundled channel metadata after rebase onto main * chore: CI fixups — lint nits, test harness types, docs map, SDK surface budget Two deliberate public SDK additions (resolveChannelStreamingProgressNarration, isCommandToolName via the streaming wildcard re-export) bump the pinned public-surface budgets to current counts (exports 10488, callable 5235). Gates verified on Testbox: oxfmt, targeted oxlint, docs:map:check, config:channels:check, check:test-types, 5 test shards.
1 parent 4a4a32a commit 26d200c

19 files changed

Lines changed: 1076 additions & 112 deletions

docs/concepts/progress-drafts.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,48 @@ pre-tool commentary/preamble narration (💬, for example "I'll check... then
257257
[Streaming and chunking](/concepts/streaming#commentary-progress-lane) for the
258258
shared config shape across channels.
259259

260+
### Narrated status
261+
262+
When the agent has a [`utilityModel`](/gateway/config-agents#utilitymodel)
263+
configured, the progress draft replaces the rolling tool lines with a short
264+
plain-language narration of what the agent is doing, written by that cheaper
265+
model and refreshed as the work moves along:
266+
267+
```text
268+
Clawing
269+
270+
Updating the default model in your config, then restarting the gateway to
271+
pick it up. One agent listing call failed and is being retried.
272+
```
273+
274+
Narration is on by default (`streaming.progress.narration`, default `true`)
275+
but only activates when an explicit `utilityModel` is configured for the agent
276+
or in `agents.defaults` — it never falls back to the primary model. Tool lines
277+
keep accumulating underneath and return if narration stops, and the draft is
278+
edited only when the narration text actually changes, which also reduces edit
279+
churn in busy channels. Disable it to keep the raw tool lines:
280+
281+
```json5
282+
{
283+
channels: {
284+
discord: {
285+
streaming: {
286+
mode: "progress",
287+
progress: {
288+
narration: false,
289+
},
290+
},
291+
},
292+
},
293+
}
294+
```
295+
296+
Narration input is bounded and redacted: the utility model receives the
297+
inbound request text plus the same compact, redacted tool summaries the draft
298+
would render — never raw command output or tool results. With
299+
`commandText: "status"`, narration input also omits exec/bash command text,
300+
matching what the draft shows.
301+
260302
### Line limits
261303

262304
Limit how many lines stay visible (default 8):
@@ -350,7 +392,7 @@ the final answer, except for the label if one is configured.
350392

351393
| Channel | Progress transport | Notes |
352394
| --------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
353-
| Discord | Send one message, then edit it. | Defaults to `progress` mode; final text edits in place when it fits one safe preview message. |
395+
| Discord | Send one message, then edit it. | Defaults to `progress` mode; the final answer carries a `-#` activity receipt and the status draft is deleted after the answer lands. |
354396
| Matrix | Send one event, then edit it. | Account-level streaming config controls account-level drafts. |
355397
| Microsoft Teams | Native Teams stream in personal chats. | `streaming.mode: "block"` maps to Teams block delivery instead. |
356398
| Slack | Native stream or editable draft post. | Needs a reply thread target; top-level DMs without one still get draft preview posts and edits. |
@@ -365,7 +407,14 @@ full runtime-behavior breakdown per channel.
365407

366408
When the final answer is ready, OpenClaw tries to keep the chat clean:
367409

368-
- If the draft can safely become the final answer, OpenClaw edits it in place.
410+
- In `progress` mode on Discord, the final answer is sent as a fresh message
411+
with a small `-#` activity receipt appended (for example
412+
`-# 🧠 2 thoughts · 🛠️ 5 tool calls · ⏱️ 12s`), and the status draft is
413+
deleted once that answer is delivered. Busy channels keep no orphaned tool
414+
log above the reply; error finals keep the draft as the visible record of
415+
the failed turn.
416+
- If the draft can safely become the final answer (`partial`/`block` modes),
417+
OpenClaw edits it in place.
369418
- If the channel uses native progress streaming, OpenClaw finalizes that
370419
stream when the native transport accepts the final text.
371420
- Otherwise (media, an approval prompt, an explicit reply target, too many

docs/concepts/streaming.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ Slack-only:
218218
- `block` mode uses draft chunking (`draftChunk`).
219219
- Preview streaming is skipped when Discord block streaming is explicitly
220220
enabled.
221+
- `progress` mode appends a small `-#` activity receipt (thought/tool-call
222+
counts and elapsed time) to the final answer and deletes the status draft
223+
once that answer is delivered, so busy channels keep no orphaned tool log
224+
above the reply. Error finals keep the draft as the record of the failed
225+
turn.
221226
- Final media, error, and explicit-reply payloads cancel pending previews
222227
without flushing a new draft, then use normal delivery.
223228

docs/docs_map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2719,6 +2719,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
27192719
- H3: Detail mode
27202720
- H3: Command/exec text
27212721
- H3: Commentary lane
2722+
- H3: Narrated status
27222723
- H3: Line limits
27232724
- H3: Rich rendering (Slack)
27242725
- H3: Hide tool/task lines

docs/gateway/config-agents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ Time format in system prompt. Default: `auto` (OS preference).
401401
- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
402402
- String form sets only the primary model.
403403
- Object form sets primary plus ordered failover models.
404-
- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, and Discord auto-thread titles. These tasks fall back to the agent's primary model when unset; `agents.list[].utilityModel` overrides the default, and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message. Choose a provider that matches your cost and data-handling requirements.
404+
- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, Discord auto-thread titles, and [progress-draft narration](/concepts/progress-drafts#narrated-status). Title tasks fall back to the agent's primary model when unset; progress narration only runs with an explicit `utilityModel`. `agents.list[].utilityModel` overrides the default, and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message; narration sends the inbound request plus compact redacted tool summaries. Choose a provider that matches your cost and data-handling requirements.
405405
- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
406406
- Used by the `image` tool path as its vision-model config.
407407
- Also used as fallback routing when the selected/default model cannot accept image input.

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
type ChannelProgressDraftLine,
55
createChannelProgressDraftCompositor,
66
resolveChannelStreamingBlockEnabled,
7+
resolveChannelStreamingPreviewCommandText,
78
resolveChannelStreamingPreviewToolProgress,
9+
resolveChannelStreamingProgressNarration,
810
resolveChannelStreamingSuppressDefaultToolProgressMessages,
911
} from "openclaw/plugin-sdk/channel-outbound";
1012
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
@@ -79,6 +81,16 @@ export function createDiscordDraftPreviewController(params: {
7981
let progressDraftStartedBeforeFinal = false;
8082
const previewToolProgressEnabled =
8183
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
84+
const narrationProgressEnabled =
85+
Boolean(draftStream) &&
86+
discordStreamMode === "progress" &&
87+
resolveChannelStreamingProgressNarration(params.discordConfig);
88+
// Narration model input follows the channel's command-text display policy:
89+
// "status" hides raw exec/bash text from viewers, so it must not reach the
90+
// utility model either.
91+
const narrationHideCommandText =
92+
narrationProgressEnabled &&
93+
resolveChannelStreamingPreviewCommandText(params.discordConfig) === "status";
8294
const suppressDefaultToolProgressMessages =
8395
Boolean(draftStream) &&
8496
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.discordConfig, {
@@ -135,6 +147,8 @@ export function createDiscordDraftPreviewController(params: {
135147
return {
136148
draftStream,
137149
previewToolProgressEnabled,
150+
narrationProgressEnabled,
151+
narrationHideCommandText,
138152
commentaryProgressEnabled: progressDraft.commentaryProgressEnabled,
139153
suppressDefaultToolProgressMessages,
140154
get isProgressMode() {
@@ -167,6 +181,9 @@ export function createDiscordDraftPreviewController(params: {
167181
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
168182
await progressDraft.pushReasoningProgress(text, options);
169183
},
184+
async pushNarrationProgress(text?: string) {
185+
await progressDraft.pushNarrationProgress(text);
186+
},
170187
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
171188
await progressDraft.pushCommentaryProgress(text, options);
172189
},

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

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ type DispatchInboundParams = {
146146
title?: string;
147147
name?: string;
148148
}) => Promise<false | void> | false | void;
149+
onNarrationUpdate?: (payload: { text: string }) => Promise<void> | void;
150+
narrationHideCommandText?: boolean;
149151
onVerboseProgressVisibility?: (isActive: () => boolean) => void;
150152
onPlanUpdate?: (payload: {
151153
phase?: string;
@@ -731,16 +733,21 @@ function createMockDraftStreamForTest() {
731733
return draftStream;
732734
}
733735

734-
function expectProgressSummaryEdit(...parts: string[]) {
735-
const call = firstMockCall(editMessageDiscord, "summary edit");
736-
expect(call[0]).toBe("c1");
737-
expect(call[1]).toBe("preview-1");
738-
const edit = requireRecord(call[2], "summary edit body");
739-
const content = String(edit.content);
740-
expect(content.startsWith("-# ")).toBe(true);
736+
function expectFinalWithProgressReceipt(answer: string, ...parts: string[]) {
737+
const finalParams = deliverDiscordReply.mock.calls
738+
.map((call) => requireRecord(call[0], "deliverDiscordReply params"))
739+
.find((params) => params.kind === "final");
740+
expect(finalParams).toBeDefined();
741+
const replies = (finalParams as { replies?: Array<{ text?: string }> }).replies;
742+
const text = replies?.[0]?.text ?? "";
743+
const receiptStart = text.lastIndexOf("\n-# ");
744+
expect(receiptStart).toBeGreaterThan(-1);
745+
expect(text.slice(0, receiptStart)).toBe(answer);
746+
const receipt = text.slice(receiptStart + 1);
741747
for (const part of parts) {
742-
expect(content).toContain(part);
748+
expect(receipt).toContain(part);
743749
}
750+
expect(receipt).toContain("⏱️");
744751
}
745752

746753
function expectFreshFinalText(text: string) {
@@ -2201,8 +2208,66 @@ describe("processDiscordMessage draft streaming", () => {
22012208

22022209
const updates = draftStream.update.mock.calls.map((call) => call[0]);
22032210
expect(updates).toEqual(["Pinching\n\n🛠️ Exec\n• exec done"]);
2204-
expectProgressSummaryEdit("🛠️ 1 tool call");
2205-
expectFreshFinalText("done");
2211+
expectFinalWithProgressReceipt("done", "🛠️ 1 tool call");
2212+
// The working draft deletes once the receipt-bearing final landed.
2213+
expect(editMessageDiscord).not.toHaveBeenCalled();
2214+
expect(draftStream.clear).toHaveBeenCalledTimes(1);
2215+
expect(draftStream.messageId()).toBeUndefined();
2216+
});
2217+
2218+
it("renders narration updates into the Discord progress draft", async () => {
2219+
const draftStream = createMockDraftStreamForTest();
2220+
2221+
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
2222+
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
2223+
await params?.replyOptions?.onNarrationUpdate?.({
2224+
text: "Reading the gateway config and restarting agents.",
2225+
});
2226+
await params?.dispatcher.sendFinalReply({ text: "done" });
2227+
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
2228+
});
2229+
2230+
const ctx = await createAutomaticSourceDeliveryContext({
2231+
discordConfig: { maxLinesPerMessage: 5 },
2232+
});
2233+
2234+
await runProcessDiscordMessage(ctx);
2235+
2236+
const updates = draftStream.update.mock.calls.map((call) => call[0]);
2237+
expect(updates).toContain("Pinching\n\nReading the gateway config and restarting agents.");
2238+
expectFinalWithProgressReceipt("done", "🛠️ 1 tool call");
2239+
});
2240+
2241+
it("omits the narration callback when progress narration is disabled", async () => {
2242+
createMockDraftStreamForTest();
2243+
dispatchInboundMessage.mockImplementationOnce(async () => createNoQueuedDispatchResult());
2244+
2245+
const ctx = await createAutomaticSourceDeliveryContext({
2246+
discordConfig: {
2247+
streaming: { mode: "progress", progress: { label: "Shelling", narration: false } },
2248+
},
2249+
});
2250+
2251+
await runProcessDiscordMessage(ctx);
2252+
2253+
expect(getLastDispatchReplyOptions()?.onNarrationUpdate).toBeUndefined();
2254+
});
2255+
2256+
it("mirrors status-only command text into the narration input policy", async () => {
2257+
createMockDraftStreamForTest();
2258+
dispatchInboundMessage.mockImplementationOnce(async () => createNoQueuedDispatchResult());
2259+
2260+
const ctx = await createAutomaticSourceDeliveryContext({
2261+
discordConfig: {
2262+
streaming: { mode: "progress", progress: { label: "Shelling", commandText: "status" } },
2263+
},
2264+
});
2265+
2266+
await runProcessDiscordMessage(ctx);
2267+
2268+
const replyOptions = getLastDispatchReplyOptions();
2269+
expect(replyOptions?.onNarrationUpdate).toBeDefined();
2270+
expect(replyOptions?.narrationHideCommandText).toBe(true);
22062271
});
22072272

22082273
it("declines failed item progress without updating the Discord draft", async () => {
@@ -2280,8 +2345,7 @@ describe("processDiscordMessage draft streaming", () => {
22802345
await runProcessDiscordMessage(ctx);
22812346

22822347
// 2 bursts closed by tool calls + 1 trailing burst flushed at summary.
2283-
expectProgressSummaryEdit("🧠 3 thoughts", "🛠️ 2 tool calls");
2284-
expectFreshFinalText("done");
2348+
expectFinalWithProgressReceipt("done", "🧠 3 thoughts", "🛠️ 2 tool calls");
22852349
});
22862350

22872351
it("counts window thinking bursts in the collapse summary", async () => {
@@ -2307,8 +2371,7 @@ describe("processDiscordMessage draft streaming", () => {
23072371

23082372
await runProcessDiscordMessage(ctx);
23092373

2310-
expectProgressSummaryEdit("🧠 2 thoughts", "🛠️ 1 tool call");
2311-
expectFreshFinalText("done");
2374+
expectFinalWithProgressReceipt("done", "🧠 2 thoughts", "🛠️ 1 tool call");
23122375
});
23132376

23142377
it("counts distinct narration notes in the collapse summary", async () => {
@@ -2344,8 +2407,7 @@ describe("processDiscordMessage draft streaming", () => {
23442407

23452408
await runProcessDiscordMessage(ctx);
23462409

2347-
expectProgressSummaryEdit("💬 2 notes", "🛠️ 1 tool call");
2348-
expectFreshFinalText("done");
2410+
expectFinalWithProgressReceipt("done", "💬 2 notes", "🛠️ 1 tool call");
23492411
});
23502412

23512413
it("does not update Discord progress drafts after final answer delivery", async () => {
@@ -2376,8 +2438,7 @@ describe("processDiscordMessage draft streaming", () => {
23762438

23772439
const updates = draftStream.update.mock.calls.map((call) => call[0]);
23782440
expect(updates).toEqual(["Shelling\n\n🛠️ Exec\n• exec running"]);
2379-
expectProgressSummaryEdit("🛠️ 1 tool call");
2380-
expectFreshFinalText("done");
2441+
expectFinalWithProgressReceipt("done", "🛠️ 1 tool call");
23812442
});
23822443

23832444
it("does not update Discord progress drafts while final answer delivery is pending", async () => {
@@ -2408,8 +2469,7 @@ describe("processDiscordMessage draft streaming", () => {
24082469

24092470
const updates = draftStream.update.mock.calls.map((call) => call[0]);
24102471
expect(updates).toEqual(["Shelling\n\n🛠️ Exec\n• exec running"]);
2411-
expectProgressSummaryEdit("🛠️ 1 tool call");
2412-
expectFreshFinalText("done");
2472+
expectFinalWithProgressReceipt("done", "🛠️ 1 tool call");
24132473
});
24142474

24152475
it("streams Discord tool progress for coding-profile message-tool-only guild replies", async () => {
@@ -2486,9 +2546,8 @@ describe("processDiscordMessage draft streaming", () => {
24862546
await runProcessDiscordMessage(ctx);
24872547

24882548
expect(draftStream.update).toHaveBeenCalledTimes(1);
2489-
expectProgressSummaryEdit("🛠️ 1 tool call");
24902549
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
2491-
expectFreshFinalText(fullAnswer);
2550+
expectFinalWithProgressReceipt(fullAnswer, "🛠️ 1 tool call");
24922551
});
24932552

24942553
it("clears partial drafts when fallback final delivery fails before completion", async () => {
@@ -3208,8 +3267,7 @@ describe("processDiscordMessage draft streaming", () => {
32083267

32093268
const updates = draftStream.update.mock.calls.map((call) => call[0]);
32103269
expect(updates).toEqual(["💬 Checking source data."]);
3211-
expectProgressSummaryEdit();
3212-
expectFreshFinalText("done");
3270+
expectFinalWithProgressReceipt("done");
32133271
});
32143272

32153273
it("does not start Discord progress drafts for text-only accepted turns", async () => {
@@ -3259,8 +3317,7 @@ describe("processDiscordMessage draft streaming", () => {
32593317
await runProcessDiscordMessage(ctx);
32603318

32613319
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• exec done");
3262-
expectProgressSummaryEdit("🛠️ 1 tool call");
3263-
expectFreshFinalText("done");
3320+
expectFinalWithProgressReceipt("done", "🛠️ 1 tool call");
32643321
});
32653322

32663323
it("drops later tool warning finals after progress preview final replies", async () => {
@@ -3289,11 +3346,12 @@ describe("processDiscordMessage draft streaming", () => {
32893346
await runProcessDiscordMessage(ctx);
32903347

32913348
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• exec done");
3292-
expectProgressSummaryEdit("🛠️ 1 tool call");
3293-
expect(draftStream.clear).not.toHaveBeenCalled();
3294-
expect(draftStream.messageId()).toBe("preview-1");
3349+
// The delivered final consumed the draft; the later tool warning must not
3350+
// resurrect it or produce a second visible reply.
3351+
expect(draftStream.clear).toHaveBeenCalledTimes(1);
3352+
expect(draftStream.messageId()).toBeUndefined();
32953353
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
3296-
expectFreshFinalText("delivery survived");
3354+
expectFinalWithProgressReceipt("delivery survived", "🛠️ 1 tool call");
32973355
});
32983356

32993357
it("uses raw tool-progress detail in Discord progress drafts", async () => {

0 commit comments

Comments
 (0)