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 sendMessage for the first chunk, then editMessageText for updates. The streamMessageId is stored, so the final delivery can edit the preview message in place. ✅ Works correctly.
-
Draft transport (DMs): Uses sendMessageDraft for streaming preview. This does not set streamMessageId because drafts don't have regular message IDs. When the final delivery arrives, resolvePreviewTarget() can't find a preview message to edit, so deliverLaneText falls through to sendPayload() 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 draft
Preview 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
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
2026.3.2"streaming": "partial"(default)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):Preview target resolution (
resolvePreviewTarget):The
stopCreatesFirstPreviewpath (tryUpdatePreviewForLane):Cleanup also fails: In the
finallyblock,stream.clear()callsclearFinalizableDraftMessagewhich triesreadMessageId()→ returnsundefined→ no-op. The draft preview is not cleaned up.Why
blockStreaming: truefixes itWith block streaming enabled,
shouldDropFinalPayloadssuppresses 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:
This ensures DMs use
sendMessage+editMessageText(like groups), wherestreamMessageIdis 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
sendMessageDrafttrack a message ID usable for finalization, or convert the draft to a real message on stop.Workaround
Setting
"blockStreaming": trueprevents duplication but disables live streaming:{ "channels": { "telegram": { "streaming": "partial", "blockStreaming": true } } }Steps to Reproduce
"streaming": "partial"(default)