Skip to content

[Bug]: Telegram streaming message frozen with cursor (▉) when final cursor-removal edit fails after tool call #7183

Description

@austinmw

Bug Description

After a tool call completes in the Telegram gateway, the LLM's post-tool streaming response is sometimes truncated mid-sentence and frozen with a block cursor (▉). The remainder of the response is silently dropped and never delivered.

Confirmed reproduced with a deterministic unit test against the real GatewayStreamConsumer.

Steps to Reproduce

  1. Run Hermes gateway connected to Telegram
  2. Send a message that triggers a tool call (e.g. skill_view)
  3. Observe the streaming response after the tool result
  4. If the final Telegram edit (cursor removal) fails due to flood control or a transient rejection, the message freezes with ▉ and no further content is delivered

Expected Behavior

The full response is delivered. If the final cursor-removal edit fails, the cursor should be cleared and/or the complete text sent as a fallback.

Actual Behavior

The partial message (e.g. 📚 skill_view: "codexbar"\n\nThe ▉) is left frozen permanently. This is most visible with short post-tool responses.

Affected Component

  • Gateway (Telegram/Discord/Slack/WhatsApp)

Messaging Platform

  • Telegram

Operating System

Ubuntu 24.04 (aarch64 — NVIDIA DGX Spark)

Python Version

3.11.15

Hermes Version

v0.8.0 (2026.4.8)

Relevant Logs / Traceback

No explicit traceback — the failure is silent. The final edit_message_text call returns success=False (flood control or transient rejection), triggering the fallback path which silently no-ops.

Root Cause Analysis

Exact failure sequence (reproduced):

SEND  1: 'T ▉'
EDIT  1: 'Th ▉'    ← OK
EDIT  2: 'The ▉'   ← OK
EDIT  3: 'The  ▉'  ← OK
EDIT  4: 'The '    ← FAILS (flood control / transient rejection)
_last_sent_text = 'The  ▉'  ← frozen permanently

Three interlocking issues in gateway/stream_consumer.py:

1. Edit failure arms fallback (stream_consumer.py:332-340)
When any edit_message() returns success=False, the consumer saves _fallback_prefix (the visible text without cursor) and sets _fallback_final_send=True, _edit_supported=False, _already_sent=True.

2. _send_fallback_final() computes empty continuation (stream_consumer.py:260-268)
At stream finish, fallback computes continuation = final_text[len(_fallback_prefix):]. For a short response like "The ", _fallback_prefix is already "The ", so continuation == "". The early-return guard fires, sets already_sent=True, and returns without sending anything or clearing the cursor.

3. Gateway final send is suppressed (gateway/run.py:7359)
Because already_sent=True, the normal gateway send path is skipped. The frozen is the permanent final state.

Why short responses are affected but long ones aren't:
A short response fits in one intermediate edit cycle. The only remaining change at finish is cursor removal — so continuation is empty and fallback silently no-ops. Longer responses have unseen tail text, so fallback sends that as a new message (bug still fires internally but is masked).

Proposed Fix

Option A (minimal): In _send_fallback_final() (stream_consumer.py:265-268), before returning on empty continuation, do a final edit to strip the cursor if _message_id is set and _last_sent_text ends with the cursor.

Option B (cleaner): In the got_done branch (stream_consumer.py:167-179), always attempt cursor removal as a dedicated step before invoking fallback logic. If that edit fails, send the full accumulated text as a new message rather than computing a potentially-empty continuation.

Repro Script

import asyncio, sys
sys.path.insert(0, "/path/to/hermes-agent")
from gateway.platforms.base import SendResult
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig

class MockAdapter:
    MAX_MESSAGE_LENGTH = 4096
    def __init__(self): self.send_calls=[]; self.edit_calls=[]; self._ec=0
    async def send(self, chat_id, content, metadata=None):
        self.send_calls.append(content)
        return SendResult(success=True, message_id="msg-1")
    async def edit_message(self, chat_id, message_id, content):
        self._ec += 1; self.edit_calls.append(content)
        if content == "The ":  # fail on final cursor-removal edit
            return SendResult(success=False, error="flood_control:6.0")
        return SendResult(success=True, message_id=message_id)

async def main():
    adapter = MockAdapter()
    consumer = GatewayStreamConsumer(adapter, "123",
        config=StreamConsumerConfig(edit_interval=0.0, buffer_threshold=1, cursor=" ▉"))
    task = asyncio.create_task(consumer.run())
    consumer.on_delta(None)  # _NEW_SEGMENT (tool boundary)
    await asyncio.sleep(0.08)
    for ch in ["T", "h", "e", " "]:
        consumer.on_delta(ch); await asyncio.sleep(0.08)
    consumer.finish()
    await task
    frozen = consumer._last_sent_text.endswith(consumer.cfg.cursor)
    print("REPRODUCED" if frozen else "NOT REPRODUCED")

asyncio.run(main())

Are you willing to submit a PR?

  • I'd like to fix this myself and submit a PR

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions