-
-
Notifications
You must be signed in to change notification settings - Fork 69.5k
Telegram DM streaming duplicates final message (draft transport finalization gap) #33492
Description
Description
When streaming is enabled (streaming: "partial") in Telegram DMs, the final reply message is sent as a new message even though the streaming preview already displayed the complete text. The user sees the same content twice: once in the streaming preview, and again as a separate final message.
This does not happen in groups, which use "message" transport instead of "draft" transport.
Environment
- OpenClaw version:
2026.3.2 - Channel: Telegram
- Config:
"streaming": "partial"(default) - Scope: DMs only (groups are unaffected)
Root Cause Analysis
The issue is in the draft stream finalization path for Telegram DMs.
How streaming works in Telegram
There are two preview transports:
-
Message transport (groups): Uses
sendMessagefor the first chunk, theneditMessageTextfor updates. ThestreamMessageIdis stored, so the final delivery can edit the preview message in place. ✅ Works correctly. -
Draft transport (DMs): Uses
sendMessageDraftfor streaming preview. This does not setstreamMessageIdbecause drafts don't have regular message IDs. When the final delivery arrives,resolvePreviewTarget()can't find a preview message to edit, sodeliverLaneTextfalls through tosendPayload()which sends a new message. ❌ Duplicated.
Code path
Draft transport preference in DMs (createTelegramDraftStream):
const prefersDraftTransport = requestedPreviewTransport === "draft"
? true
: requestedPreviewTransport === "message"
? false
: params.thread?.scope === "dm"; // <-- DMs default to draftPreview target resolution (resolvePreviewTarget):
function resolvePreviewTarget(params) {
const lanePreviewMessageId = params.lane.stream?.messageId();
// ^ Returns streamMessageId, which is NEVER set in draft transport
return {
previewMessageId: typeof previewMessageId === "number" ? previewMessageId : void 0,
stopCreatesFirstPreview: params.stopBeforeEdit && !hadPreviewMessage && params.context === "final"
};
}The stopCreatesFirstPreview path (tryUpdatePreviewForLane):
if (resolvePreviewTarget(...).stopCreatesFirstPreview) {
lane.stream.update(text); // Update draft with final text
await params.stopDraftLane(lane); // Stop the draft
const previewTargetAfterStop = resolvePreviewTarget({lane, stopBeforeEdit: false, context});
if (typeof previewTargetAfterStop.previewMessageId !== "number") return false;
// ^ Still undefined! Draft transport never sets streamMessageId
// Falls through → sendPayload → NEW message → DUPLICATE
}Cleanup also fails: In the finally block, stream.clear() calls clearFinalizableDraftMessage which tries readMessageId() → returns undefined → no-op. The draft preview is not cleaned up.
Why blockStreaming: true fixes it
With block streaming enabled, shouldDropFinalPayloads suppresses the final payload when streaming has occurred. However, this also disables the live streaming preview, degrading UX.
Proposed Fix
Option A: Force message transport for answer lane in DMs (recommended)
The reasoning lane already forces message transport in DMs. Apply the same to the answer lane:
// Current:
const useMessagePreviewTransportForDmReasoning =
laneName === "reasoning" && threadSpec?.scope === "dm" && canStreamAnswerDraft;
previewTransport: useMessagePreviewTransportForDmReasoning ? "message" : "auto",
// Proposed:
const useMessageTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft;
previewTransport: useMessageTransportForDm ? "message" : "auto",This ensures DMs use sendMessage + editMessageText (like groups), where streamMessageId is properly tracked and the final delivery edits the preview in place.
Option B: Add config option for preview transport
{
"channels": {
"telegram": {
"streaming": "partial",
"previewTransport": "message"
}
}
}Option C: Fix draft transport finalization
Make sendMessageDraft track a message ID usable for finalization, or convert the draft to a real message on stop.
Workaround
Setting "blockStreaming": true prevents duplication but disables live streaming:
{
"channels": {
"telegram": {
"streaming": "partial",
"blockStreaming": true
}
}
}Steps to Reproduce
- Configure Telegram with
"streaming": "partial"(default) - Send a message in a DM conversation with the bot
- Observe: streaming preview shows text as it generates
- Observe: after generation completes, a second identical message appears