{"repo":"NousResearch/hermes-agent","public_id":"7183","display_label":"Telegram streaming message left frozen with cursor character","summary":"These items concern a streaming message in the gateway (reported for Telegram) that can stay frozen with a cursor (▉) when the final cursor-removal edit fails after a tool call. Two members are fix proposals that strip the cursor when the fallback continuation is empty, and one is the bug report; their outcomes are not stated in the titles.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12472,"merged_at":"2026-04-19T08:51:12+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-19T08:51:12+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":7183,"pr_number":12472,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-19T08:51:12+00:00"}],"members":[{"kind":"issue","number":7183,"title":"[Bug]: Telegram streaming message frozen with cursor (▉) when final cursor-removal edit fails after tool call","url":null,"state":"closed"},{"kind":"pr","number":7429,"title":"fix(gateway): strip cursor from frozen message when fallback continuation is empty","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12472","number":12472},{"kind":"pr","value":"#10807","number":10807}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12472,"title":"fix(gateway): strip cursor from frozen message on empty fallback continuation (#7183)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/stream_consumer.py","tests/gateway/test_stream_consumer.py"],"fix_hunks":[{"file":"gateway/stream_consumer.py","hunks":["@@ -571,6 +571,30 @@ async def _send_fallback_final(self, text: str) -> None:\n             if final_text.strip() and final_text != self._visible_prefix():\n                 continuation = final_text\n             else:\n+                # Defence-in-depth for #7183: the last edit may still show the\n+                # cursor character because fallback mode was entered after an\n+                # edit failure left it stuck.  Try one final edit to strip it\n+                # so the message doesn't freeze with a visible ▉.  Best-effort\n+                # — if this edit also fails (flood control still active),\n+                # _try_strip_cursor has already been called on fallback entry\n+                # and the adaptive-backoff retries will have had their shot.\n+                if (\n+                    self._message_id\n+                    and self._last_sent_text\n+                    and self.cfg.cursor\n+                    and self._last_sent_text.endswith(self.cfg.cursor)\n+                ):\n+                    clean_text = self._last_sent_text[:-len(self.cfg.cursor)]\n+                    try:\n+                        result = await self.adapter.edit_message(\n+                            chat_id=self.chat_id,\n+                            message_id=self._message_id,\n+                            content=clean_text,\n+                        )\n+                        if result.success:\n+                            self._last_sent_text = clean_text\n+                    except Exception:\n+                        pass\n                 self._already_sent = True\n                 self._final_response_sent = True\n                 return"]},{"file":"tests/gateway/test_stream_consumer.py","hunks":["@@ -1216,3 +1216,87 @@ async def test_default_mode_still_triggers_intermediate_edits(self):\n         # text, the consumer may send then edit, or just send once at got_done.\n         # The key assertion: this doesn't break.\n         assert adapter.send.call_count >= 1\n+\n+\n+# ── Cursor stripping on fallback (#7183) ────────────────────────────────────\n+\n+\n+class TestCursorStrippingOnFallback:\n+    \"\"\"Regression: cursor must be stripped when fallback continuation is empty (#7183).\n+\n+    When _send_fallback_final is called with nothing new to deliver (the visible\n+    partial already matches final_text), the last edit may still show the cursor\n+    character because fallback mode was entered after a failed edit.  Before the\n+    fix this would leave the message permanently frozen with a visible ▉.\n+    \"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_cursor_stripped_when_continuation_empty(self):\n+        \"\"\"_send_fallback_final must attempt a final edit to strip the cursor.\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.edit_message = AsyncMock(\n+            return_value=SimpleNamespace(success=True, message_id=\"msg-1\")\n+        )\n+\n+        consumer = GatewayStreamConsumer(\n+            adapter, \"chat-1\",\n+            config=StreamConsumerConfig(cursor=\" ▉\"),\n+        )\n+        consumer._message_id = \"msg-1\"\n+        consumer._last_sent_text = \"Hello world ▉\"\n+        consumer._fallback_final_send = False\n+\n+        await consumer._send_fallback_final(\"Hello world\")\n+\n+        adapter.edit_message.assert_called_once()\n+        call_args = adapter.edit_message.call_args\n+        assert call_args.kwargs[\"content\"] == \"Hello world\"\n+        assert consumer._already_sent is True\n+        # _last_sent_text should reflect the cleaned text after a successful strip\n+        assert consumer._last_sent_text == \"Hello world\"\n+\n+    @pytest.mark.asyncio\n+    async def test_cursor_not_stripped_when_no_cursor_configured(self):\n+        \"\"\"No edit attempted when cursor is not configured.\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.edit_message = AsyncMock()\n+\n+        consumer = GatewayStreamConsumer(\n+            adapter, \"chat-1\",\n+            config=StreamConsumerConfig(cursor=\"\"),\n+        )\n+        consumer._message_id = \"msg-1\"\n+        consumer._last_sent_text = \"Hello world\"\n+        consumer._fallback_final_send = False\n+\n+        await consumer._send_fallback_final(\"Hello world\")\n+\n+        adapter.edit_message.assert_not_called()\n+        assert consumer._already_sent is True\n+\n+    @pytest.mark.asyncio\n+    async def test_cursor_strip_edit_failure_handled(self):\n+        \"\"\"If the cursor-stripping edit itself fails, it must not crash and\n+        must not corrupt _last_sent_text.\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.edit_message = AsyncMock(\n+            return_value=SimpleNamespace(success=False, error=\"flood_control\")\n+        )\n+\n+        consumer = GatewayStreamConsumer(\n+            adapter, \"chat-1\",\n+            config=StreamConsumerConfig(cursor=\" ▉\"),\n+        )\n+        consumer._message_id = \"msg-1\"\n+        consumer._last_sent_text = \"Hello ▉\"\n+        consumer._fallback_final_send = False\n+\n+        await consumer._send_fallback_final(\"Hello\")\n+\n+        # Should still set already_sent despite the cursor-strip edit failure\n+        assert consumer._already_sent is True\n+        # _last_sent_text must NOT be updated when the edit failed\n+        assert consumer._last_sent_text == \"Hello ▉\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}