Skip to content

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

@austinmw

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