fix(channels): harden tool progress previews

This commit is contained in:
Peter Steinberger
2026-04-25 04:52:10 +01:00
parent da89108b82
commit 70fd1c91aa
12 changed files with 300 additions and 54 deletions

View File

@@ -101,7 +101,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Voice Call: start provider STT after Telnyx outbound conversation greetings and pass configured Telnyx voice IDs through to the speak action. Fixes #56091. Thanks @Roshan.
- Skills: honor legacy `metadata.clawdbot` requirements and installer hints when `metadata.openclaw` is absent, so older skills no longer appear ready when required binaries are missing. Fixes #71323. Thanks @chen-zhang-cs-code.
- Browser/config: expand `~` in `browser.executablePath` before Chromium launch, so home-relative custom browser paths no longer fail with `ENOENT`. Fixes #67264. Thanks @Quratulain-bilal.
- Telegram/streaming: hide tool-progress status updates by default while keeping explicit `streaming.preview.toolProgress` opt-in support for edited preview messages. Fixes #71320. Thanks @neeravmakwana.
- Channels/streaming: keep Telegram tool-progress preview updates enabled by default to match released behavior, document `streaming.preview.toolProgress: false` for disabling only those status lines, and prevent preview progress text from triggering Telegram Markdown links, Discord mentions, or Slack mrkdwn mentions. Fixes #71320. Thanks @neeravmakwana.
- Gateway/sessions: copy the oversized `sessions.json` to a rotation backup before the atomic rewrite instead of renaming the live store away, so a crash during rotation keeps the existing session-to-transcript mapping authoritative. Fixes #68229. Thanks @jjjojoj.
- Providers/OpenAI-compatible: strip OpenAI-only Completions `store` from proxy payloads and allow `extra_body`/`extraBody` passthrough params for provider-specific request fields. Fixes #61826 and #69717.
- Discord/subagents: preserve thread-bound completion delivery by keeping the requester-agent announce path primary and falling back to direct thread sends only when the announce produces no visible output. (#71064) Thanks @DolencLuka.

View File

@@ -1,4 +1,4 @@
5c7709e1686f6ad90beaa8e34ba45e6445e34c48d598407bd837361b58c365ab config-baseline.json
b6d1e53947fcdfbff1b99f8ec79d3814d243385a1750b7fb40b40bb30f2e2975 config-baseline.json
98c83ce8af9ec4703726d7d673add95279be008a801b1d298982cbd9c1785747 config-baseline.core.json
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json
86f615b7d267b03888af0af7ccb3f8232a6b636f8a741d522ff425e46729ba81 config-baseline.plugin.json

View File

@@ -273,9 +273,28 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `false`). Set `true` only when visible Telegram progress updates are desired.
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
Tool-progress preview updates are the short "Working..." lines shown while tools run, for example command execution, file reads, planning updates, or patch summaries. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later. To keep the edited preview for answer text but hide tool-progress lines, set:
```json
{
"channels": {
"telegram": {
"streaming": {
"mode": "partial",
"preview": {
"toolProgress": false
}
}
}
}
}
```
Use `streaming.mode: "off"` only when you want to disable Telegram preview edits entirely. Use `streaming.preview.toolProgress: false` when you only want to disable the tool-progress status lines.
For text-only replies:
- DM: OpenClaw keeps the same preview message and performs a final edit in place (no second message)

View File

@@ -176,10 +176,28 @@ Preview streaming can also include **tool-progress** updates — short status li
Supported surfaces:
- **Discord** and **Slack** stream tool-progress into the live preview edit by default.
- **Telegram** only streams tool-progress into the live preview edit when `streaming.preview.toolProgress` is explicitly enabled.
- **Discord**, **Slack**, and **Telegram** stream tool-progress into the live preview edit by default when preview streaming is active.
- Telegram has shipped with tool-progress preview updates enabled since `v2026.4.22`; keeping them enabled preserves that released behavior.
- **Mattermost** already folds tool activity into its single draft preview post (see above).
- Tool-progress edits follow the active preview streaming mode; they are skipped when preview streaming is `off` or when block streaming has taken over the message.
- To keep preview streaming but hide tool-progress lines, set `streaming.preview.toolProgress` to `false` for that channel. To disable preview edits entirely, set `streaming.mode` to `off`.
Example:
```json
{
"channels": {
"telegram": {
"streaming": {
"mode": "partial",
"preview": {
"toolProgress": false
}
}
}
}
}
```
## Related

