Skip to content

Telegram DM streaming duplicates final message (draft transport finalization gap) #33492

@Brotherinlaw-13

Description

@Brotherinlaw-13

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:

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

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

  1. Configure Telegram with "streaming": "partial" (default)
  2. Send a message in a DM conversation with the bot
  3. Observe: streaming preview shows text as it generates
  4. Observe: after generation completes, a second identical message appears

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions