Skip to content

Commit d60ab48

Browse files
Add Telegram progress preview flows (#83847)
* feat(telegram): add progress preview flow tooling * docs: add channel flow preview skill * test(telegram): exercise native draft flow fixture * fix(telegram): remove progress label ellipsis animation * fix(telegram): address progress preview review
1 parent b86435f commit d60ab48

29 files changed

Lines changed: 919 additions & 114 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
name: channel-message-flows
3+
description: "Use when previewing local channel message flow fixtures."
4+
---
5+
6+
# Channel Message Flows
7+
8+
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
9+
10+
## Telegram
11+
12+
Native Telegram `sendMessageDraft` tool progress, then a final answer:
13+
14+
```bash
15+
node --import tsx scripts/dev/channel-message-flows.ts \
16+
--channel telegram \
17+
--target <telegram-chat-id> \
18+
--flow working-final \
19+
--duration-ms 20000
20+
```
21+
22+
Thinking preview, then a final answer:
23+
24+
```bash
25+
node --import tsx scripts/dev/channel-message-flows.ts \
26+
--channel telegram \
27+
--target <telegram-chat-id> \
28+
--flow thinking-final
29+
```
30+
31+
## Options
32+
33+
- `--account <accountId>`: Telegram account id when not using the default.
34+
- `--thread-id <id>`: Telegram forum topic/message thread id.
35+
- `--delay-ms <ms>`: Override preview update cadence.
36+
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
37+
- `--final-text <text>`: Override the durable final message.
38+
39+
## Notes
40+
41+
- `--target` is the numeric Telegram chat id.
42+
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
43+
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
44+
- Only `--channel telegram` is implemented for now.

docs/channels/mattermost.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ Enable via `channels.mattermost.streaming`:
289289
</Accordion>
290290
<Accordion title="Streaming behavior notes">
291291
- If the stream cannot be finalized in place (for example the post was deleted mid-stream), OpenClaw falls back to sending a fresh final post so the reply is never lost.
292-
- Reasoning-only payloads are suppressed from channel posts, including text that arrives as a `> Reasoning:` blockquote. Set `/reasoning on` to see thinking in other surfaces; the Mattermost final post keeps the answer only.
292+
- Thinking-only payloads are suppressed from channel posts, including text that arrives as a `> Thinking` blockquote. Set `/reasoning on` to see thinking in other surfaces; the Mattermost final post keeps the answer only.
293293
- See [Streaming](/concepts/streaming#preview-streaming-modes) for the channel-mapping matrix.
294294

295295
</Accordion>

docs/concepts/progress-drafts.md

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ A progress draft has two parts:
5353

5454
| Part | Purpose |
5555
| -------------- | ------------------------------------------------------------------------------------- |
56-
| Label | A short starter/status line such as `Thinking...` or `Shelling...`. |
56+
| Label | A short starter/status line such as `Working` or `Shelling`. |
5757
| Progress lines | Compact run updates using the same tool icons and detail formatter as verbose output. |
5858

5959
The label appears after the agent starts meaningful work and either remains busy
@@ -95,29 +95,29 @@ normal block delivery. Use `streaming.block.enabled` or legacy
9595
Progress labels live under `channels.<channel>.streaming.progress`.
9696

9797
The default label is `auto`, which chooses from OpenClaw's built-in
98-
single-word-with-ellipsis label pool:
98+
single-word label pool:
9999

100100
```text
101-
Thinking...
102-
Shelling...
103-
Scuttling...
104-
Clawing...
105-
Pinching...
106-
Molting...
107-
Bubbling...
108-
Tiding...
109-
Reefing...
110-
Cracking...
111-
Sifting...
112-
Brining...
113-
Nautiling...
114-
Krilling...
115-
Barnacling...
116-
Lobstering...
117-
Tidepooling...
118-
Pearling...
119-
Snapping...
120-
Surfacing...
101+
Working
102+
Shelling
103+
Scuttling
104+
Clawing
105+
Pinching
106+
Molting
107+
Bubbling
108+
Tiding
109+
Reefing
110+
Cracking
111+
Sifting
112+
Brining
113+
Nautiling
114+
Krilling
115+
Barnacling
116+
Lobstering
117+
Tidepooling
118+
Pearling
119+
Snapping
120+
Surfacing
121121
```
122122

123123
Use a fixed label:

docs/gateway/heartbeat.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Example config:
5252
isolatedSession: true, // optional: fresh session each run (no conversation history)
5353
skipWhenBusy: true, // optional: also defer when this agent's subagent or nested lanes are busy
5454
// activeHours: { start: "08:00", end: "24:00" },
55-
// includeReasoning: true, // optional: send separate `Reasoning:` message too
55+
// includeReasoning: true, // optional: send separate `Thinking` message too
5656
},
5757
},
5858
},
@@ -98,7 +98,7 @@ Outside heartbeats, stray `HEARTBEAT_OK` at the start/end of a message is stripp
9898
heartbeat: {
9999
every: "30m", // default: 30m (0m disables)
100100
model: "anthropic/claude-opus-4-6",
101-
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
101+
includeReasoning: false, // default: false (deliver separate Thinking message when available)
102102
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
103103
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
104104
skipWhenBusy: false, // default: false; true also waits for this agent's subagent/nested lanes
@@ -226,7 +226,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele
226226
Optional model override for heartbeat runs (`provider/model`).
227227
</ParamField>
228228
<ParamField path="includeReasoning" type="boolean" default="false">
229-
When enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
229+
When enabled, also deliver the separate `Thinking` message when available (same shape as `/reasoning on`).
230230
</ParamField>
231231
<ParamField path="lightContext" type="boolean" default="false">
232232
When true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
@@ -465,7 +465,7 @@ If you want transparency, enable:
465465

466466
- `agents.defaults.heartbeat.includeReasoning: true`
467467

468-
When enabled, heartbeats will also deliver a separate message prefixed `Reasoning:` (same shape as `/reasoning on`). This can be useful when the agent is managing multiple sessions/codexes and you want to see why it decided to ping you — but it can also leak more internal detail than you want. Prefer keeping it off in group chats.
468+
When enabled, heartbeats will also deliver a separate message prefixed `Thinking` (same shape as `/reasoning on`). This can be useful when the agent is managing multiple sessions/codexes and you want to see why it decided to ping you — but it can also leak more internal detail than you want. Prefer keeping it off in group chats.
469469

470470
## Cost awareness
471471

docs/tools/thinking.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ title: "Thinking levels"
103103

104104
- Levels: `on|off|stream`.
105105
- Directive-only message toggles whether thinking blocks are shown in replies.
106-
- When enabled, reasoning is sent as a **separate message** prefixed with `Reasoning:`.
106+
- When enabled, reasoning is sent as a **separate message** prefixed with `Thinking`.
107107
- `stream` (Telegram only): streams reasoning into the Telegram draft bubble while the reply is generating, then sends the final answer without reasoning.
108108
- Alias: `/reason`.
109109
- Send `/reasoning` (or `/reasoning:`) with no argument to see the current reasoning level.
@@ -118,7 +118,7 @@ Malformed local-model reasoning tags are handled conservatively. Closed `<think>
118118
## Heartbeats
119119

120120
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
121-
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`.
121+
- Heartbeat delivery defaults to the final payload only. To also send the separate `Thinking` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`.
122122

123123
## Web chat UI
124124

extensions/discord/src/chunk.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,19 @@ describe("chunkDiscordText", () => {
130130
}
131131
});
132132

133+
it("keeps thinking-prefixed reasoning italics balanced across chunks", () => {
134+
const body = Array.from({ length: 25 }, (_, i) => `${i + 1}. line`).join("\n");
135+
const text = `Thinking\n\n_${body}_`;
136+
137+
const chunks = chunkDiscordText(text, { maxLines: 10, maxChars: 2000 });
138+
expect(chunks.length).toBeGreaterThan(1);
139+
140+
for (const chunk of chunks) {
141+
const underscoreCount = (chunk.match(/_/g) || []).length;
142+
expect(underscoreCount % 2).toBe(0);
143+
}
144+
});
145+
133146
it("reopens italics while preserving leading whitespace on following chunk", () => {
134147
const body = [
135148
"1. line",

extensions/discord/src/chunk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ function rebalanceReasoningItalics(source: string, chunks: string[]): string[] {
287287
}
288288

289289
const opensWithReasoningItalics =
290-
source.startsWith("Reasoning:\n_") && source.trimEnd().endsWith("_");
290+
/^(?:Reasoning:|Thinking\.{0,3})\n+_/u.test(source) && source.trimEnd().endsWith("_");
291291
if (!opensWithReasoningItalics) {
292292
return chunks;
293293
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ export function createDiscordDraftPreviewController(params: {
388388

389389
function normalizeReasoningProgressLine(text: string): string {
390390
return text
391-
.replace(/^\s*(?:>\s*)?Reasoning:\s*/i, "")
391+
.replace(/^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i, "")
392392
.replace(/\s+/g, " ")
393393
.trim();
394394
}
@@ -409,7 +409,7 @@ function mergeReasoningProgressText(current: string, incoming: string): string {
409409
}
410410

411411
function isReasoningSnapshotText(text: string): boolean {
412-
return /^\s*(?:>\s*)?Reasoning:\s*/i.test(text);
412+
return /^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i.test(text);
413413
}
414414

415415
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,7 +1743,7 @@ describe("processDiscordMessage draft streaming", () => {
17431743
await runProcessDiscordMessage(ctx);
17441744

17451745
const updates = draftStream.update.mock.calls.map((call) => call[0]);
1746-
expect(updates).toEqual(["Pinching...\n🛠️ Exec\n• exec done"]);
1746+
expect(updates).toEqual(["Pinching\n\n🛠️ Exec\n• exec done"]);
17471747
expectPreviewEditContent("done");
17481748
expect(deliverDiscordReply).not.toHaveBeenCalled();
17491749
});
@@ -1773,7 +1773,7 @@ describe("processDiscordMessage draft streaming", () => {
17731773
await runProcessDiscordMessage(ctx);
17741774

17751775
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
1776-
expect(draftStream.update).toHaveBeenCalledWith("Pinching...\n🛠️ Exec\n• exec done");
1776+
expect(draftStream.update).toHaveBeenCalledWith("Pinching\n\n🛠️ Exec\n• exec done");
17771777
expect(deliverDiscordReply).not.toHaveBeenCalled();
17781778
});
17791779

@@ -2205,7 +2205,7 @@ describe("processDiscordMessage draft streaming", () => {
22052205

22062206
await runProcessDiscordMessage(ctx);
22072207

2208-
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• exec done");
2208+
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• exec done");
22092209
expect(deliverDiscordReply).not.toHaveBeenCalled();
22102210
expectPreviewEditContent("done");
22112211
});
@@ -2238,7 +2238,7 @@ describe("processDiscordMessage draft streaming", () => {
22382238
await runProcessDiscordMessage(ctx);
22392239

22402240
expect(draftStream.update).toHaveBeenCalledWith(
2241-
"Shelling\n🛠️ run tests, `pnpm test -- --watch=false`\n• done",
2241+
"Shelling\n\n🛠️ run tests, `pnpm test -- --watch=false`\n• done",
22422242
);
22432243
});
22442244

@@ -2270,7 +2270,7 @@ describe("processDiscordMessage draft streaming", () => {
22702270

22712271
await runProcessDiscordMessage(ctx);
22722272

2273-
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• done");
2273+
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• done");
22742274
});
22752275

22762276
it("keeps Discord progress lines below the configured label", async () => {
@@ -2297,7 +2297,7 @@ describe("processDiscordMessage draft streaming", () => {
22972297

22982298
await runProcessDiscordMessage(ctx);
22992299

2300-
expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n🧩 First\n🧩 Second\n🧩 Third");
2300+
expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n\n🧩 First\n🧩 Second\n🧩 Third");
23012301
});
23022302

23032303
it("skips empty apply_patch starts and renders the patch summary", async () => {
@@ -2328,7 +2328,7 @@ describe("processDiscordMessage draft streaming", () => {
23282328
await runProcessDiscordMessage(ctx);
23292329

23302330
expect(draftStream.update).toHaveBeenCalledWith(
2331-
"Clawing...\n🩹 1 modified; extensions/discord/src/monitor/message-handler.draft-preview.ts",
2331+
"Clawing...\n\n🩹 1 modified; extensions/discord/src/monitor/message-handler.draft-preview.ts",
23322332
);
23332333
const updates = draftStream.update.mock.calls.map((call) => call[0]);
23342334
expect(updates.join("\n")).not.toContain("Apply Patch");
@@ -2362,10 +2362,11 @@ describe("processDiscordMessage draft streaming", () => {
23622362
await runProcessDiscordMessage(ctx);
23632363

23642364
expect(draftStream.update).toHaveBeenCalledWith(
2365-
"Clawing...\n🛠️ Exec\n• _Reading the event projector_",
2365+
"Clawing...\n\n🛠️ Exec\n• _Reading the event projector_",
23662366
);
23672367
const updates = draftStream.update.mock.calls.map((call) => call[0]);
23682368
expect(updates.join("\n")).not.toContain("Reasoning");
2369+
expect(updates.join("\n")).not.toContain("Thinking\n");
23692370
});
23702371

23712372
it("replaces reasoning snapshots instead of appending duplicates", async () => {
@@ -2394,10 +2395,11 @@ describe("processDiscordMessage draft streaming", () => {
23942395
await runProcessDiscordMessage(ctx);
23952396

23962397
expect(draftStream.update).toHaveBeenCalledWith(
2397-
"Clawing...\n🛠️ Exec\n• _Checking files and tests_",
2398+
"Clawing...\n\n🛠️ Exec\n• _Checking files and tests_",
23982399
);
23992400
const updates = draftStream.update.mock.calls.map((call) => call[0]);
24002401
expect(updates.join("\n")).not.toContain("_Checking files_Reasoning:");
2402+
expect(updates.join("\n")).not.toContain("_Checking files_Thinking");
24012403
});
24022404

24032405
it("keeps Discord progress lines across assistant boundaries", async () => {
@@ -2423,7 +2425,7 @@ describe("processDiscordMessage draft streaming", () => {
24232425

24242426
await runProcessDiscordMessage(ctx);
24252427

2426-
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🧩 First\n🧩 Second");
2428+
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🧩 First\n🧩 Second");
24272429
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
24282430
});
24292431

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
250250
if (!thinking) {
251251
return "";
252252
}
253-
const withoutLabel = thinking.replace(/^Reasoning:\n/, "");
253+
const withoutLabel = thinking.replace(/^(?:Reasoning:|Thinking\.{0,3})\s*/u, "");
254254
const plain = withoutLabel.replace(/^_(.*)_$/gm, "$1");
255255
const lines = plain.split("\n").map((line) => `> ${line}`);
256256
return `> 💭 **Thinking**\n${lines.join("\n")}`;

0 commit comments

Comments
 (0)