View File

@@ -44,6 +44,7 @@ describe("createDiscordDraftStream", () => {
expect(rest.post).toHaveBeenCalledWith(Routes.channelMessages("c1"), {
body: {
content: "first draft",
allowed_mentions: { parse: [] },
message_reference: {
message_id: "parent-1",
fail_if_not_exists: false,
@@ -51,11 +52,42 @@ describe("createDiscordDraftStream", () => {
},
});
expect(rest.patch).toHaveBeenCalledWith(Routes.channelMessage("c1", "m1"), {
body: { content: "second draft" },
body: { content: "second draft", allowed_mentions: { parse: [] } },
});
expect(stream.messageId()).toBe("m1");
});
it("suppresses mentions in preview creates and edits", async () => {
const rest = {
post: vi.fn(async () => ({ id: "m1" })),
patch: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
};
const stream = createDiscordDraftStream({
rest: rest as never,
channelId: "c1",
throttleMs: 250,
});
stream.update("working @everyone <@123>");
await stream.flush();
stream.update("still working @here");
await stream.flush();
expect(rest.post).toHaveBeenCalledWith(Routes.channelMessages("c1"), {
body: {
content: "working @everyone <@123>",
allowed_mentions: { parse: [] },
},
});
expect(rest.patch).toHaveBeenCalledWith(Routes.channelMessage("c1", "m1"), {
body: {
content: "still working @here",
allowed_mentions: { parse: [] },
},
});
});
it("stops previewing and warns once text exceeds the configured limit", async () => {
const rest = {
post: vi.fn(async () => ({ id: "m1" })),

View File

@@ -6,6 +6,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
/** Discord messages cap at 2000 characters. */
const DISCORD_STREAM_MAX_CHARS = 2000;
const DEFAULT_THROTTLE_MS = 1200;
const DISCORD_PREVIEW_ALLOWED_MENTIONS = { parse: [] };
export type DiscordDraftStream = {
update: (text: string) => void;
@@ -76,7 +77,7 @@ export function createDiscordDraftStream(params: {
if (streamMessageId !== undefined) {
// Edit existing message
await rest.patch(Routes.channelMessage(channelId, streamMessageId), {
body: { content: trimmed },
body: { content: trimmed, allowed_mentions: DISCORD_PREVIEW_ALLOWED_MENTIONS },
});
return true;
}
@@ -88,6 +89,7 @@ export function createDiscordDraftStream(params: {
const sent = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: trimmed,
allowed_mentions: DISCORD_PREVIEW_ALLOWED_MENTIONS,
...(messageReference ? { message_reference: messageReference } : {}),
},
})) as { id?: string } | undefined;

View File

@@ -42,13 +42,14 @@ let mockedDispatchSequence: Array<{
mediaUrls?: string[];
};
}> = [];
let mockedProgressEvents: string[] = [];
const noop = () => {};
const noopAsync = async () => {};
function createDraftStreamStub() {
return {
update: noop,
update: vi.fn(),
flush: noopAsync,
clear: noopAsync,
discardPending: noopAsync,
@@ -292,7 +293,10 @@ vi.mock("../reply.runtime.js", () => ({
markDispatchIdle: () => {},
}),
dispatchInboundMessage: async (params: {
replyOptions?: { disableBlockStreaming?: boolean };
replyOptions?: {
disableBlockStreaming?: boolean;
onItemEvent?: (payload: { progressText: string }) => Promise<void> | void;
};
dispatcher: {
deliver: (
payload: {
@@ -307,6 +311,9 @@ vi.mock("../reply.runtime.js", () => ({
};
}) => {
capturedReplyOptions = params.replyOptions;
for (const progressText of mockedProgressEvents) {
await params.replyOptions?.onItemEvent?.({ progressText });
}
for (const entry of mockedDispatchSequence) {
await params.dispatcher.deliver(entry.payload, { kind: entry.kind });
}
@@ -344,6 +351,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
mockedReplyThreadTs = THREAD_TS;
mockedReplyThreadTsSequence = undefined;
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
mockedProgressEvents = [];
createSlackDraftStreamMock.mockReturnValue(createDraftStreamStub());
finalizeSlackPreviewEditMock.mockRejectedValue(new Error("socket closed"));
@@ -406,6 +414,19 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expect(capturedReplyOptions?.disableBlockStreaming).toBe(true);
});
it("escapes Slack mrkdwn in tool progress preview labels", async () => {
const draftStream = createDraftStreamStub();
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);
mockedDispatchSequence = [];
mockedProgressEvents = ["ran <!here> <@U123> *bold* `code` & done"];
await dispatchPreparedSlackMessage(createPreparedSlackMessage());
expect(draftStream.update).toHaveBeenCalledWith(
"Working…\n• ran &lt;!here&gt; &lt;@U123&gt; \\*bold\\* \\`code\\` &amp; done",
);
});
it("starts native streams in the first-reply thread for top-level channel messages", async () => {
mockedNativeStreaming = true;
mockedReplyThreadTs = "171234.111";

View File

@@ -47,6 +47,7 @@ import {
import { resolveSlackThreadTargets } from "../../threading.js";
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
import { escapeSlackMrkdwn } from "../mrkdwn.js";
import {
createSlackReplyDeliveryPlan,
deliverReplies,
@@ -891,11 +892,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (!normalized) {
return;
}
const escaped = escapeSlackMrkdwn(normalized);
const previous = previewToolProgressLines.at(-1);
if (previous === normalized) {
if (previous === escaped) {
return;
}
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(-8);
previewToolProgressLines = [...previewToolProgressLines, escaped].slice(-8);
draftStream.update(
["Working…", ...previewToolProgressLines.map((entry) => `${entry}`)].join("\n"),
);

View File

@@ -499,7 +499,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("suppresses Telegram tool progress by default", async () => {
it("streams Telegram tool progress by default when preview streaming is active", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
@@ -510,6 +510,33 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
expect(draftStream.update).toHaveBeenCalledWith(
"Working…\n• `tool: exec`\n• `exec ls ~/Desktop`",
);
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyOptions: expect.objectContaining({
suppressDefaultToolProgressMessages: true,
}),
}),
);
});
it("suppresses Telegram tool progress when explicitly disabled", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await replyOptions?.onItemEvent?.({ progressText: "exec ls ~/Desktop" });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
telegramCfg: { streaming: { preview: { toolProgress: false } } },
});
expect(draftStream.update).not.toHaveBeenCalled();
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
@@ -520,7 +547,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("streams Telegram tool progress only when explicitly enabled", async () => {
it("keeps Telegram tool progress links inside code formatting", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
@@ -532,7 +559,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
telegramCfg: { streaming: { preview: { toolProgress: true } } },
});
const lastPreviewText = draftStream.update.mock.calls.at(-1)?.[0];
@@ -566,11 +592,32 @@ describe("dispatchTelegramMessage draft streaming", () => {
const progressLine = lastPreviewText.split("\n").at(1) ?? "";
expect(lastPreviewText.length).toBeLessThan(340);
expect(progressLine).toMatch(/^• `{10}/);
expect(progressLine).toMatch(/^• `'{10}/);
expect(progressLine).toContain("…");
expect(renderTelegramHtmlText(lastPreviewText)).not.toContain("<a ");
});
it("does not let Telegram tool progress backticks break out of code formatting", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);
const breakoutProgress = `${"`".repeat(10)} [label](tg://user?id=123)`;
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onItemEvent?.({ progressText: breakoutProgress });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "partial",
telegramCfg: { streaming: { preview: { toolProgress: true } } },
});
const lastPreviewText = draftStream.update.mock.calls.at(-1)?.[0] ?? "";
expect(lastPreviewText).toContain(`\`'''''''''' [label](tg://user?id=123)\``);
expect(renderTelegramHtmlText(lastPreviewText)).not.toContain("<a ");
});
it("keeps block streaming enabled when account config enables it", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" });

View File

@@ -208,7 +208,6 @@ function resolveTelegramReasoningLevel(params: {
}
const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300;
const MAX_PROGRESS_MARKDOWN_FENCE_CHARS = 10;
function clipProgressMarkdownText(text: string): string {
if (text.length <= MAX_PROGRESS_MARKDOWN_TEXT_CHARS) {
@@ -219,12 +218,8 @@ function clipProgressMarkdownText(text: string): string {
function formatProgressAsMarkdownCode(text: string): string {
const clipped = clipProgressMarkdownText(text);
const maxBacktickRun = Math.max(
0,
...Array.from(clipped.matchAll(/`+/g), (match) => match[0].length),
);
const fence = "`".repeat(Math.min(maxBacktickRun + 1, MAX_PROGRESS_MARKDOWN_FENCE_CHARS));
return `${fence}${clipped}${fence}`;
const safe = clipped.replaceAll("`", "'");
return `\`${safe}\``;
}
export const dispatchTelegramMessage = async ({
@@ -408,7 +403,7 @@ export const dispatchTelegramMessage = async ({
const answerLane = lanes.answer;
const reasoningLane = lanes.reasoning;
const previewToolProgressEnabled =
Boolean(answerLane.stream) && resolveChannelStreamingPreviewToolProgress(telegramCfg, false);
Boolean(answerLane.stream) && resolveChannelStreamingPreviewToolProgress(telegramCfg);
let previewToolProgressSuppressed = false;
let previewToolProgressLines: string[] = [];
const pushPreviewToolProgress = (line?: string) => {

View File

@@ -63,7 +63,7 @@ export const telegramChannelConfigUiHints = {
},
"streaming.preview.toolProgress": {
label: "Telegram Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: false). Enable only when visible Telegram progress updates are desired.",
help: "Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview.",
},
"retry.attempts": {
label: "Telegram Retry Attempts",

View File

@@ -893,6 +893,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -2066,6 +2069,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -3081,6 +3087,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Discord Draft Chunk Break Preference",
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
},
"streaming.preview.toolProgress": {
label: "Discord Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to keep tool updates as separate messages.",
},
"retry.attempts": {
label: "Discord Retry Attempts",
help: "Max retry attempts for outbound Discord API calls (default: 3).",
@@ -9417,6 +9427,27 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
],
},
},
groupAllowFrom: {
type: "array",
items: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
],
},
},
dmPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
groupPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
systemPrompt: {
type: "string",
},
@@ -9479,42 +9510,41 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
],
},
tts: {
execApprovals: {
type: "object",
properties: {
enabled: {
type: "boolean",
anyOf: [
{
type: "boolean",
},
{
type: "string",
const: "auto",
},
],
},
provider: {
type: "string",
},
baseUrl: {
type: "string",
},
apiKey: {
type: "string",
},
model: {
type: "string",
},
voice: {
type: "string",
},
authStyle: {
type: "string",
enum: ["bearer", "api-key"],
},
queryParams: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
approvers: {
type: "array",
items: {
type: "string",
},
},
speed: {
type: "number",
agentFilter: {
type: "array",
items: {
type: "string",
},
},
sessionFilter: {
type: "array",
items: {
type: "string",
},
},
target: {
type: "string",
enum: ["dm", "channel", "both"],
},
},
additionalProperties: false,
@@ -9637,6 +9667,27 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
],
},
},
groupAllowFrom: {
type: "array",
items: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
],
},
},
dmPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
groupPolicy: {
type: "string",
enum: ["open", "allowlist", "disabled"],
},
systemPrompt: {
type: "string",
},
@@ -9699,6 +9750,45 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
],
},
execApprovals: {
type: "object",
properties: {
enabled: {
anyOf: [
{
type: "boolean",
},
{
type: "string",
const: "auto",
},
],
},
approvers: {
type: "array",
items: {
type: "string",
},
},
agentFilter: {
type: "array",
items: {
type: "string",
},
},
sessionFilter: {
type: "array",
items: {
type: "string",
},
},
target: {
type: "string",
enum: ["dm", "channel", "both"],
},
},
additionalProperties: false,
},
},
additionalProperties: {},
},
@@ -10866,6 +10956,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -11775,6 +11868,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -12311,6 +12407,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Slack Native Streaming",
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Requires a reply thread target; top-level DMs stay on the non-thread fallback path.",
},
"streaming.preview.toolProgress": {
label: "Slack Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to keep tool updates as separate messages.",
},
"thread.historyScope": {
label: "Slack Thread History Scope",
help: 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
@@ -13058,6 +13158,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -14096,6 +14199,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
toolProgress: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -14498,6 +14604,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Telegram Draft Chunk Break Preference",
help: "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence).",
},
"streaming.preview.toolProgress": {
label: "Telegram Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview.",
},
"retry.attempts": {
label: "Telegram Retry Attempts",
help: "Max retry attempts for outbound Telegram API calls (default: 3).",