WeChat message chunking, truncation, and delivery reliability
Cluster 7565 · gateway. Data as of
1 open item. Fix merged in #7932 on 11 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 9 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #7932. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #7932. That is separate from the fix event. - Still open
- 1 issue and 0 PRs.
Next step: read #7932, then check whether your report is the one still open.
Read #7932Pick up a Lab taskThese 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.
Open issues1
- Issue#46529[Bug] WeChat iLink channel: replies longer than ~10 segments are silently truncated; only recovery is for the human to send a message
Open PRs0
Fixes on record
-
#7903 fix(weixin): keep multi-line messages in single bubble by default
files:
gateway/config.py,gateway/platforms/weixin.py,tests/gateway/test_weixin.py,website/docs/user-guide/messaging/weixin.mdShow the diff
-
gateway/config.py@@ -1017,6 +1017,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: weixin_group_allowed_users = os.getenv("WEIXIN_GROUP_ALLOWED_USERS", "").strip() if weixin_group_allowed_users: extra["group_allow_from"] = weixin_group_allowed_users + weixin_split_multiline = os.getenv("WEIXIN_SPLIT_MULTILINE_MESSAGES", "").strip() + if weixin_split_multiline: + extra["split_multiline_messages"] = weixin_split_multiline weixin_home = os.getenv("WEIXIN_HOME_CHANNEL", "").strip() if weixin_home: config.platforms[Platform.WEIXIN].home_channel = HomeChannel( -
gateway/platforms/weixin.py@@ -755,23 +755,58 @@ def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str] return packed -def _split_text_for_weixin_delivery(content: str, max_length: int) -> List[str]: +def _split_text_for_weixin_delivery( + content: str, max_length: int, split_per_line: bool = False, +) -> List[str]: """Split content into sequential Weixin messages. - Prefer one message per top-level line/markdown unit when the author used - explicit line breaks. Oversized units fall back to block-aware packing so - long code fences still split safely. + *compact* (default): Keep everything in a single message whenever it fits + within the platform limit, even when the author used explicit line breaks. + Only fall back to block-aware packing when the payload exceeds + ``max_length``. + + *per_line* (``split_per_line=True``): Legacy behavior — top-level line + breaks become separate chat messages; oversized units still use + block-aware packing. + + The active mode is controlled via ``config.yaml`` -> + ``platforms.weixin.extra.split_multiline_messages`` (``true`` / ``false``) + or the env var ``WEIXIN_SPLIT_MULTILINE_MESSAGES``. """ - if len(content) <= max_length and "\n" not in content: - return [content] + if split_per_line: + # Legacy: one message per top-level delivery unit. + if len(content) <= max_length and "\n" not in content: + return [content] + chunks: List[str] = [] + for unit in _split_delivery_units_for_weixin(content): + if len(unit) <= max_length: + chunks.append(unit) + continue + chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length)) + return chunks or [content] - chunks: List[str] = [] - for unit in _split_delivery_units_for_weixin(content): - if len(unit) <= max_length: - chunks.append(unit) - continue - chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length)) - return chunks or [content] + # Compact (default): single message when under the limit. + if len(content) <= max_length: + return [content] + return _pack_markdown_blocks_for_weixin(content, max_length) or [content] + + +def _coerce_bool(value: Any, default: bool = True) -> bool: + """Coerce a config value to bool, tolerating strings like ``"true"``.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if not text: + return default + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default def _extract_text(item_list: List[Dict[str, Any]]) -> str: @@ -991,6 +1026,11 @@ def __init__(self, config: PlatformConfig): group_allow_from = os.getenv("WEIXIN_GROUP_ALLOWED_USERS", "") self._allow_from = self._coerce_list(allow_from) self._group_allow_from = self._coerce_list(group_allow_from) + self._split_multiline_messages = _coerce_bool( + extra.get("split_multiline_messages") + or os.getenv("WEIXIN_SPLIT_MULTILINE_MESSAGES"), + default=False, + ) if self._account_id and not self._token: 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 logger.debug("[%s] getConfig failed for %s: %s", self.name, _safe_id(user_id), exc) def _split_text(self, content: str) -> List[str]: - return _split_text_for_weixin_delivery(content, self.MAX_MESSAGE_LENGTH) + return _split_text_for_weixin_delivery( + content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages, + ) async def send( self, @@ -1344,7 +1386,10 @@ async def send( context_token = [redacted:secret-assignment] chat_id) last_message_id: Optional[str] = None try: - for chunk in self._split_text(self.format_message(content)): + chunks = self._split_text(self.format_message(content)) + for idx, chunk in enumerate(chunks): + if idx > 0: + await asyncio.sleep(0.3) client_id = f"hermes-weixin-{uuid.uuid4().hex}" await _send_message( self._session, -
tests/gateway/test_weixin.py@@ -62,15 +62,15 @@ def test_format_message_returns_empty_string_for_none(self): class TestWeixinChunking: - def test_split_text_sends_top_level_newlines_as_separate_messages(self): + def test_split_text_keeps_short_multiline_message_in_single_chunk(self): adapter = _make_adapter() content = adapter.format_message("第一行\n第二行\n第三行") chunks = adapter._split_text(content) - assert chunks == ["第一行", "第二行", "第三行"] + assert chunks == ["第一行\n第二行\n第三行"] - def test_split_text_keeps_indented_followup_with_previous_line(self): + def test_split_text_keeps_short_reformatted_table_in_single_chunk(self): adapter = _make_adapter() content = adapter.format_message( @@ -81,10 +81,7 @@ def test_split_text_keeps_indented_followup_with_previous_line(self): ) chunks = adapter._split_text(content) - assert chunks == [ - "- Setting: Timeout\n Value: 30s", - "- Setting: Retries\n Value: 3", - ] + assert chunks == [content] def test_split_text_keeps_complete_code_block_together_when_possible(self): adapter = _make_adapter() @@ -114,6 +111,23 @@ def test_split_text_safely_splits_long_code_blocks(self): assert all(len(chunk) <= adapter.MAX_MESSAGE_LENGTH for chunk in chunks) assert all(chunk.count("```") >= 2 for chunk in chunks) + def test_split_text_can_restore_legacy_multiline_splitting_via_config(self): + adapter = WeixinAdapter( + PlatformConfig( + enabled=True, + extra={ + "account_id": "acct", + "token": "***", + "split_multiline_messages": True, + }, + ) + ) + + content = adapter.format_message("第一行\n第二行\n第三行") + chunks = adapter._split_text(content) + + assert chunks == ["第一行", "第二行", "第三行"] + class TestWeixinConfig: def test_apply_env_overrides_configures_weixin(self): @@ -127,6 +141,7 @@ def test_apply_env_overrides_configures_weixin(self): "WEIXIN_BASE_URL": "https://ilink.example.com/", "WEIXIN_CDN_BASE_URL": "https://cdn.example.com/c2c/", "WEIXIN_DM_POLICY": "allowlist", + "WEIXIN_SPLIT_MULTILINE_MESSAGES": "true", "WEIXIN_ALLOWED_USERS": "wxid_1,wxid_2", "WEIXIN_HOME_CHANNEL": "wxid_1", "WEIXIN_HOME_CHANNEL_NAME": "Primary DM", @@ -142,6 +157,7 @@ def test_apply_env_overrides_configures_weixin(self): assert platform_config.extra["base_url"] == "https://ilink.example.com" assert platform_config.extra["cdn_base_url"] == "https://cdn.example.com/c2c" assert platform_config.extra["dm_policy"] == "allowlist" + assert platform_config.extra["split_multiline_messages"] == "true" assert platform_config.extra["allow_from"] == "wxid_1,wxid_2" assert platform_config.home_channel == HomeChannel(Platform.WEIXIN, "wxid_1", "Primary DM") -
website/docs/user-guide/messaging/weixin.md@@ -66,6 +66,9 @@ WEIXIN_ACCOUNT_ID=your-account-id WEIXIN_DM_POLICY=open WEIXIN_ALLOWED_USERS=user_id_1,user_id_2 +# Optional: restore legacy multiline splitting behavior +# WEIXIN_SPLIT_MULTILINE_MESSAGES=true + # Optional: home channel for cron/notifications WEIXIN_HOME_CHANNEL=chat_id WEIXIN_HOME_CHANNEL_NAME=Home @@ -88,7 +91,7 @@ The adapter will restore saved credentials, connect to the iLink API, and begin - **AES-128-ECB encrypted CDN** — automatic encryption/decryption for all media transfers - **Context token persistence** — disk-backed reply continuity across restarts - **Markdown formatting** — headers, tables, and code blocks are reformatted for WeChat readability -- **Smart message chunking** — long messages are split at logical boundaries (paragraphs, code fences) +- **Smart message chunking** — messages stay as a single bubble when under the limit; only oversized payloads split at logical boundaries - **Typing indicators** — shows "typing…" status in the WeChat client while the agent processes - **SSRF protection** — outbound media URLs are validated before download - **Message deduplication** — 5-minute sliding window prevents double-processing @@ -108,6 +111,7 @@ Set these in `config.yaml` under `platforms.weixin.extra`: | `group_policy` | `disabled` | Group access: `open`, `allowlist`, `disabled` | | `allow_from` | `[]` | User IDs allowed for DMs (when dm_policy=allowlist) | | `group_allow_from` | `[]` | Group IDs allowed (when group_policy=allowlist) | +| `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. | ## Access Policies @@ -211,13 +215,14 @@ WeChat's personal chat does not natively render full Markdown. The adapter refor ## Message Chunking -Long messages are split intelligently for chat delivery: +Messages are delivered as a single chat message whenever they fit within the platform limit. Only oversized payloads are split for delivery: - Maximum message length: **4000 characters** -- Split points prefer paragraph boundaries and blank lines -- Code fences are kept intact (never split mid-block) -- Indented continuation lines (sub-items in reformatted tables/lists) stay with their parent +- Messages under the limit stay intact even when they contain multiple paragraphs or line breaks +- Oversized messages split at logical boundaries (paragraphs, blank lines, code fences) +- Code fences are kept intact whenever possible (never split mid-block unless the fence itself exceeds the limit) - Oversized individual blocks fall back to the base adapter's truncation logic +- A 0.3 s inter-chunk delay prevents WeChat rate-limit drops when multiple chunks are sent ## Typing Indicators
-
-
#7932 fix(weixin): add per-chunk retry with backoff for text delivery
files:
gateway/platforms/weixin.py,tests/gateway/test_weixin.pyShow the diff
-
gateway/platforms/weixin.py@@ -1017,6 +1017,16 @@ def __init__(self, config: PlatformConfig): self._cdn_base_url = str( extra.get("cdn_base_url") or os.getenv("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL) ).strip().rstrip("/") + self._send_chunk_delay_seconds = float( + extra.get("send_chunk_delay_seconds") or os.getenv("WEIXIN_SEND_CHUNK_DELAY_SECONDS", "0.35") + ) + self._send_chunk_retries = int( + extra.get("send_chunk_retries") or os.getenv("WEIXIN_SEND_CHUNK_RETRIES", "2") + ) + self._send_chunk_retry_delay_seconds = float( + extra.get("send_chunk_retry_delay_seconds") + or os.getenv("WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS", "1.0") + ) self._dm_policy = str(extra.get("dm_policy") or os.getenv("WEIXIN_DM_POLICY", "open")).strip().lower() self._group_policy = str(extra.get("group_policy") or os.getenv("WEIXIN_GROUP_POLICY", "disabled")).strip().lower() allow_from = extra.get("allow_from") @@ -1346,6 +1356,47 @@ def _split_text(self, content: str) -> List[str]: content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages, ) + async def _send_text_chunk( + self, + *, + chat_id: str, + chunk: str, + context_token: [redacted:secret-assignment] + client_id: str, + ) -> None: + """Send a single text chunk with per-chunk retry and backoff.""" + last_error: Optional[Exception] = None + for attempt in range(self._send_chunk_retries + 1): + try: + await _send_message( + self._session, + base_url=self._base_url, + token=self._token, + to=chat_id, + text=chunk, + context_token=[redacted:secret-assignment] + client_id=client_id, + ) + return + except Exception as exc: + last_error = exc + if attempt >= self._send_chunk_retries: + break + wait = self._send_chunk_retry_delay_seconds * (attempt + 1) + logger.warning( + "[%s] send chunk failed to=%s attempt=%d/%d, retrying in %.2fs: %s", + self.name, + _safe_id(chat_id), + attempt + 1, + self._send_chunk_retries + 1, + wait, + exc, + ) + if wait > 0: + await asyncio.sleep(wait) + assert last_error is not None + raise last_error + async def send( self, chat_id: str, @@ -1360,19 +1411,16 @@ async def send( try: chunks = self._split_text(self.format_message(content)) for idx, chunk in enumerate(chunks): - if idx > 0: - await asyncio.sleep(0.3) client_id = f"hermes-weixin-{uuid.uuid4().hex}" - await _send_message( - self._session, - base_url=self._base_url, - token=self._token, - to=chat_id, - text=chunk, + await self._send_text_chunk( + chat_id=chat_id, + chunk=chunk, context_token=[redacted:secret-assignment] client_id=client_id, ) last_message_id = client_id + if idx < len(chunks) - 1 and self._send_chunk_delay_seconds > 0: + await asyncio.sleep(self._send_chunk_delay_seconds) return SendResult(success=True, message_id=last_message_id) except Exception as exc: logger.error("[%s] send failed to=%s: %s", self.name, _safe_id(chat_id), exc) -
tests/gateway/test_weixin.py@@ -283,6 +283,55 @@ def test_send_to_platform_routes_weixin_media_to_native_helper(self, send_weixin ) +class TestWeixinChunkDelivery: + def _connected_adapter(self) -> WeixinAdapter: + adapter = _make_adapter() + adapter._session = object() + adapter._token = "[redacted:secret-assignment]" + adapter._base_url = "https://weixin.example.com" + adapter._token_store.get = lambda account_id, chat_id: "ctx-token" + return adapter + + @patch("gateway.platforms.weixin.asyncio.sleep", new_callable=AsyncMock) + @patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock) + def test_send_waits_between_multiple_chunks(self, send_message_mock, sleep_mock): + adapter = self._connected_adapter() + adapter.MAX_MESSAGE_LENGTH = 12 + + # Use double newlines so _pack_markdown_blocks splits into 3 blocks + result = asyncio.run(adapter.send("wxid_test123", "first\n\nsecond\n\nthird")) + + assert result.success is True + assert send_message_mock.await_count == 3 + assert sleep_mock.await_count == 2 + + @patch("gateway.platforms.weixin.asyncio.sleep", new_callable=AsyncMock) + @patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock) + def test_send_retries_failed_chunk_before_continuing(self, send_message_mock, sleep_mock): + adapter = self._connected_adapter() + adapter.MAX_MESSAGE_LENGTH = 12 + calls = {"count": 0} + + async def flaky_send(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == 2: + raise RuntimeError("temporary iLink failure") + + send_message_mock.side_effect = flaky_send + + # Use double newlines so _pack_markdown_blocks splits into 3 blocks + result = asyncio.run(adapter.send("wxid_test123", "first\n\nsecond\n\nthird")) + + assert result.success is True + # 3 chunks, but chunk 2 fails once and retries → 4 _send_message calls total + assert send_message_mock.await_count == 4 + # The retried chunk should reuse the same client_id for deduplication + first_try = send_message_mock.await_args_list[1].kwargs + retry = send_message_mock.await_args_list[2].kwargs + assert first_try["text"] == retry["text"] + assert first_try["client_id"] == retry["client_id"] + + class TestWeixinRemoteMediaSafety: def test_download_remote_media_blocks_unsafe_urls(self): adapter = _make_adapter()
-
4 PRs closed without merging
From the close comments: merged via another change 2 · other 2
-
#7624 fix(weixin): re-pack message chunks to avoid flooding WeChat chat · merged via another change →
#8665 -
#7899 fix weixin chunk delivery retries · merged via another change →
#7932,#7903 - #46679 fix(weixin): wait for rate-limit cooldown mid-send not truncate (#46529) · other
-
#51924 fix(weixin): 5 WeChat bug fixes — voice STT, polling hang, msg truncation, session split, interrupt · other →
#51924
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.19: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab