{"repo":"NousResearch/hermes-agent","public_id":"8326","display_label":"Streaming cursor and display artifacts fixes","summary":"These issues and fixes address visual artifacts during streaming responses, including a stuck or black-square cursor and related display problems on certain platforms. The fixes span WeChat (Weixin) and gateway-level handling for platforms that do not support message editing.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":8665,"merged_at":"2026-04-12T23:43:25+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-12T23:43:25+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":7529,"pr_number":8665,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-12T23:43:25+00:00"},{"issue_number":7617,"pr_number":8665,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-12T23:43:25+00:00"},{"issue_number":8307,"pr_number":8665,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-12T23:43:25+00:00"},{"issue_number":8326,"pr_number":8665,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-12T23:43:25+00:00"}],"members":[{"kind":"issue","number":8326,"title":"[Bug]: Enabling streaming causes a black square to appear at the end of the message.","url":null,"state":"closed"},{"kind":"pr","number":8525,"title":"fix(gateway): avoid streaming cursor artifacts on no-edit platforms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8665","number":8665}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":8665,"title":"fix(weixin): streaming cursor, media uploads, markdown links, blank messages","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py","gateway/run.py","tests/gateway/test_weixin.py"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -112,6 +112,7 @@\n _HEADER_RE = re.compile(r\"^(#{1,6})\\s+(.+?)\\s*$\")\n _TABLE_RULE_RE = re.compile(r\"^\\s*\\|?(?:\\s*:?-{3,}:?\\s*\\|)+\\s*:?-{3,}:?\\s*\\|?\\s*$\")\n _FENCE_RE = re.compile(r\"^```([^\\n`]*)\\s*$\")\n+_MARKDOWN_LINK_RE = re.compile(r\"\\[([^\\]]+)\\]\\(([^)]+)\\)\")\n \n \n def check_weixin_requirements() -> bool:","@@ -398,15 +399,16 @@ async def _send_message(\n     context_token: [redacted:secret-assignment]\n     client_id: str,\n ) -> None:\n+    if not text or not text.strip():\n+        raise ValueError(\"_send_message: text must not be empty\")\n     message: Dict[str, Any] = {\n         \"from_user_id\": \"\",\n         \"to_user_id\": to,\n         \"client_id\": client_id,\n         \"message_type\": MSG_TYPE_BOT,\n         \"message_state\": MSG_STATE_FINISH,\n+        \"item_list\": [{\"type\": ITEM_TEXT, \"text_item\": {\"text\": text}}],\n     }\n-    if text:\n-        message[\"item_list\"] = [{\"type\": ITEM_TEXT, \"text_item\": {\"text\": text}}]\n     if context_token:\n         [redacted:secret-assignment]\"context_token\"] = context_token\n     await _api_post(","@@ -499,13 +501,15 @@ async def _upload_ciphertext(\n     session: \"aiohttp.ClientSession\",\n     *,\n     ciphertext: bytes,\n-    cdn_base_url: str,\n-    upload_param: str,\n-    filekey: str,\n+    upload_url: str,\n ) -> str:\n-    url = _cdn_upload_url(cdn_base_url, upload_param, filekey)\n+    \"\"\"Upload encrypted media to the CDN.\n+\n+    Accepts either a constructed CDN URL (from upload_param) or a direct\n+    upload_full_url — both use POST with the raw ciphertext as the body.\n+    \"\"\"\n     timeout = aiohttp.ClientTimeout(total=120)\n-    async with session.post(url, data=ciphertext, headers={\"Content-Type\": \"application/octet-stream\"}, timeout=timeout) as response:\n+    async with session.post(upload_url, data=ciphertext, headers={\"Content-Type\": \"application/octet-stream\"}, timeout=timeout) as response:\n         if response.status == 200:\n             encrypted_param = response.headers.get(\"x-encrypted-param\")\n             if encrypted_param:","@@ -649,7 +653,7 @@ def _normalize_markdown_blocks(content: str) -> str:\n             result.append(_rewrite_table_block_for_weixin(table_lines))\n             continue\n \n-        result.append(_rewrite_headers_for_weixin(line))\n+        result.append(_MARKDOWN_LINK_RE.sub(r\"\\1 (\\2)\", _rewrite_headers_for_weixin(line)))\n         i += 1\n \n     normalized = \"\\n\".join(item.rstrip() for item in result)","@@ -811,6 +815,8 @@ def _split_text_for_weixin_delivery(\n     ``platforms.weixin.extra.split_multiline_messages`` (``true`` / ``false``)\n     or the env var ``WEIXIN_SPLIT_MULTILINE_MESSAGES``.\n     \"\"\"\n+    if not content:\n+        return []\n     if split_per_line:\n         # Legacy: one message per top-level delivery unit.\n         if len(content) <= max_length and \"\\n\" not in content:","@@ -821,14 +827,14 @@ def _split_text_for_weixin_delivery(\n                 chunks.append(unit)\n                 continue\n             chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))\n-        return chunks or [content]\n+        return [c for c in chunks if c] or [content]\n \n     # Compact (default): single message when under the limit — unless the\n     # content looks like a short chatty exchange, in which case split into\n     # separate bubbles for a more natural chat feel.\n     if len(content) <= max_length:\n         return (\n-            _split_delivery_units_for_weixin(content)\n+            [u for u in _split_delivery_units_for_weixin(content) if u]\n             if _should_split_short_chat_block_for_weixin(content)\n             else [content]\n         )","@@ -1042,6 +1048,10 @@ class WeixinAdapter(BasePlatformAdapter):\n \n     MAX_MESSAGE_LENGTH = 4000\n \n+    # WeChat does not support editing sent messages — streaming must use the\n+    # fallback \"send-final-only\" path so the cursor (▉) is never left visible.\n+    SUPPORTS_MESSAGE_EDITING = False\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.WEIXIN)\n         extra = config.extra or {}","@@ -1451,7 +1461,7 @@ async def send(\n         context_token = [redacted:secret-assignment] chat_id)\n         last_message_id: Optional[str] = None\n         try:\n-            chunks = self._split_text(self.format_message(content))\n+            chunks = [c for c in self._split_text(self.format_message(content)) if c and c.strip()]\n             for idx, chunk in enumerate(chunks):\n                 client_id = f\"hermes-weixin-{uuid.uuid4().hex}\"\n                 await self._send_text_chunk(","@@ -1555,6 +1565,33 @@ async def send_document(\n             logger.error(\"[%s] send_document failed to=%s: %s\", self.name, _safe_id(chat_id), exc)\n             return SendResult(success=False, error=str(exc))\n \n+    async def send_video(\n+        self,\n+        chat_id: str,\n+        video_path: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        if not self._session or not self._token:\n+            return SendResult(success=False, error=\"Not connected\")\n+        try:\n+            message_id = await self._send_file(chat_id, video_path, caption or \"\")\n+            return SendResult(success=True, message_id=message_id)\n+        except Exception as exc:\n+            logger.error(\"[%s] send_video failed to=%s: %s\", self.name, _safe_id(chat_id), exc)\n+            return SendResult(success=False, error=str(exc))\n+\n+    async def send_voice(\n+        self,\n+        chat_id: str,\n+        audio_path: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        return await self.send_document(chat_id, audio_path, caption=caption or \"\", metadata=metadata)\n+\n     async def _download_remote_media(self, url: str) -> str:\n         from tools.url_safety import is_safe_url\n ","@@ -1577,6 +1614,7 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str:\n         filekey = secrets.token_hex(16)\n         aes_key = secrets.token_bytes(16)\n         rawsize = len(plaintext)\n+        rawfilemd5 = hashlib.md5(plaintext).hexdigest()\n         upload_response = await _get_upload_url(\n             self._session,\n             base_url=self._base_url,","@@ -1585,41 +1623,42 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str:\n             media_type=media_type,\n             filekey=filekey,\n             rawsize=rawsize,\n-            rawfilemd5=hashlib.md5(plaintext).hexdigest(),\n+            rawfilemd5=rawfilemd5,\n             filesize=_aes_padded_size(rawsize),\n             aeskey_hex=aes_key.hex(),\n         )\n         upload_param = str(upload_response.get(\"upload_param\") or \"\")\n         upload_full_url = str(upload_response.get(\"upload_full_url\") or \"\")\n         ciphertext = _aes128_ecb_encrypt(plaintext, aes_key)\n-        if upload_param:\n-            encrypted_query_param = await _upload_ciphertext(\n-                self._session,\n-                ciphertext=ciphertext,\n-                cdn_base_url=self._cdn_base_url,\n-                upload_param=upload_param,\n-                filekey=filekey,\n-            )\n-        elif upload_full_url:\n-            timeout = aiohttp.ClientTimeout(total=120)\n-            async with self._session.put(\n-                upload_full_url,\n-                data=ciphertext,\n-                headers={\"Content-Type\": \"application/octet-stream\"},\n-                timeout=timeout,\n-            ) as response:\n-                response.raise_for_status()\n-                encrypted_query_param = response.headers.get(\"x-encrypted-param\") or filekey\n+\n+        # Prefer upload_full_url (direct CDN), fall back to constructed CDN URL\n+        # from upload_param.  Both paths use POST — the old PUT for\n+        # upload_full_url caused 404s on the WeChat CDN.\n+        if upload_full_url:\n+            upload_url = upload_full_url\n+        elif upload_param:\n+            upload_url = _cdn_upload_url(self._cdn_base_url, upload_param, filekey)\n         else:\n             raise RuntimeError(f\"getUploadUrl returned neither upload_param nor upload_full_url: {upload_response}\")\n \n+        encrypted_query_param = await _upload_ciphertext(\n+            self._session,\n+            ciphertext=ciphertext,\n+            upload_url=upload_url,\n+        )\n+\n         context_token = [redacted:secret-assignment] chat_id)\n+        # The iLink API expects aes_key as base64(hex_string), not base64(raw_bytes).\n+        # Sending base64(raw_bytes) causes images to show as grey boxes on the\n+        # receiver side because the decryption key doesn't match.\n+        aes_key_for_api = base64.b64encode(aes_key.hex().encode(\"ascii\")).decode(\"ascii\")\n         media_item = item_builder(\n             encrypt_query_param=encrypted_query_param,\n-            aes_key_b64=base64.b64encode(aes_key).decode(\"ascii\"),\n+            aes_key_for_api=aes_key_for_api,\n             ciphertext_size=len(ciphertext),\n             plaintext_size=rawsize,\n             filename=Path(path).name,\n+            rawfilemd5=rawfilemd5,\n         )\n \n         last_message_id = None","@@ -1659,39 +1698,53 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str:\n     def _outbound_media_builder(self, path: str):\n         mime = mimetypes.guess_type(path)[0] or \"application/octet-stream\"\n         if mime.startswith(\"image/\"):\n-            return MEDIA_IMAGE, lambda **kwargs: {\n+            return MEDIA_IMAGE, lambda **kw: {\n                 \"type\": ITEM_IMAGE,\n                 \"image_item\": {\n                     \"media\": {\n-                        \"encrypt_query_param\": kwargs[\"encrypt_query_param\"],\n-                        \"aes_key\": kwargs[\"aes_key_b64\"],\n+                        \"encrypt_query_param\": kw[\"encrypt_query_param\"],\n+                        \"aes_key\": kw[\"aes_key_for_api\"],\n                         \"encrypt_type\": 1,\n                     },\n-                    \"mid_size\": kwargs[\"ciphertext_size\"],\n+                    \"mid_size\": kw[\"ciphertext_size\"],\n                 },\n             }\n         if mime.startswith(\"video/\"):\n-            return MEDIA_VIDEO, lambda **kwargs: {\n+            return MEDIA_VIDEO, lambda **kw: {\n                 \"type\": ITEM_VIDEO,\n                 \"video_item\": {\n                     \"media\": {\n-                        \"encrypt_query_param\": kwargs[\"encrypt_query_param\"],\n-                        \"aes_key\": kwargs[\"aes_key_b64\"],\n+                        \"encrypt_query_param\": kw[\"encrypt_query_param\"],\n+                        \"aes_key\": kw[\"aes_key_for_api\"],\n+                        \"encrypt_type\": 1,\n+                    },\n+                    \"video_size\": kw[\"ciphertext_size\"],\n+                    \"play_length\": kw.get(\"play_length\", 0),\n+                    \"video_md5\": kw.get(\"rawfilemd5\", \"\"),\n+                },\n+            }\n+        if mime.startswith(\"audio/\") or path.endswith(\".silk\"):\n+            return MEDIA_VOICE, lambda **kw: {\n+                \"type\": ITEM_VOICE,\n+                \"voice_item\": {\n+                    \"media\": {\n+                        \"encrypt_query_param\": kw[\"encrypt_query_param\"],\n+                        \"aes_key\": kw[\"aes_key_for_api\"],\n                         \"encrypt_type\": 1,\n                     },\n-                    \"video_size\": kwargs[\"ciphertext_size\"],\n+                    \"playtime\": kw.get(\"playtime\", 0),\n                 },\n             }\n-        return MEDIA_FILE, lambda **kwargs: {\n+        return MEDIA_FILE, lambda **kw: {\n             \"type\": ITEM_FILE,\n             \"file_item\": {\n                 \"media\": {\n-                    \"encrypt_query_param\": kwargs[\"encrypt_query_param\"],\n-                    \"aes_key\": kwargs[\"aes_key_b64\"],\n+                    \"encrypt_query_param\": kw[\"encrypt_query_param\"],\n+                    \"aes_key\": kw[\"aes_key_for_api\"],\n                     \"encrypt_type\": 1,\n                 },\n-                \"file_name\": kwargs[\"filename\"],\n-                \"len\": str(kwargs[\"plaintext_size\"]),\n+                \"file_name\": kw[\"filename\"],\n+                \"len\": str(kw[\"plaintext_size\"]),\n             },\n         }\n "]},{"file":"gateway/run.py","hunks":["@@ -7665,10 +7665,18 @@ def run_sync():\n                     from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig\n                     _adapter = self.adapters.get(source.platform)\n                     if _adapter:\n+                        # Platforms that don't support editing sent messages\n+                        # (e.g. WeChat) must not show a cursor in intermediate\n+                        # sends — the cursor would be permanently visible because\n+                        # it can never be edited away.  Use an empty cursor for\n+                        # such platforms so streaming still delivers the final\n+                        # response, just without the typing indicator.\n+                        _adapter_supports_edit = getattr(_adapter, \"SUPPORTS_MESSAGE_EDITING\", True)\n+                        _effective_cursor = _scfg.cursor if _adapter_supports_edit else \"\"\n                         _consumer_cfg = StreamConsumerConfig(\n                             edit_interval=_scfg.edit_interval,\n                             buffer_threshold=_scfg.buffer_threshold,\n-                            cursor=_scfg.cursor,\n+                            cursor=_effective_cursor,\n                         )\n                         _stream_consumer = GatewayStreamConsumer(\n                             adapter=_adapter,"]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -30,7 +30,7 @@ def test_format_message_preserves_markdown_and_rewrites_headers(self):\n \n         assert (\n             adapter.format_message(content)\n-            == \"【Title】\\n\\n**Plan**\\n\\nUse **bold** and [docs](https://example.com).\"\n+            == \"【Title】\\n\\n**Plan**\\n\\nUse **bold** and docs (https://example.com).\"\n         )\n \n     def test_format_message_rewrites_markdown_tables(self):","@@ -374,3 +374,149 @@ def test_download_remote_media_blocks_unsafe_urls(self):\n                 assert \"Blocked unsafe URL\" in str(exc)\n             else:\n                 raise AssertionError(\"expected ValueError for unsafe URL\")\n+\n+\n+class TestWeixinMarkdownLinks:\n+    \"\"\"Markdown links should be converted to plaintext since WeChat can't render them.\"\"\"\n+\n+    def test_format_message_converts_markdown_links_to_plain_text(self):\n+        adapter = _make_adapter()\n+\n+        content = \"Check [the docs](https://example.com) and [GitHub](https://github.com) for details\"\n+        assert (\n+            adapter.format_message(content)\n+            == \"Check the docs (https://example.com) and GitHub (https://github.com) for details\"\n+        )\n+\n+    def test_format_message_preserves_links_inside_code_blocks(self):\n+        adapter = _make_adapter()\n+\n+        content = \"See below:\\n\\n```\\n[link](https://example.com)\\n```\\n\\nDone.\"\n+        result = adapter.format_message(content)\n+        assert \"[link](https://example.com)\" in result\n+\n+\n+class TestWeixinBlankMessagePrevention:\n+    \"\"\"Regression tests for the blank-bubble bugs.\n+\n+    Three separate guards now prevent a blank WeChat message from ever being\n+    dispatched:\n+\n+    1. ``_split_text_for_weixin_delivery(\"\")`` returns ``[]`` — not ``[\"\"]``.\n+    2. ``send()`` filters out empty/whitespace-only chunks before calling\n+       ``_send_text_chunk``.\n+    3. ``_send_message()`` raises ``ValueError`` for empty text as a last-resort\n+       safety net.\n+    \"\"\"\n+\n+    def test_split_text_returns_empty_list_for_empty_string(self):\n+        adapter = _make_adapter()\n+        assert adapter._split_text(\"\") == []\n+\n+    def test_split_text_returns_empty_list_for_empty_string_split_per_line(self):\n+        adapter = WeixinAdapter(\n+            PlatformConfig(\n+                enabled=True,\n+                extra={\n+                    \"account_id\": \"acct\",\n+                    \"token\": \"[redacted:secret-assignment]\",\n+                    \"split_multiline_messages\": True,\n+                },\n+            )\n+        )\n+        assert adapter._split_text(\"\") == []\n+\n+    @patch(\"gateway.platforms.weixin._send_message\", new_callable=AsyncMock)\n+    def test_send_empty_content_does_not_call_send_message(self, send_message_mock):\n+        adapter = _make_adapter()\n+        adapter._session = object()\n+        adapter._token = \"[redacted:secret-assignment]\"\n+        adapter._base_url = \"https://weixin.example.com\"\n+        adapter._token_store.get = lambda account_id, chat_id: \"ctx-token\"\n+\n+        result = asyncio.run(adapter.send(\"wxid_test123\", \"\"))\n+        # Empty content → no chunks → no _send_message calls\n+        assert result.success is True\n+        send_message_mock.assert_not_awaited()\n+\n+    def test_send_message_rejects_empty_text(self):\n+        \"\"\"_send_message raises ValueError for empty/whitespace text.\"\"\"\n+        import pytest\n+        with pytest.raises(ValueError, match=\"text must not be empty\"):\n+            asyncio.run(\n+                weixin._send_message(\n+                    AsyncMock(),\n+                    base_url=\"https://example.com\",\n+                    token=\"tok\",\n+                    to=\"wxid_test\",\n+                    text=\"\",\n+                    context_token=None,\n+                    client_id=\"cid\",\n+                )\n+            )\n+\n+\n+class TestWeixinStreamingCursorSuppression:\n+    \"\"\"WeChat doesn't support message editing — cursor must be suppressed.\"\"\"\n+\n+    def test_supports_message_editing_is_false(self):\n+        adapter = _make_adapter()\n+        assert adapter.SUPPORTS_MESSAGE_EDITING is False\n+\n+\n+class TestWeixinMediaBuilder:\n+    \"\"\"Media builder uses base64(hex_key), not base64(raw_bytes) for aes_key.\"\"\"\n+\n+    def test_image_builder_aes_key_is_base64_of_hex(self):\n+        import base64\n+        adapter = _make_adapter()\n+        media_type, builder = adapter._outbound_media_builder(\"photo.jpg\")\n+        assert media_type == weixin.MEDIA_IMAGE\n+\n+        fake_hex_key = \"0123456789abcdef0123456789abcdef\"\n+        expected_aes = base64.b64encode(fake_hex_key.encode(\"ascii\")).decode(\"ascii\")\n+        item = builder(\n+            encrypt_query_param=\"eq\",\n+            aes_key_for_api=expected_aes,\n+            ciphertext_size=1024,\n+            plaintext_size=1000,\n+            filename=\"photo.jpg\",\n+            rawfilemd5=\"abc123\",\n+        )\n+        assert item[\"image_item\"][\"media\"][\"aes_key\"] == expected_aes\n+\n+    def test_video_builder_includes_md5(self):\n+        adapter = _make_adapter()\n+        media_type, builder = adapter._outbound_media_builder(\"clip.mp4\")\n+        assert media_type == weixin.MEDIA_VIDEO\n+\n+        item = builder(\n+            encrypt_query_param=\"eq\",\n+            aes_key_for_api=\"fakekey\",\n+            ciphertext_size=2048,\n+            plaintext_size=2000,\n+            filename=\"clip.mp4\",\n+            rawfilemd5=\"deadbeef\",\n+        )\n+        assert item[\"video_item\"][\"video_md5\"] == \"deadbeef\"\n+\n+    def test_voice_builder_for_audio_files(self):\n+        adapter = _make_adapter()\n+        media_type, builder = adapter._outbound_media_builder(\"note.mp3\")\n+        assert media_type == weixin.MEDIA_VOICE\n+\n+        item = builder(\n+            encrypt_query_param=\"eq\",\n+            aes_key_for_api=\"fakekey\",\n+            ciphertext_size=512,\n+            plaintext_size=500,\n+            filename=\"note.mp3\",\n+            rawfilemd5=\"abc\",\n+        )\n+        assert item[\"type\"] == weixin.ITEM_VOICE\n+        assert \"voice_item\" in item\n+\n+    def test_voice_builder_for_silk_files(self):\n+        adapter = _make_adapter()\n+        media_type, builder = adapter._outbound_media_builder(\"recording.silk\")\n+        assert media_type == weixin.MEDIA_VOICE\n"]}]},{"kind":"pr","number":8670,"title":"fix(gateway): avoid stuck streaming cursor on no-edit platforms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a266238e1e5ab438e2b1358503541a3d420285c4","number":0},{"kind":"commit","value":"884cd920d406ffcda9678cb87f5b034187baca50","number":0},{"kind":"pr","value":"#8326","number":8326},{"kind":"pr","value":"#9346","number":9346}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}