{"repo":"NousResearch/hermes-agent","public_id":"7565","display_label":"WeChat message chunking, truncation, and delivery reliability","summary":"These issues and pull requests concern WeChat (Weixin) channel failures around multi-part message delivery, including silent truncation of long replies, rate-limit handling, per-chunk retry logic, and related sending behavior. Several fixes address how message chunks are packed, retried, and rate-limited to prevent dropped trailing segments.","subsystem":"gateway","counts":{"members":9,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7932,"merged_at":"2026-04-11T21:02:34+00:00"},"resolution":{"state":"partially_resolved","by":"pr-7932"},"freshness":{"state":"stale","last_verified_at":"2026-04-11T21:02:34+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18666666666666665},"fix_edges":[{"issue_number":7468,"pr_number":7903,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-11T19:00:05+00:00"},{"issue_number":7836,"pr_number":7903,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-11T19:00:05+00:00"},{"issue_number":7836,"pr_number":7932,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-11T21:02:34+00:00"}],"members":[{"kind":"issue","number":7565,"title":"[Bug]: WeChat gateway can only reply to 10 messages.","url":null,"state":"closed"},{"kind":"issue","number":7836,"title":"Weixin: multi-part replies can drop trailing chunks while the agent believes all parts were delivered","url":null,"state":"closed"},{"kind":"issue","number":46529,"title":"[Bug] WeChat iLink channel: replies longer than ~10 segments are silently truncated; only recovery is for the human to send a message","url":null,"state":"open"},{"kind":"pr","number":7624,"title":"fix(weixin): re-pack message chunks to avoid flooding WeChat chat","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8665","number":8665}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7899,"title":"fix weixin chunk delivery retries","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7932","number":7932},{"kind":"pr","value":"#7903","number":7903}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7903,"title":"fix(weixin): keep multi-line messages in single bubble by default","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/weixin.py","tests/gateway/test_weixin.py","website/docs/user-guide/messaging/weixin.md"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -1017,6 +1017,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:\n         weixin_group_allowed_users = os.getenv(\"WEIXIN_GROUP_ALLOWED_USERS\", \"\").strip()\n         if weixin_group_allowed_users:\n             extra[\"group_allow_from\"] = weixin_group_allowed_users\n+        weixin_split_multiline = os.getenv(\"WEIXIN_SPLIT_MULTILINE_MESSAGES\", \"\").strip()\n+        if weixin_split_multiline:\n+            extra[\"split_multiline_messages\"] = weixin_split_multiline\n         weixin_home = os.getenv(\"WEIXIN_HOME_CHANNEL\", \"\").strip()\n         if weixin_home:\n             config.platforms[Platform.WEIXIN].home_channel = HomeChannel("]},{"file":"gateway/platforms/weixin.py","hunks":["@@ -755,23 +755,58 @@ def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]\n     return packed\n \n \n-def _split_text_for_weixin_delivery(content: str, max_length: int) -> List[str]:\n+def _split_text_for_weixin_delivery(\n+    content: str, max_length: int, split_per_line: bool = False,\n+) -> List[str]:\n     \"\"\"Split content into sequential Weixin messages.\n \n-    Prefer one message per top-level line/markdown unit when the author used\n-    explicit line breaks. Oversized units fall back to block-aware packing so\n-    long code fences still split safely.\n+    *compact* (default): Keep everything in a single message whenever it fits\n+    within the platform limit, even when the author used explicit line breaks.\n+    Only fall back to block-aware packing when the payload exceeds\n+    ``max_length``.\n+\n+    *per_line* (``split_per_line=True``): Legacy behavior — top-level line\n+    breaks become separate chat messages; oversized units still use\n+    block-aware packing.\n+\n+    The active mode is controlled via ``config.yaml`` ->\n+    ``platforms.weixin.extra.split_multiline_messages`` (``true`` / ``false``)\n+    or the env var ``WEIXIN_SPLIT_MULTILINE_MESSAGES``.\n     \"\"\"\n-    if len(content) <= max_length and \"\\n\" not in content:\n-        return [content]\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:\n+            return [content]\n+        chunks: List[str] = []\n+        for unit in _split_delivery_units_for_weixin(content):\n+            if len(unit) <= max_length:\n+                chunks.append(unit)\n+                continue\n+            chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))\n+        return chunks or [content]\n \n-    chunks: List[str] = []\n-    for unit in _split_delivery_units_for_weixin(content):\n-        if len(unit) <= max_length:\n-            chunks.append(unit)\n-            continue\n-        chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))\n-    return chunks or [content]\n+    # Compact (default): single message when under the limit.\n+    if len(content) <= max_length:\n+        return [content]\n+    return _pack_markdown_blocks_for_weixin(content, max_length) or [content]\n+\n+\n+def _coerce_bool(value: Any, default: bool = True) -> bool:\n+    \"\"\"Coerce a config value to bool, tolerating strings like ``\"true\"``.\"\"\"\n+    if value is None:\n+        return default\n+    if isinstance(value, bool):\n+        return value\n+    if isinstance(value, (int, float)):\n+        return bool(value)\n+    text = str(value).strip().lower()\n+    if not text:\n+        return default\n+    if text in {\"1\", \"true\", \"yes\", \"on\"}:\n+        return True\n+    if text in {\"0\", \"false\", \"no\", \"off\"}:\n+        return False\n+    return default\n \n \n def _extract_text(item_list: List[Dict[str, Any]]) -> str:","@@ -991,6 +1026,11 @@ def __init__(self, config: PlatformConfig):\n             group_allow_from = os.getenv(\"WEIXIN_GROUP_ALLOWED_USERS\", \"\")\n         self._allow_from = self._coerce_list(allow_from)\n         self._group_allow_from = self._coerce_list(group_allow_from)\n+        self._split_multiline_messages = _coerce_bool(\n+            extra.get(\"split_multiline_messages\")\n+            or os.getenv(\"WEIXIN_SPLIT_MULTILINE_MESSAGES\"),\n+            default=False,\n+        )\n \n         if self._account_id and not self._token:\n             persisted = load_weixin_account(hermes_home, self._account_id)","@@ -1330,7 +1370,9 @@ async def _maybe_fetch_typing_ticket(self, user_id: str, context_token: Optional\n             logger.debug(\"[%s] getConfig failed for %s: %s\", self.name, _safe_id(user_id), exc)\n \n     def _split_text(self, content: str) -> List[str]:\n-        return _split_text_for_weixin_delivery(content, self.MAX_MESSAGE_LENGTH)\n+        return _split_text_for_weixin_delivery(\n+            content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages,\n+        )\n \n     async def send(\n         self,","@@ -1344,7 +1386,10 @@ async def send(\n         context_token = [redacted:secret-assignment] chat_id)\n         last_message_id: Optional[str] = None\n         try:\n-            for chunk in self._split_text(self.format_message(content)):\n+            chunks = self._split_text(self.format_message(content))\n+            for idx, chunk in enumerate(chunks):\n+                if idx > 0:\n+                    await asyncio.sleep(0.3)\n                 client_id = f\"hermes-weixin-{uuid.uuid4().hex}\"\n                 await _send_message(\n                     self._session,"]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -62,15 +62,15 @@ def test_format_message_returns_empty_string_for_none(self):\n \n \n class TestWeixinChunking:\n-    def test_split_text_sends_top_level_newlines_as_separate_messages(self):\n+    def test_split_text_keeps_short_multiline_message_in_single_chunk(self):\n         adapter = _make_adapter()\n \n         content = adapter.format_message(\"第一行\\n第二行\\n第三行\")\n         chunks = adapter._split_text(content)\n \n-        assert chunks == [\"第一行\", \"第二行\", \"第三行\"]\n+        assert chunks == [\"第一行\\n第二行\\n第三行\"]\n \n-    def test_split_text_keeps_indented_followup_with_previous_line(self):\n+    def test_split_text_keeps_short_reformatted_table_in_single_chunk(self):\n         adapter = _make_adapter()\n \n         content = adapter.format_message(","@@ -81,10 +81,7 @@ def test_split_text_keeps_indented_followup_with_previous_line(self):\n         )\n         chunks = adapter._split_text(content)\n \n-        assert chunks == [\n-            \"- Setting: Timeout\\n  Value: 30s\",\n-            \"- Setting: Retries\\n  Value: 3\",\n-        ]\n+        assert chunks == [content]\n \n     def test_split_text_keeps_complete_code_block_together_when_possible(self):\n         adapter = _make_adapter()","@@ -114,6 +111,23 @@ def test_split_text_safely_splits_long_code_blocks(self):\n         assert all(len(chunk) <= adapter.MAX_MESSAGE_LENGTH for chunk in chunks)\n         assert all(chunk.count(\"```\") >= 2 for chunk in chunks)\n \n+    def test_split_text_can_restore_legacy_multiline_splitting_via_config(self):\n+        adapter = WeixinAdapter(\n+            PlatformConfig(\n+                enabled=True,\n+                extra={\n+                    \"account_id\": \"acct\",\n+                    \"token\": \"***\",\n+                    \"split_multiline_messages\": True,\n+                },\n+            )\n+        )\n+\n+        content = adapter.format_message(\"第一行\\n第二行\\n第三行\")\n+        chunks = adapter._split_text(content)\n+\n+        assert chunks == [\"第一行\", \"第二行\", \"第三行\"]\n+\n \n class TestWeixinConfig:\n     def test_apply_env_overrides_configures_weixin(self):","@@ -127,6 +141,7 @@ def test_apply_env_overrides_configures_weixin(self):\n                 \"WEIXIN_BASE_URL\": \"https://ilink.example.com/\",\n                 \"WEIXIN_CDN_BASE_URL\": \"https://cdn.example.com/c2c/\",\n                 \"WEIXIN_DM_POLICY\": \"allowlist\",\n+                \"WEIXIN_SPLIT_MULTILINE_MESSAGES\": \"true\",\n                 \"WEIXIN_ALLOWED_USERS\": \"wxid_1,wxid_2\",\n                 \"WEIXIN_HOME_CHANNEL\": \"wxid_1\",\n                 \"WEIXIN_HOME_CHANNEL_NAME\": \"Primary DM\",","@@ -142,6 +157,7 @@ def test_apply_env_overrides_configures_weixin(self):\n         assert platform_config.extra[\"base_url\"] == \"https://ilink.example.com\"\n         assert platform_config.extra[\"cdn_base_url\"] == \"https://cdn.example.com/c2c\"\n         assert platform_config.extra[\"dm_policy\"] == \"allowlist\"\n+        assert platform_config.extra[\"split_multiline_messages\"] == \"true\"\n         assert platform_config.extra[\"allow_from\"] == \"wxid_1,wxid_2\"\n         assert platform_config.home_channel == HomeChannel(Platform.WEIXIN, \"wxid_1\", \"Primary DM\")\n "]},{"file":"website/docs/user-guide/messaging/weixin.md","hunks":["@@ -66,6 +66,9 @@ WEIXIN_ACCOUNT_ID=your-account-id\n WEIXIN_DM_POLICY=open\n WEIXIN_ALLOWED_USERS=user_id_1,user_id_2\n \n+# Optional: restore legacy multiline splitting behavior\n+# WEIXIN_SPLIT_MULTILINE_MESSAGES=true\n+\n # Optional: home channel for cron/notifications\n WEIXIN_HOME_CHANNEL=chat_id\n WEIXIN_HOME_CHANNEL_NAME=Home","@@ -88,7 +91,7 @@ The adapter will restore saved credentials, connect to the iLink API, and begin\n - **AES-128-ECB encrypted CDN** — automatic encryption/decryption for all media transfers\n - **Context token persistence** — disk-backed reply continuity across restarts\n - **Markdown formatting** — headers, tables, and code blocks are reformatted for WeChat readability\n-- **Smart message chunking** — long messages are split at logical boundaries (paragraphs, code fences)\n+- **Smart message chunking** — messages stay as a single bubble when under the limit; only oversized payloads split at logical boundaries\n - **Typing indicators** — shows \"typing…\" status in the WeChat client while the agent processes\n - **SSRF protection** — outbound media URLs are validated before download\n - **Message deduplication** — 5-minute sliding window prevents double-processing","@@ -108,6 +111,7 @@ Set these in `config.yaml` under `platforms.weixin.extra`:\n | `group_policy` | `disabled` | Group access: `open`, `allowlist`, `disabled` |\n | `allow_from` | `[]` | User IDs allowed for DMs (when dm_policy=allowlist) |\n | `group_allow_from` | `[]` | Group IDs allowed (when group_policy=allowlist) |\n+| `split_multiline_messages` | `false` | When `true`, split multi-line replies into multiple chat messages (legacy behavior). When `false`, keep multi-line replies as one message unless they exceed the length limit. |\n \n ## Access Policies\n ","@@ -211,13 +215,14 @@ WeChat's personal chat does not natively render full Markdown. The adapter refor\n \n ## Message Chunking\n \n-Long messages are split intelligently for chat delivery:\n+Messages are delivered as a single chat message whenever they fit within the platform limit. Only oversized payloads are split for delivery:\n \n - Maximum message length: **4000 characters**\n-- Split points prefer paragraph boundaries and blank lines\n-- Code fences are kept intact (never split mid-block)\n-- Indented continuation lines (sub-items in reformatted tables/lists) stay with their parent\n+- Messages under the limit stay intact even when they contain multiple paragraphs or line breaks\n+- Oversized messages split at logical boundaries (paragraphs, blank lines, code fences)\n+- Code fences are kept intact whenever possible (never split mid-block unless the fence itself exceeds the limit)\n - Oversized individual blocks fall back to the base adapter's truncation logic\n+- A 0.3 s inter-chunk delay prevents WeChat rate-limit drops when multiple chunks are sent\n \n ## Typing Indicators\n \n"]}]},{"kind":"pr","number":7932,"title":"fix(weixin): add per-chunk retry with backoff for text delivery","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py","tests/gateway/test_weixin.py"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -1017,6 +1017,16 @@ def __init__(self, config: PlatformConfig):\n         self._cdn_base_url = str(\n             extra.get(\"cdn_base_url\") or os.getenv(\"WEIXIN_CDN_BASE_URL\", WEIXIN_CDN_BASE_URL)\n         ).strip().rstrip(\"/\")\n+        self._send_chunk_delay_seconds = float(\n+            extra.get(\"send_chunk_delay_seconds\") or os.getenv(\"WEIXIN_SEND_CHUNK_DELAY_SECONDS\", \"0.35\")\n+        )\n+        self._send_chunk_retries = int(\n+            extra.get(\"send_chunk_retries\") or os.getenv(\"WEIXIN_SEND_CHUNK_RETRIES\", \"2\")\n+        )\n+        self._send_chunk_retry_delay_seconds = float(\n+            extra.get(\"send_chunk_retry_delay_seconds\")\n+            or os.getenv(\"WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS\", \"1.0\")\n+        )\n         self._dm_policy = str(extra.get(\"dm_policy\") or os.getenv(\"WEIXIN_DM_POLICY\", \"open\")).strip().lower()\n         self._group_policy = str(extra.get(\"group_policy\") or os.getenv(\"WEIXIN_GROUP_POLICY\", \"disabled\")).strip().lower()\n         allow_from = extra.get(\"allow_from\")","@@ -1346,6 +1356,47 @@ def _split_text(self, content: str) -> List[str]:\n             content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages,\n         )\n \n+    async def _send_text_chunk(\n+        self,\n+        *,\n+        chat_id: str,\n+        chunk: str,\n+        context_token: [redacted:secret-assignment]\n+        client_id: str,\n+    ) -> None:\n+        \"\"\"Send a single text chunk with per-chunk retry and backoff.\"\"\"\n+        last_error: Optional[Exception] = None\n+        for attempt in range(self._send_chunk_retries + 1):\n+            try:\n+                await _send_message(\n+                    self._session,\n+                    base_url=self._base_url,\n+                    token=self._token,\n+                    to=chat_id,\n+                    text=chunk,\n+                    context_token=[redacted:secret-assignment]\n+                    client_id=client_id,\n+                )\n+                return\n+            except Exception as exc:\n+                last_error = exc\n+                if attempt >= self._send_chunk_retries:\n+                    break\n+                wait = self._send_chunk_retry_delay_seconds * (attempt + 1)\n+                logger.warning(\n+                    \"[%s] send chunk failed to=%s attempt=%d/%d, retrying in %.2fs: %s\",\n+                    self.name,\n+                    _safe_id(chat_id),\n+                    attempt + 1,\n+                    self._send_chunk_retries + 1,\n+                    wait,\n+                    exc,\n+                )\n+                if wait > 0:\n+                    await asyncio.sleep(wait)\n+        assert last_error is not None\n+        raise last_error\n+\n     async def send(\n         self,\n         chat_id: str,","@@ -1360,19 +1411,16 @@ async def send(\n         try:\n             chunks = self._split_text(self.format_message(content))\n             for idx, chunk in enumerate(chunks):\n-                if idx > 0:\n-                    await asyncio.sleep(0.3)\n                 client_id = f\"hermes-weixin-{uuid.uuid4().hex}\"\n-                await _send_message(\n-                    self._session,\n-                    base_url=self._base_url,\n-                    token=self._token,\n-                    to=chat_id,\n-                    text=chunk,\n+                await self._send_text_chunk(\n+                    chat_id=chat_id,\n+                    chunk=chunk,\n                     context_token=[redacted:secret-assignment]\n                     client_id=client_id,\n                 )\n                 last_message_id = client_id\n+                if idx < len(chunks) - 1 and self._send_chunk_delay_seconds > 0:\n+                    await asyncio.sleep(self._send_chunk_delay_seconds)\n             return SendResult(success=True, message_id=last_message_id)\n         except Exception as exc:\n             logger.error(\"[%s] send failed to=%s: %s\", self.name, _safe_id(chat_id), exc)"]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -283,6 +283,55 @@ def test_send_to_platform_routes_weixin_media_to_native_helper(self, send_weixin\n         )\n \n \n+class TestWeixinChunkDelivery:\n+    def _connected_adapter(self) -> WeixinAdapter:\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+        return adapter\n+\n+    @patch(\"gateway.platforms.weixin.asyncio.sleep\", new_callable=AsyncMock)\n+    @patch(\"gateway.platforms.weixin._send_message\", new_callable=AsyncMock)\n+    def test_send_waits_between_multiple_chunks(self, send_message_mock, sleep_mock):\n+        adapter = self._connected_adapter()\n+        adapter.MAX_MESSAGE_LENGTH = 12\n+\n+        # Use double newlines so _pack_markdown_blocks splits into 3 blocks\n+        result = asyncio.run(adapter.send(\"wxid_test123\", \"first\\n\\nsecond\\n\\nthird\"))\n+\n+        assert result.success is True\n+        assert send_message_mock.await_count == 3\n+        assert sleep_mock.await_count == 2\n+\n+    @patch(\"gateway.platforms.weixin.asyncio.sleep\", new_callable=AsyncMock)\n+    @patch(\"gateway.platforms.weixin._send_message\", new_callable=AsyncMock)\n+    def test_send_retries_failed_chunk_before_continuing(self, send_message_mock, sleep_mock):\n+        adapter = self._connected_adapter()\n+        adapter.MAX_MESSAGE_LENGTH = 12\n+        calls = {\"count\": 0}\n+\n+        async def flaky_send(*args, **kwargs):\n+            calls[\"count\"] += 1\n+            if calls[\"count\"] == 2:\n+                raise RuntimeError(\"temporary iLink failure\")\n+\n+        send_message_mock.side_effect = flaky_send\n+\n+        # Use double newlines so _pack_markdown_blocks splits into 3 blocks\n+        result = asyncio.run(adapter.send(\"wxid_test123\", \"first\\n\\nsecond\\n\\nthird\"))\n+\n+        assert result.success is True\n+        # 3 chunks, but chunk 2 fails once and retries → 4 _send_message calls total\n+        assert send_message_mock.await_count == 4\n+        # The retried chunk should reuse the same client_id for deduplication\n+        first_try = send_message_mock.await_args_list[1].kwargs\n+        retry = send_message_mock.await_args_list[2].kwargs\n+        assert first_try[\"text\"] == retry[\"text\"]\n+        assert first_try[\"client_id\"] == retry[\"client_id\"]\n+\n+\n class TestWeixinRemoteMediaSafety:\n     def test_download_remote_media_blocks_unsafe_urls(self):\n         adapter = _make_adapter()\n"]}]},{"kind":"pr","number":46679,"title":"fix(weixin): wait for rate-limit cooldown mid-send not truncate (#46529)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":51924,"title":"fix(weixin): 5 WeChat bug fixes — voice STT, polling hang, msg truncation, session split, interrupt","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51924","number":51924}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}