April 24, 2026 β€’ Version: 2026.5.3-1

Telegram: Final Assistant Reply Ordering in Streaming Mode with Tool Calls

When the assistant streams responses with multiple tool calls in Telegram, the final reply may appear above intermediate tool call progress messages, breaking chronological order.

πŸ” Symptoms

Visual Manifestation

The Telegram chat displays messages in an incorrect chronological sequence:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ [Assistant] Final summary answer β”‚ ← Should be LAST β”‚ [System] Tool B completed βœ“ β”‚ ← Appears after β”‚ [System] Tool A completed βœ“ β”‚ β”‚ [System] Tool B executing… β”‚ β”‚ [System] Tool A executing… β”‚ β”‚ [User] Original request… β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Technical Detection Criteria

The issue occurs when all of the following conditions are met:

  • The Telegram adapter is running in streaming mode
  • The assistant response triggers multiple sequential tool calls (β‰₯2)
  • Tool call progress messages (streaming updates) are sent to Telegram
  • The final assistant reply is delivered after tool messages have been posted

Diagnostic Queries

Check the message sequence in debug logs:

# Enable Telegram adapter debug logging
OPENCLAW_LOG_LEVEL=debug openclaw

# Filter for message ordering events
grep -E "(bubble_id|preview|tool.*call|answer.*preview)" debug.log | tail -100

Look for entries indicating:

  • Multiple answer_preview events for the same turn
  • Tool execution messages with timestamps later than final bubble replacement
  • Missing sequence numbering on final replacement messages

Error Pattern Recognition

The Telegram message timeline diverges from expected behavior:

TimestampExpected MessageActual Message
T+0msUser requestUser request
T+500msTool A executingTool A executing
T+1000msTool A completedTool A completed
T+1500msTool B executingFinal answer ⚠️
T+2000msTool B completedTool B executing
T+2500msFinal answerTool B completed

🧠 Root Cause

Architectural Analysis

The issue stems from a race condition in the Telegram chat adapter’s message sequencing logic. The previous fix from v2026.5.3 addressed a specific case but missed an edge condition.

Message Lifecycle in Streaming Mode

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ TELEGRAM STREAMING MESSAGE FLOW β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ [1] User Request β†’ [2] Generate Response β†’ [3] Stream Preview β”‚ β”‚ ↓ β”‚ β”‚ [4] Edit Preview ←────────────────────── Streaming tokens β”‚ β”‚ ↓ β”‚ β”‚ [5] Tool Call Detected ──────────→ [6] Send Progress Message β”‚ β”‚ ↓ ↓ β”‚ β”‚ [7] Tool Execution ─────────────→ [8] Send Completion Message β”‚ β”‚ ↓ ↓ β”‚ β”‚ [9] Replace Final Bubble ←──────── [10] Final Answer Delivered β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Race Condition

The v2026.5.3 fix introduced logic to “force a fresh final message when a visible non-preview bubble was delivered after the active answer preview.” However, the implementation contains a timing flaw:

Step 1: Preview Bubble Created When streaming begins, a preview bubble is created with a specific bubble_id and tracked as the “active answer preview.”

Step 2: Tool Message Interleaving During tool execution, progress messages are sent via the standard Telegram API. These messages are appended to the chat sequence.

Step 3: Final Bubble Replacement Race The code that detects “non-preview bubbles delivered after active preview” relies on checking bubble_id sequence numbers. When tool messages arrive concurrently with the stream completing:

Stream Thread: Tool Thread: ───────────────── ───────────────── check bubble_id=5
send tool_msg_1 send tool_msg_2 bubble_id now=7 stream finishes
check bubble_id=7 ←─── stale check, already past

REPLACEMENT SKIPPED ← BUG: final answer sent as new message

The check sees current_bubble_id > expected_preview_id and incorrectly assumes the preview was already replaced, when in reality tool messages incremented the counter without replacing the preview.

Code Path Analysis

The problematic logic resides in:

// packages/openclaw-adapter-telegram/src/TelegramChatAdapter.ts

private async ensureFinalBubbleFreshness(): Promise<boolean> {
  const activePreview = this.activeAnswerPreviewBubbleId;
  
  // This check fails when tool messages increment the counter
  if (this.lastBubbleId > activePreview) {
    // Previous fix assumed this meant preview was replaced
    // But it could mean tool messages were sent
    this.activeAnswerPreviewBubbleId = null;  // ← WRONG CONCLUSION
    return false;  // Skip bubble replacement
  }
  
  return true;  // Safe to replace preview
}

