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_previewevents 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:
| Timestamp | Expected Message | Actual Message |
|---|---|---|
| T+0ms | User request | User request |
| T+500ms | Tool A executing | Tool A executing |
| T+1000ms | Tool A completed | Tool A completed |
| T+1500ms | Tool B executing | Final answer β οΈ |
| T+2000ms | Tool B completed | Tool B executing |
| T+2500ms | Final answer | Tool 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:
- Enable Debug Logging
export OPENCLAW_LOG_LEVEL=debug export OPENCLAW_LOG_FORMAT=json openclaw start --channel telegram - 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. - Collect Message Timestamps
# Extract message events from logs grep -E '"event":"telegram.*message"' debug.log | \ jq '.timestamp, .message.text, .message.message_id' | \ paste - - - - 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;
}
π Related Errors
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.