fix(telegram): separate progress drafts from final replies

This commit is contained in:
Ayaan Zaidi
2026-05-06 08:04:40 +05:30
parent e27f179361
commit 814b125f11
4 changed files with 60 additions and 9 deletions

View File

@@ -278,7 +278,7 @@ curl "https://api.telegram.org/bot<bot_token>/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<bot_token>/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<bot_token>/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.

View File

@@ -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.

View File

@@ -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);

View File

@@ -901,6 +901,16 @@ export const dispatchTelegramMessage = async ({
deliveryState.markDelivered();
},
});
const deliverProgressModeFinalAnswer = async (
payload: ReplyPayload,
text: string,
): Promise<LaneDeliveryResult> => {
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);
}