Skip to content

Commit 814b125

Browse files
committed
fix(telegram): separate progress drafts from final replies
1 parent e27f179 commit 814b125

4 files changed

Lines changed: 60 additions & 9 deletions

File tree

docs/channels/telegram.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
278278
Requirement:
279279

280280
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
281-
- `progress` keeps one editable status draft and updates it with tool progress until final delivery
281+
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
282282
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
283283
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
284284
- 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<bot_token>/getUpdates"
317317
}
318318
```
319319

320-
For progress-draft mode, put the same command-text policy under `streaming.progress`:
320+
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`:
321321

322322
```json
323323
{
@@ -345,6 +345,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
345345

346346
- short DM/group/topic previews: OpenClaw keeps the same preview message and performs the final edit in place
347347
- 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
348+
- progress-mode finals clear the status draft and use normal final delivery instead of editing the draft into the answer
348349
- if the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview
349350

350351
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.

docs/concepts/streaming.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ Telegram:
161161

162162
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
163163
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
164+
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
164165
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
165166
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
166167
- `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery.

extensions/telegram/src/bot-message-dispatch.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,40 @@ describe("dispatchTelegramMessage draft streaming", () => {
919919
expect(deliverReplies).not.toHaveBeenCalled();
920920
});
921921

922+
it("keeps progress updates in a draft and sends the final answer normally", async () => {
923+
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
924+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
925+
async ({ dispatcherOptions, replyOptions }) => {
926+
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
927+
await replyOptions?.onItemEvent?.({
928+
kind: "command",
929+
name: "exec",
930+
progressText: "git rev-parse --abbrev-ref HEAD",
931+
});
932+
await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" });
933+
return { queuedFinal: true };
934+
},
935+
);
936+
937+
await dispatchWithContext({
938+
context: createContext(),
939+
streamMode: "progress",
940+
telegramCfg: { streaming: { mode: "progress" } },
941+
});
942+
943+
expect(answerDraftStream.update).toHaveBeenCalledWith(
944+
expect.stringMatching(/`🛠 Exec: git rev-parse --abbrev-ref HEAD`$/),
945+
);
946+
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date");
947+
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
948+
expect(deliverReplies).toHaveBeenCalledWith(
949+
expect.objectContaining({
950+
replies: [expect.objectContaining({ text: "Branch is up to date" })],
951+
}),
952+
);
953+
expect(editMessageTelegram).not.toHaveBeenCalled();
954+
});
955+
922956
it("streams the first long final chunk and sends follow-up chunks", async () => {
923957
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
924958
const longText = "one ".repeat(80);

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,16 @@ export const dispatchTelegramMessage = async ({
901901
deliveryState.markDelivered();
902902
},
903903
});
904+
const deliverProgressModeFinalAnswer = async (
905+
payload: ReplyPayload,
906+
text: string,
907+
): Promise<LaneDeliveryResult> => {
908+
await answerLane.stream?.clear();
909+
resetDraftLaneState(answerLane);
910+
const delivered = await sendPayload(applyTextToPayload(payload, text), { durable: true });
911+
answerLane.finalized = true;
912+
return delivered ? { kind: "sent" } : { kind: "skipped" };
913+
};
904914

905915
if (isDmTopic) {
906916
try {
@@ -1042,13 +1052,18 @@ export const dispatchTelegramMessage = async ({
10421052
if (segment.lane === "reasoning") {
10431053
reasoningStepState.noteReasoningHint();
10441054
}
1045-
const result = await deliverLaneText({
1046-
laneName: segment.lane,
1047-
text: segment.text,
1048-
payload,
1049-
infoKind: info.kind,
1050-
buttons: telegramButtons,
1051-
});
1055+
const result =
1056+
streamMode === "progress" &&
1057+
segment.lane === "answer" &&
1058+
info.kind === "final"
1059+
? await deliverProgressModeFinalAnswer(payload, segment.text)
1060+
: await deliverLaneText({
1061+
laneName: segment.lane,
1062+
text: segment.text,
1063+
payload,
1064+
infoKind: info.kind,
1065+
buttons: telegramButtons,
1066+
});
10521067
if (info.kind === "final") {
10531068
emitPreviewFinalizedHook(result);
10541069
}

0 commit comments

Comments
 (0)