The fix should distinguish between:

  • Preview replacement: A new assistant bubble was sent (legitimately replaces preview)
  • Tool messages: System bubbles that increment the counter but don’t replace preview

πŸ› οΈ Step-by-Step Fix

Immediate Workaround

For immediate relief, disable streaming mode in Telegram configuration:

# In openclaw.yaml or environment variables
channels:
  telegram:
    streaming:
      enabled: false

# Alternative: Disable per-conversation
# Set /streaming off via Telegram bot command

This forces sequential message delivery but eliminates the streaming preview experience.

Permanent Fix

Apply the following changes to the Telegram adapter source code:

Step 1: Track Preview Replacement Separately

Add a dedicated flag to track whether the preview has been legitimately replaced:

// In TelegramChatAdapter class

private activeAnswerPreviewBubbleId: string | null = null;
private previewWasReplacedByAssistant: boolean = false;  // NEW FLAG

public async streamAnswer(
  context: StreamingMessageContext,
  generator: AsyncIterable<StreamingMessageEvent>
): Promise<void> {
  this.previewWasReplacedByAssistant = false;  // RESET on new stream
  
  // ... streaming logic ...
  
  await this.handleStreamingComplete(context);
}

protected async handleStreamingComplete(
  context: StreamingMessageContext
): Promise<void> {
  // When sending final assistant response, mark the flag
  await this.sendFinalAssistantMessage(context);
  this.previewWasReplacedByAssistant = true;
}

Step 2: Modify Bubble Freshness Check

Replace the flawed counter comparison with explicit flag checking:

// BEFORE (broken logic)
private async ensureFinalBubbleFreshness(): Promise<boolean> {
  if (this.lastBubbleId > this.activeAnswerPreviewBubbleId) {
    this.activeAnswerPreviewBubbleId = null;
    return false;
  }
  return true;
}

// AFTER (corrected logic)
private async ensureFinalBubbleFreshness(): Promise<boolean> {
  // First check: was the preview explicitly replaced?
  if (this.previewWasReplacedByAssistant) {
    this.activeAnswerPreviewBubbleId = null;
    return false;
  }
  
  // Second check: is there an active preview to replace?
  if (this.activeAnswerPreviewBubbleId === null) {
    return false;
  }
  
  // Safe to proceed with replacement
  return true;
}

Step 3: Update Tool Message Handler

Ensure tool messages don’t incorrectly set the replacement flag:

public async sendToolProgressMessage(
  toolId: string,
  status: 'executing' | 'completed' | 'failed',
  message: string
): Promise<void> {
  // Tool messages are system messages, not assistant replacements
  // Do NOT set previewWasReplacedByAssistant = true here
  
  const telegramMessage = this.formatToolMessage(toolId, status, message);
  const sentBubble = await this.sendTelegramMessage(telegramMessage);
  
  // Update counter for tracking, but this is not a preview replacement
  this.lastBubbleId = sentBubble.bubble_id;
  // NOTE: previewWasReplacedByAssistant stays unchanged
}

Step 4: Clear Flag on New Turn

Reset the tracking state when a new user message initiates a new turn:

public async handleIncomingMessage(
  update: TelegramUpdate
): Promise<void> {
  // New turn detected - reset all preview tracking
  this.activeAnswerPreviewBubbleId = null;
  this.previewWasReplacedByAssistant = false;
  this.lastBubbleId = null;
  
  // ... rest of handler ...
}

Git Patch

Apply this unified patch to resolve the issue:

