From 814b125f114c60fb12a7074c65a747740dc88c37 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 6 May 2026 08:04:40 +0530 Subject: [PATCH] fix(telegram): separate progress drafts from final replies --- docs/channels/telegram.md | 5 +-- docs/concepts/streaming.md | 1 + .../telegram/src/bot-message-dispatch.test.ts | 34 +++++++++++++++++++ .../telegram/src/bot-message-dispatch.ts | 29 ++++++++++++---- 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 53d5f03fa17..392e60af7a4 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -278,7 +278,7 @@ curl "https://api.telegram.org/bot/getUpdates" Requirement: - `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`) - - `progress` keeps one editable status draft and updates it with tool progress until final delivery + - `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message - `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active) - `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only) - legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode` @@ -317,7 +317,7 @@ curl "https://api.telegram.org/bot/getUpdates" } ``` - For progress-draft mode, put the same command-text policy under `streaming.progress`: + Use `progress` mode when you want visible tool progress without editing the final answer into that same message. Put the command-text policy under `streaming.progress`: ```json { @@ -345,6 +345,7 @@ curl "https://api.telegram.org/bot/getUpdates" - short DM/group/topic previews: OpenClaw keeps the same preview message and performs the final edit in place - long text finals that split into multiple Telegram messages reuse the existing preview as the first final chunk when possible, then send only the remaining chunks + - progress-mode finals clear the status draft and use normal final delivery instead of editing the draft into the answer - if the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message. diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 94523e312f4..93e616b4719 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -161,6 +161,7 @@ Telegram: - Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics. - Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks. +- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery. - If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview. - Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming). - `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery. diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index c5ddf8d6ca1..7f4baa217aa 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -919,6 +919,40 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("keeps progress updates in a draft and sends the final answer normally", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await replyOptions?.onItemEvent?.({ + kind: "command", + name: "exec", + progressText: "git rev-parse --abbrev-ref HEAD", + }); + await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ + context: createContext(), + streamMode: "progress", + telegramCfg: { streaming: { mode: "progress" } }, + }); + + expect(answerDraftStream.update).toHaveBeenCalledWith( + expect.stringMatching(/`🛠️ Exec: git rev-parse --abbrev-ref HEAD`$/), + ); + expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date"); + expect(answerDraftStream.clear).toHaveBeenCalledTimes(1); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "Branch is up to date" })], + }), + ); + expect(editMessageTelegram).not.toHaveBeenCalled(); + }); + it("streams the first long final chunk and sends follow-up chunks", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); const longText = "one ".repeat(80); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 3d50a01888c..f227bb19fed 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -901,6 +901,16 @@ export const dispatchTelegramMessage = async ({ deliveryState.markDelivered(); }, }); + const deliverProgressModeFinalAnswer = async ( + payload: ReplyPayload, + text: string, + ): Promise => { + await answerLane.stream?.clear(); + resetDraftLaneState(answerLane); + const delivered = await sendPayload(applyTextToPayload(payload, text), { durable: true }); + answerLane.finalized = true; + return delivered ? { kind: "sent" } : { kind: "skipped" }; + }; if (isDmTopic) { try { @@ -1042,13 +1052,18 @@ export const dispatchTelegramMessage = async ({ if (segment.lane === "reasoning") { reasoningStepState.noteReasoningHint(); } - const result = await deliverLaneText({ - laneName: segment.lane, - text: segment.text, - payload, - infoKind: info.kind, - buttons: telegramButtons, - }); + const result = + streamMode === "progress" && + segment.lane === "answer" && + info.kind === "final" + ? await deliverProgressModeFinalAnswer(payload, segment.text) + : await deliverLaneText({ + laneName: segment.lane, + text: segment.text, + payload, + infoKind: info.kind, + buttons: telegramButtons, + }); if (info.kind === "final") { emitPreviewFinalizedHook(result); }