diff --git a/packages/openclaw-adapter-telegram/src/TelegramChatAdapter.ts b/packages/openclaw-adapter-telegram/src/TelegramChatAdapter.ts
--- a/packages/openclaw-adapter-telegram/src/TelegramChatAdapter.ts
+++ b/packages/openclaw-adapter-telegram/src/TelegramChatAdapter.ts
@@ -45,6 +45,7 @@ export class TelegramChatAdapter extends ChatAdapter {
   private activeAnswerPreviewBubbleId: string | null = null;
   private lastBubbleId: string | null = null;
+  private previewWasReplacedByAssistant: boolean = false;
   
   private pendingToolCalls: Map<string, ToolCallState> = new Map();
   
@@ -112,6 +113,7 @@ export class TelegramChatAdapter extends ChatAdapter {
   }
 
   public async streamAnswer(context: StreamingMessageContext, generator: AsyncIterable<StreamingMessageEvent>): Promise<void> {
+    this.previewWasReplacedByAssistant = false;
     // ... existing logic ...
   }
 
@@ -178,10 +180,14 @@ export class TelegramChatAdapter extends ChatAdapter {
   }
 
   private async ensureFinalBubbleFreshness(): Promise<boolean> {
-    if (this.lastBubbleId > this.activeAnswerPreviewBubbleId) {
-      this.activeAnswerPreviewBubbleId = null;
+    if (this.previewWasReplacedByAssistant) {
+      // Preview was explicitly replaced by assistant message
       return false;
     }
-    return this.activeAnswerPreviewBubbleId !== null;
+    
+    if (this.activeAnswerPreviewBubbleId === null) {
+      return false;
+    }
+    
+    return true;
   }

πŸ§ͺ Verification

Test Case Definition

Create a test that reproduces the original issue:

// tests/channels/telegram/message-ordering.test.ts

import { TelegramChatAdapter } from '../../../packages/openclaw-adapter-telegram/src';
import { createMockTelegramUpdate, createToolExecutionEvents } from '../helpers';

describe('Telegram Message Ordering', () => {
  let adapter: TelegramChatAdapter;
  let messages: string[] = [];
  
  beforeEach(async () => {
    adapter = new TelegramChatAdapter({
      botToken: 'test-token',
      // Use mock transport that captures message order
      transport: new MockTelegramTransport((msg) => {
        messages.push(msg.text);
      }),
    });
    messages = [];
  });
  
  test('final assistant reply appears after tool messages', async () => {
    // Step 1: Send user message that triggers tool calls
    const update = createMockTelegramUpdate({
      message: { text: 'Run my morning routine' },
    });
    await adapter.handleIncomingMessage(update);
    
    // Step 2: Stream response with tool calls
    const events = [
      ...createToolExecutionEvents('tool_A', 'getting emails'),
      { type: 'tool_call', toolCall: { id: 'call_A', name: 'fetch_email' } },
      { type: 'content_block', content: { text: 'Found 5 new emails' } },
      ...createToolExecutionEvents('tool_B', 'checking calendar'),
      { type: 'tool_call', toolCall: { id: 'call_B', name: 'check_calendar' } },
      { type: 'content_block', content: { text: '3 events today' } },
      { type: 'done', finalMessage: 'Summary of your morning routine' },
    ];
    
    await adapter.streamAnswer({ turnId: 'turn_1' }, generateFromArray(events));
    await adapter.waitForPendingMessages();
    
    // Step 3: Verify chronological order
    expect(messages).toEqual([
      'Found 5 new emails',
      'Tool A completed',
      'Checking calendar...',
      '3 events today',
      'Tool B completed',
      'Summary of your morning routine',  // Final answer MUST be last
    ]);
  });
});

Manual Verification Procedure

To verify the fix on a running system:

  1. Enable Debug Logging
    export OPENCLAW_LOG_LEVEL=debug
    export OPENCLAW_LOG_FORMAT=json
    openclaw start --channel telegram
    
  2. Trigger the Issue Scenario Send a message that requires multiple sequential tool calls. Example prompt:
    Search my emails for messages from this week, then check my calendar for today, and summarize what you found.
    
  3. Collect Message Timestamps
    # Extract message events from logs
    grep -E '"event":"telegram.*message"' debug.log | \
      jq '.timestamp, .message.text, .message.message_id' | \
      paste - - -
    
  4. Verify Order The output should show:
    • Tool progress messages appearing before final summary
    • Each message with monotonically increasing timestamp
    • No message IDs that suggest out-of-order insertion

Integration Test Run

Run the full test suite for the Telegram adapter:

cd packages/openclaw-adapter-telegram
npm test -- --testPathPattern="message-ordering|streaming"

Expected output:

  Telegram Message Ordering
    βœ“ final assistant reply appears after tool messages (150ms)
    βœ“ handles concurrent tool call completion
    βœ“ preserves order when preview updates during tool execution
    βœ“ handles rapid successive tool calls

  Telegram Streaming
    βœ“ streams tokens to preview bubble
    βœ“ replaces preview with final on completion
    βœ“ handles streaming interruption

Test Suites: 2 passed, 2 total
Tests:       7 passed, 7 total

⚠️ Common Pitfalls

Environment-Specific Traps

Docker Container Isolation

When running OpenClaw in Docker, message ordering issues may appear due to non-deterministic scheduling:

# DON'T: Run with container resource limits that cause scheduling issues
docker run --cpus="0.5" --memory="256m" openclaw:latest

# DO: Allow sufficient resources for concurrent operations
docker run --cpus="2" --memory="512m" --pids-limit=200 openclaw:latest

macOS Event Loop

The Telegram adapter uses setImmediate and process.nextTick for async coordination. macOS may exhibit different timing characteristics:

typescript // If tests pass on Linux but fail on macOS, adjust timing: // packages/openclaw-adapter-telegram/src/async-coordination.ts

export const TICK_ADJUSTMENT = process.platform === ‘darwin’ ? 50 : 0;

Configuration Pitfalls

Conflicting Streaming Settings

Multiple streaming configuration options may conflict:

# WRONG - Multiple streaming configs
channels:
  telegram:
    streaming:
      enabled: true
    api:
      streaming_mode: false  # Conflicts!

# CORRECT - Single streaming config
channels:
  telegram:
    streaming:
      enabled: true  # or false

Preview Refresh Rate

Excessive preview updates may cause ordering issues under load:

# BEFORE - Rapid updates cause race conditions
channels:
  telegram:
    preview_refresh_ms: 50

# AFTER - Slower refresh allows proper sequencing
channels:
  telegram:
    preview_refresh_ms: 200

Development Pitfalls

Not Resetting State Between Tests

Ensure each test fully resets adapter state:

// INCOMPLETE - May cause test bleed
beforeEach(() => {
  adapter = new TelegramChatAdapter({...});
});

// COMPLETE - Full reset
beforeEach(() => {
  adapter = new TelegramChatAdapter({...});
  adapter.resetState();  // Clear all tracking flags
});

Mocking the Telegram API Incorrectly

When mocking for tests, ensure message IDs increment atomically:

// WRONG - IDs don't reflect real API behavior
const mockSendMessage = jest.fn().mockResolvedValue({ message_id: 1 });

// CORRECT - IDs increment per call
let messageIdCounter = 100;
const mockSendMessage = jest.fn().mockImplementation(() => ({
  message_id: ++messageIdCounter,
  date: Math.floor(Date.now() / 1000),
  chat: { id: 123456 },
}));

Edge Cases

Rapid Tool Call Completion

When multiple tools complete nearly simultaneously:

User: “do everything” Tool A: completes at T+100ms Tool B: completes at T+105ms Tool C: completes at T+110ms

The fix must handle microsecond-level timing differences without reordering.

Preview Already Deleted

If the Telegram message is deleted (user swipes away preview), the replacement logic must handle the missing bubble:

// Handle MISSING_MESSAGE error from Telegram
try {
  await this.telegramApi.editMessageText({
    chat_id: chatId,
    message_id: previewBubbleId,
    text: finalText,
  });
} catch (error) {
  if (error.response?.error_code === 400 && 
      error.response?.description?.includes('message to edit not found')) {
    // Preview was deleted - send as new message
    await this.sendNewBubble(finalText);
  }
  throw error;
}

Issue #76529 β€” Original Fix (v2026.5.3)

Description: Initial report of final reply appearing above tool call messages.

Resolution: Added “force a fresh final message when a visible non-preview bubble was delivered after the active answer preview” logic.

Status: Partial fix β€” addressed the primary case but missed tool message interleaving scenario.


Issue #76530 β€” Preview Bubble Not Updating During Streaming

Description: Preview bubble shows stale content while streaming, only updates on final message.

Symptoms: Users see “…” indefinitely until all tool calls complete.

Root Cause: Related to the same message lifecycle management β€” preview refresh was being suppressed during tool execution.


Issue #76531 β€” Tool Messages Sent After Conversation End

Description: Tool call progress messages appear in next conversation turn, after new user message.

Symptoms: Stale tool completion messages appearing out of temporal context.

Root Cause: Async tool execution completing after the turn context was already closed, with no mechanism to discard or associate late-arriving messages.


Error Code: TG_MSG_REORDER_001

Full Name: Telegram Message Reorder Sequence Violation

Trigger: Detected when final assistant message has lower message_id than a subsequently-arrived tool message.

Detection Query:

grep "TG_MSG_REORDER_001" debug.log

Recommended Action: Apply the bubble freshness fix from Step 3 above.


Error Code: TG_STREAM_TIMEOUT_002

Full Name: Telegram Streaming Timeout

Trigger: Stream completes but final message replacement exceeds 30-second window.

Relation: May manifest as message ordering issues if timeout handling attempts to resend final message after tool messages.


Historical Pattern: v2026.4.x Bubble Management Refactor

The v2026.4.x series introduced significant changes to bubble management across all adapters. The Telegram adapter’s streaming logic was partially rewritten during this refactor. The current issue traces to incomplete state tracking for the new bubble lifecycle.

Migration Note: Projects upgrading from v2026.3.x to v2026.5.x should verify streaming behavior with multi-tool-call scenarios, as the new bubble management API changed async coordination patterns.

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.