{"repo":"NousResearch/hermes-agent","public_id":"6892","display_label":"Inbound text batching fixes across gateway adapters","summary":"These items address missing or incomplete inbound message batching in Discord, Matrix, WeCom, Feishu, and other gateway adapters, including flush timing, adaptive delay, and message splitting behavior.","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":6979,"merged_at":"2026-04-10T06:25:28+00:00"},"resolution":{"state":"resolved","by":"pr-6979"},"freshness":{"state":"stale","last_verified_at":"2026-04-10T06:25:28+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":6892,"pr_number":6979,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-10T06:25:28+00:00"}],"members":[{"kind":"issue","number":6892,"title":"[Gateway] Split inbound messages lost on Discord, Feishu, Matrix, WeCom — no text batching","url":null,"state":"closed"},{"kind":"pr","number":6979,"title":"fix(gateway): add inbound text batching to Discord, Matrix, WeCom + adaptive delay for all","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","gateway/platforms/feishu.py","gateway/platforms/matrix.py","gateway/platforms/telegram.py","gateway/platforms/wecom.py","tests/gateway/test_discord_channel_controls.py","tests/gateway/test_discord_free_response.py","tests/gateway/test_discord_slash_commands.py","tests/gateway/test_matrix_mention.py","tests/gateway/test_text_batching.py","tests/gateway/test_wecom.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -422,6 +422,7 @@ class DiscordAdapter(BasePlatformAdapter):\n \n     # Discord message limits\n     MAX_MESSAGE_LENGTH = 2000\n+    _SPLIT_THRESHOLD = 1900  # near the 2000-char split point\n \n     # Auto-disconnect from voice channel after this many seconds of inactivity\n     VOICE_TIMEOUT = 300","@@ -433,6 +434,11 @@ def __init__(self, config: PlatformConfig):\n         self._allowed_user_ids: set = set()  # For button approval authorization\n         # Voice channel state (per-guild)\n         self._voice_clients: Dict[int, Any] = {}  # guild_id -> VoiceClient\n+        # Text batching: merge rapid successive messages (Telegram-style)\n+        self._text_batch_delay_seconds = float(os.getenv(\"HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS\", \"0.6\"))\n+        self._text_batch_split_delay_seconds = float(os.getenv(\"HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS\", \"2.0\"))\n+        self._pending_text_batches: Dict[str, MessageEvent] = {}\n+        self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}\n         self._voice_text_channels: Dict[int, int] = {}  # guild_id -> text_channel_id\n         self._voice_timeout_tasks: Dict[int, asyncio.Task] = {}  # guild_id -> timeout task\n         # Phase 2: voice listening","@@ -2466,7 +2472,80 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n         if thread_id:\n             self._track_thread(thread_id)\n \n-        await self.handle_message(event)\n+        # Only batch plain text messages — commands, media, etc. dispatch\n+        # immediately since they won't be split by the Discord client.\n+        if msg_type == MessageType.TEXT:\n+            self._enqueue_text_event(event)\n+        else:\n+            await self.handle_message(event)\n+\n+    # ------------------------------------------------------------------\n+    # Text message aggregation (handles Discord client-side splits)\n+    # ------------------------------------------------------------------\n+\n+    def _text_batch_key(self, event: MessageEvent) -> str:\n+        \"\"\"Session-scoped key for text message batching.\"\"\"\n+        from gateway.session import build_session_key\n+        return build_session_key(\n+            event.source,\n+            group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n+            thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+        )\n+\n+    def _enqueue_text_event(self, event: MessageEvent) -> None:\n+        \"\"\"Buffer a text event and reset the flush timer.\n+\n+        When Discord splits a long user message at 2000 chars, the chunks\n+        arrive within a few hundred milliseconds.  This merges them into\n+        a single event before dispatching.\n+        \"\"\"\n+        key = self._text_batch_key(event)\n+        existing = self._pending_text_batches.get(key)\n+        chunk_len = len(event.text or \"\")\n+        if existing is None:\n+            event._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            self._pending_text_batches[key] = event\n+        else:\n+            if event.text:\n+                existing.text = f\"{existing.text}\\n{event.text}\" if existing.text else event.text\n+            existing._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            if event.media_urls:\n+                existing.media_urls.extend(event.media_urls)\n+                existing.media_types.extend(event.media_types)\n+\n+        prior_task = self._pending_text_batch_tasks.get(key)\n+        if prior_task and not prior_task.done():\n+            prior_task.cancel()\n+        self._pending_text_batch_tasks[key] = asyncio.create_task(\n+            self._flush_text_batch(key)\n+        )\n+\n+    async def _flush_text_batch(self, key: str) -> None:\n+        \"\"\"Wait for the quiet period then dispatch the aggregated text.\n+\n+        Uses a longer delay when the latest chunk is near Discord's 2000-char\n+        split point, since a continuation chunk is almost certain.\n+        \"\"\"\n+        current_task = asyncio.current_task()\n+        try:\n+            pending = self._pending_text_batches.get(key)\n+            last_len = getattr(pending, \"_last_chunk_len\", 0) if pending else 0\n+            if last_len >= self._SPLIT_THRESHOLD:\n+                delay = self._text_batch_split_delay_seconds\n+            else:\n+                delay = self._text_batch_delay_seconds\n+            await asyncio.sleep(delay)\n+            event = self._pending_text_batches.pop(key, None)\n+            if not event:\n+                return\n+            logger.info(\n+                \"[Discord] Flushing text batch %s (%d chars)\",\n+                key, len(event.text or \"\"),\n+            )\n+            await self.handle_message(event)\n+        finally:\n+            if self._pending_text_batch_tasks.get(key) is current_task:\n+                self._pending_text_batch_tasks.pop(key, None)\n \n \n # ---------------------------------------------------------------------------"]},{"file":"gateway/platforms/feishu.py","hunks":["@@ -264,6 +264,7 @@ class FeishuAdapterSettings:\n     bot_name: str\n     dedup_cache_size: int\n     text_batch_delay_seconds: float\n+    text_batch_split_delay_seconds: float\n     text_batch_max_messages: int\n     text_batch_max_chars: int\n     media_batch_delay_seconds: float","@@ -1014,6 +1015,10 @@ class FeishuAdapter(BasePlatformAdapter):\n     \"\"\"Feishu/Lark bot adapter.\"\"\"\n \n     MAX_MESSAGE_LENGTH = 8000\n+    # Threshold for detecting Feishu client-side message splits.\n+    # When a chunk is near the ~4096-char practical limit, a continuation\n+    # is almost certain.\n+    _SPLIT_THRESHOLD = 4000\n \n     # =========================================================================\n     # Lifecycle — init / settings / connect / disconnect","@@ -1105,6 +1110,9 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:\n             text_batch_delay_seconds=float(\n                 os.getenv(\"HERMES_FEISHU_TEXT_BATCH_DELAY_SECONDS\", str(_DEFAULT_TEXT_BATCH_DELAY_SECONDS))\n             ),\n+            text_batch_split_delay_seconds=float(\n+                os.getenv(\"HERMES_FEISHU_TEXT_BATCH_SPLIT_DELAY_SECONDS\", \"2.0\")\n+            ),\n             text_batch_max_messages=max(\n                 1,\n                 int(os.getenv(\"HERMES_FEISHU_TEXT_BATCH_MAX_MESSAGES\", str(_DEFAULT_TEXT_BATCH_MAX_MESSAGES))),","@@ -1152,6 +1160,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None:\n         self._bot_name = settings.bot_name\n         self._dedup_cache_size = settings.dedup_cache_size\n         self._text_batch_delay_seconds = settings.text_batch_delay_seconds\n+        self._text_batch_split_delay_seconds = settings.text_batch_split_delay_seconds\n         self._text_batch_max_messages = settings.text_batch_max_messages\n         self._text_batch_max_chars = settings.text_batch_max_chars\n         self._media_batch_delay_seconds = settings.media_batch_delay_seconds","@@ -2478,8 +2487,10 @@ def _text_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) ->\n     async def _enqueue_text_event(self, event: MessageEvent) -> None:\n         \"\"\"Debounce rapid Feishu text bursts into a single MessageEvent.\"\"\"\n         key = self._text_batch_key(event)\n+        chunk_len = len(event.text or \"\")\n         existing = self._pending_text_batches.get(key)\n         if existing is None:\n+            event._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n             self._pending_text_batches[key] = event\n             self._pending_text_batch_counts[key] = 1\n             self._schedule_text_batch_flush(key)","@@ -2504,6 +2515,7 @@ async def _enqueue_text_event(self, event: MessageEvent) -> None:\n             return\n \n         existing.text = next_text\n+        existing._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n         existing.timestamp = event.timestamp\n         if event.message_id:\n             existing.message_id = event.message_id","@@ -2530,10 +2542,22 @@ def _reschedule_batch_task(\n         task_map[key] = asyncio.create_task(flush_fn(key))\n \n     async def _flush_text_batch(self, key: str) -> None:\n-        \"\"\"Flush a pending text batch after the quiet period.\"\"\"\n+        \"\"\"Flush a pending text batch after the quiet period.\n+\n+        Uses a longer delay when the latest chunk is near Feishu's ~4096-char\n+        split point, since a continuation chunk is almost certain.\n+        \"\"\"\n         current_task = asyncio.current_task()\n         try:\n-            await asyncio.sleep(self._text_batch_delay_seconds)\n+            # Adaptive delay: if the latest chunk is near the split threshold,\n+            # a continuation is almost certain — wait longer.\n+            pending = self._pending_text_batches.get(key)\n+            last_len = getattr(pending, \"_last_chunk_len\", 0) if pending else 0\n+            if last_len >= self._SPLIT_THRESHOLD:\n+                delay = self._text_batch_split_delay_seconds\n+            else:\n+                delay = self._text_batch_delay_seconds\n+            await asyncio.sleep(delay)\n             await self._flush_text_batch_now(key)\n         finally:\n             if self._pending_text_batch_tasks.get(key) is current_task:"]},{"file":"gateway/platforms/matrix.py","hunks":["@@ -120,6 +120,11 @@ def check_matrix_requirements() -> bool:\n class MatrixAdapter(BasePlatformAdapter):\n     \"\"\"Gateway adapter for Matrix (any homeserver).\"\"\"\n \n+    # Threshold for detecting Matrix client-side message splits.\n+    # When a chunk is near the ~4000-char practical limit, a continuation\n+    # is almost certain.\n+    _SPLIT_THRESHOLD = 3900\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.MATRIX)\n ","@@ -172,6 +177,13 @@ def __init__(self, config: PlatformConfig):\n             \"MATRIX_REACTIONS\", \"true\"\n         ).lower() not in (\"false\", \"0\", \"no\")\n \n+        # Text batching: merge rapid successive messages (Telegram-style).\n+        # Matrix clients split long messages around 4000 chars.\n+        self._text_batch_delay_seconds = float(os.getenv(\"HERMES_MATRIX_TEXT_BATCH_DELAY_SECONDS\", \"0.6\"))\n+        self._text_batch_split_delay_seconds = float(os.getenv(\"HERMES_MATRIX_TEXT_BATCH_SPLIT_DELAY_SECONDS\", \"2.0\"))\n+        self._pending_text_batches: Dict[str, MessageEvent] = {}\n+        self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}\n+\n     def _is_duplicate_event(self, event_id) -> bool:\n         \"\"\"Return True if this event was already processed. Tracks the ID otherwise.\"\"\"\n         if not event_id:","@@ -1088,7 +1100,81 @@ async def _on_room_message(self, room: Any, event: Any) -> None:\n         # Acknowledge receipt so the room shows as read (fire-and-forget).\n         self._background_read_receipt(room.room_id, event.event_id)\n \n-        await self.handle_message(msg_event)\n+        # Only batch plain text messages — commands dispatch immediately.\n+        if msg_type == MessageType.TEXT:\n+            self._enqueue_text_event(msg_event)\n+        else:\n+            await self.handle_message(msg_event)\n+\n+    # ------------------------------------------------------------------\n+    # Text message aggregation (handles Matrix client-side splits)\n+    # ------------------------------------------------------------------\n+\n+    def _text_batch_key(self, event: MessageEvent) -> str:\n+        \"\"\"Session-scoped key for text message batching.\"\"\"\n+        from gateway.session import build_session_key\n+        return build_session_key(\n+            event.source,\n+            group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n+            thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+        )\n+\n+    def _enqueue_text_event(self, event: MessageEvent) -> None:\n+        \"\"\"Buffer a text event and reset the flush timer.\n+\n+        When a Matrix client splits a long message, the chunks arrive within\n+        a few hundred milliseconds.  This merges them into a single event\n+        before dispatching.\n+        \"\"\"\n+        key = self._text_batch_key(event)\n+        existing = self._pending_text_batches.get(key)\n+        chunk_len = len(event.text or \"\")\n+        if existing is None:\n+            event._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            self._pending_text_batches[key] = event\n+        else:\n+            if event.text:\n+                existing.text = f\"{existing.text}\\n{event.text}\" if existing.text else event.text\n+            existing._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            # Merge any media that might be attached\n+            if event.media_urls:\n+                existing.media_urls.extend(event.media_urls)\n+                existing.media_types.extend(event.media_types)\n+\n+        # Cancel any pending flush and restart the timer\n+        prior_task = self._pending_text_batch_tasks.get(key)\n+        if prior_task and not prior_task.done():\n+            prior_task.cancel()\n+        self._pending_text_batch_tasks[key] = asyncio.create_task(\n+            self._flush_text_batch(key)\n+        )\n+\n+    async def _flush_text_batch(self, key: str) -> None:\n+        \"\"\"Wait for the quiet period then dispatch the aggregated text.\n+\n+        Uses a longer delay when the latest chunk is near Matrix's ~4000-char\n+        split point, since a continuation chunk is almost certain.\n+        \"\"\"\n+        current_task = asyncio.current_task()\n+        try:\n+            pending = self._pending_text_batches.get(key)\n+            last_len = getattr(pending, \"_last_chunk_len\", 0) if pending else 0\n+            if last_len >= self._SPLIT_THRESHOLD:\n+                delay = self._text_batch_split_delay_seconds\n+            else:\n+                delay = self._text_batch_delay_seconds\n+            await asyncio.sleep(delay)\n+            event = self._pending_text_batches.pop(key, None)\n+            if not event:\n+                return\n+            logger.info(\n+                \"[Matrix] Flushing text batch %s (%d chars)\",\n+                key, len(event.text or \"\"),\n+            )\n+            await self.handle_message(event)\n+        finally:\n+            if self._pending_text_batch_tasks.get(key) is current_task:\n+                self._pending_text_batch_tasks.pop(key, None)\n \n     async def _on_room_message_media(self, room: Any, event: Any) -> None:\n         \"\"\"Handle incoming media messages (images, audio, video, files).\"\"\""]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -121,6 +121,9 @@ class TelegramAdapter(BasePlatformAdapter):\n     \n     # Telegram message limits\n     MAX_MESSAGE_LENGTH = 4096\n+    # Threshold for detecting Telegram client-side message splits.\n+    # When a chunk is near this limit, a continuation is almost certain.\n+    _SPLIT_THRESHOLD = 4000\n     MEDIA_GROUP_WAIT_SECONDS = 0.8\n     \n     def __init__(self, config: PlatformConfig):","@@ -140,6 +143,7 @@ def __init__(self, config: PlatformConfig):\n         # Buffer rapid text messages so Telegram client-side splits of long\n         # messages are aggregated into a single MessageEvent.\n         self._text_batch_delay_seconds = float(os.getenv(\"HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS\", \"0.6\"))\n+        self._text_batch_split_delay_seconds = float(os.getenv(\"HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS\", \"2.0\"))\n         self._pending_text_batches: Dict[str, MessageEvent] = {}\n         self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}\n         self._token_lock_identity: [redacted:secret-assignment] = None","@@ -2160,12 +2164,15 @@ def _enqueue_text_event(self, event: MessageEvent) -> None:\n         \"\"\"\n         key = self._text_batch_key(event)\n         existing = self._pending_text_batches.get(key)\n+        chunk_len = len(event.text or \"\")\n         if existing is None:\n+            event._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n             self._pending_text_batches[key] = event\n         else:\n             # Append text from the follow-up chunk\n             if event.text:\n                 existing.text = f\"{existing.text}\\n{event.text}\" if existing.text else event.text\n+            existing._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n             # Merge any media that might be attached\n             if event.media_urls:\n                 existing.media_urls.extend(event.media_urls)","@@ -2180,10 +2187,22 @@ def _enqueue_text_event(self, event: MessageEvent) -> None:\n         )\n \n     async def _flush_text_batch(self, key: str) -> None:\n-        \"\"\"Wait for the quiet period then dispatch the aggregated text.\"\"\"\n+        \"\"\"Wait for the quiet period then dispatch the aggregated text.\n+\n+        Uses a longer delay when the latest chunk is near Telegram's 4096-char\n+        split point, since a continuation chunk is almost certain.\n+        \"\"\"\n         current_task = asyncio.current_task()\n         try:\n-            await asyncio.sleep(self._text_batch_delay_seconds)\n+            # Adaptive delay: if the latest chunk is near Telegram's 4096-char\n+            # split point, a continuation is almost certain — wait longer.\n+            pending = self._pending_text_batches.get(key)\n+            last_len = getattr(pending, \"_last_chunk_len\", 0) if pending else 0\n+            if last_len >= self._SPLIT_THRESHOLD:\n+                delay = self._text_batch_split_delay_seconds\n+            else:\n+                delay = self._text_batch_delay_seconds\n+            await asyncio.sleep(delay)\n             event = self._pending_text_batches.pop(key, None)\n             if not event:\n                 return"]},{"file":"gateway/platforms/wecom.py","hunks":["@@ -143,6 +143,9 @@ class WeComAdapter(BasePlatformAdapter):\n     \"\"\"WeCom AI Bot adapter backed by a persistent WebSocket connection.\"\"\"\n \n     MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH\n+    # Threshold for detecting WeCom client-side message splits.\n+    # When a chunk is near the 4000-char limit, a continuation is almost certain.\n+    _SPLIT_THRESHOLD = 3900\n \n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.WECOM)","@@ -172,6 +175,13 @@ def __init__(self, config: PlatformConfig):\n         self._seen_messages: Dict[str, float] = {}\n         self._reply_req_ids: Dict[str, str] = {}\n \n+        # Text batching: merge rapid successive messages (Telegram-style).\n+        # WeCom clients split long messages around 4000 chars.\n+        self._text_batch_delay_seconds = float(os.getenv(\"HERMES_WECOM_TEXT_BATCH_DELAY_SECONDS\", \"0.6\"))\n+        self._text_batch_split_delay_seconds = float(os.getenv(\"HERMES_WECOM_TEXT_BATCH_SPLIT_DELAY_SECONDS\", \"2.0\"))\n+        self._pending_text_batches: Dict[str, MessageEvent] = {}\n+        self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}\n+\n     # ------------------------------------------------------------------\n     # Connection lifecycle\n     # ------------------------------------------------------------------","@@ -519,7 +529,82 @@ async def _on_message(self, payload: Dict[str, Any]) -> None:\n             timestamp=datetime.now(tz=timezone.utc),\n         )\n \n-        await self.handle_message(event)\n+        # Only batch plain text messages — commands, media, etc. dispatch\n+        # immediately since they won't be split by the WeCom client.\n+        if message_type == MessageType.TEXT:\n+            self._enqueue_text_event(event)\n+        else:\n+            await self.handle_message(event)\n+\n+    # ------------------------------------------------------------------\n+    # Text message aggregation (handles WeCom client-side splits)\n+    # ------------------------------------------------------------------\n+\n+    def _text_batch_key(self, event: MessageEvent) -> str:\n+        \"\"\"Session-scoped key for text message batching.\"\"\"\n+        from gateway.session import build_session_key\n+        return build_session_key(\n+            event.source,\n+            group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n+            thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+        )\n+\n+    def _enqueue_text_event(self, event: MessageEvent) -> None:\n+        \"\"\"Buffer a text event and reset the flush timer.\n+\n+        When WeCom splits a long user message at 4000 chars, the chunks\n+        arrive within a few hundred milliseconds.  This merges them into\n+        a single event before dispatching.\n+        \"\"\"\n+        key = self._text_batch_key(event)\n+        existing = self._pending_text_batches.get(key)\n+        chunk_len = len(event.text or \"\")\n+        if existing is None:\n+            event._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            self._pending_text_batches[key] = event\n+        else:\n+            if event.text:\n+                existing.text = f\"{existing.text}\\n{event.text}\" if existing.text else event.text\n+            existing._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n+            # Merge any media that might be attached\n+            if event.media_urls:\n+                existing.media_urls.extend(event.media_urls)\n+                existing.media_types.extend(event.media_types)\n+\n+        # Cancel any pending flush and restart the timer\n+        prior_task = self._pending_text_batch_tasks.get(key)\n+        if prior_task and not prior_task.done():\n+            prior_task.cancel()\n+        self._pending_text_batch_tasks[key] = asyncio.create_task(\n+            self._flush_text_batch(key)\n+        )\n+\n+    async def _flush_text_batch(self, key: str) -> None:\n+        \"\"\"Wait for the quiet period then dispatch the aggregated text.\n+\n+        Uses a longer delay when the latest chunk is near WeCom's 4000-char\n+        split point, since a continuation chunk is almost certain.\n+        \"\"\"\n+        current_task = asyncio.current_task()\n+        try:\n+            pending = self._pending_text_batches.get(key)\n+            last_len = getattr(pending, \"_last_chunk_len\", 0) if pending else 0\n+            if last_len >= self._SPLIT_THRESHOLD:\n+                delay = self._text_batch_split_delay_seconds\n+            else:\n+                delay = self._text_batch_delay_seconds\n+            await asyncio.sleep(delay)\n+            event = self._pending_text_batches.pop(key, None)\n+            if not event:\n+                return\n+            logger.info(\n+                \"[WeCom] Flushing text batch %s (%d chars)\",\n+                key, len(event.text or \"\"),\n+            )\n+            await self.handle_message(event)\n+        finally:\n+            if self._pending_text_batch_tasks.get(key) is current_task:\n+                self._pending_text_batch_tasks.pop(key, None)\n \n     @staticmethod\n     def _extract_text(body: Dict[str, Any]) -> Tuple[str, Optional[str]]:"]},{"file":"tests/gateway/test_discord_channel_controls.py","hunks":["@@ -81,6 +81,7 @@ def adapter(monkeypatch):\n     config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n     adapter = DiscordAdapter(config)\n     adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))\n+    adapter._text_batch_delay_seconds = 0  # disable batching for tests\n     adapter.handle_message = AsyncMock()\n     return adapter\n "]},{"file":"tests/gateway/test_discord_free_response.py","hunks":["@@ -91,6 +91,7 @@ def adapter(monkeypatch):\n     config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n     adapter = DiscordAdapter(config)\n     adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))\n+    adapter._text_batch_delay_seconds = 0  # disable batching for tests\n     adapter.handle_message = AsyncMock()\n     return adapter\n "]},{"file":"tests/gateway/test_discord_slash_commands.py","hunks":["@@ -62,6 +62,7 @@ def adapter():\n         fetch_channel=AsyncMock(),\n         user=SimpleNamespace(id=99999, name=\"HermesBot\"),\n     )\n+    adapter._text_batch_delay_seconds = 0  # disable batching for tests\n     return adapter\n \n "]},{"file":"tests/gateway/test_matrix_mention.py","hunks":["@@ -44,6 +44,7 @@ def _make_adapter(tmp_path=None):\n         },\n     )\n     adapter = MatrixAdapter(config)\n+    adapter._text_batch_delay_seconds = 0  # disable batching for tests\n     adapter.handle_message = AsyncMock()\n     adapter._startup_ts = time.time() - 10  # avoid startup grace filter\n     return adapter"]},{"file":"tests/gateway/test_text_batching.py","hunks":["@@ -0,0 +1,448 @@\n+\"\"\"Tests for text message batching across all gateway adapters.\n+\n+When a user sends a long message, the messaging client splits it at the\n+platform's character limit.  Each adapter should buffer rapid successive\n+text messages from the same session and aggregate them before dispatching.\n+\n+Covers: Discord, Matrix, WeCom, and the adaptive delay logic for\n+Telegram and Feishu.\n+\"\"\"\n+\n+import asyncio\n+import os\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent, MessageType, SessionSource\n+\n+\n+# =====================================================================\n+# Helpers\n+# =====================================================================\n+\n+def _make_event(\n+    text: str,\n+    platform: Platform,\n+    chat_id: str = \"12345\",\n+    msg_type: MessageType = MessageType.TEXT,\n+) -> MessageEvent:\n+    return MessageEvent(\n+        text=text,\n+        message_type=msg_type,\n+        source=SessionSource(platform=platform, chat_id=chat_id, chat_type=\"dm\"),\n+    )\n+\n+\n+# =====================================================================\n+# Discord text batching\n+# =====================================================================\n+\n+def _make_discord_adapter():\n+    \"\"\"Create a minimal DiscordAdapter for testing text batching.\"\"\"\n+    from gateway.platforms.discord import DiscordAdapter\n+\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter = object.__new__(DiscordAdapter)\n+    adapter._platform = Platform.DISCORD\n+    adapter.config = config\n+    adapter._pending_text_batches = {}\n+    adapter._pending_text_batch_tasks = {}\n+    adapter._text_batch_delay_seconds = 0.1  # fast for tests\n+    adapter._text_batch_split_delay_seconds = 0.3  # fast for tests\n+    adapter._active_sessions = {}\n+    adapter._pending_messages = {}\n+    adapter._message_handler = AsyncMock()\n+    adapter.handle_message = AsyncMock()\n+    return adapter\n+\n+\n+class TestDiscordTextBatching:\n+    @pytest.mark.asyncio\n+    async def test_single_message_dispatched_after_delay(self):\n+        adapter = _make_discord_adapter()\n+        event = _make_event(\"hello world\", Platform.DISCORD)\n+\n+        adapter._enqueue_text_event(event)\n+\n+        # Not dispatched yet\n+        adapter.handle_message.assert_not_called()\n+\n+        # Wait for flush\n+        await asyncio.sleep(0.2)\n+\n+        adapter.handle_message.assert_called_once()\n+        dispatched = adapter.handle_message.call_args[0][0]\n+        assert dispatched.text == \"hello world\"\n+\n+    @pytest.mark.asyncio\n+    async def test_split_messages_aggregated(self):\n+        \"\"\"Two rapid messages from the same chat should be merged.\"\"\"\n+        adapter = _make_discord_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"Part one of a long\", Platform.DISCORD))\n+        await asyncio.sleep(0.02)\n+        adapter._enqueue_text_event(_make_event(\"message that was split.\", Platform.DISCORD))\n+\n+        adapter.handle_message.assert_not_called()\n+\n+        await asyncio.sleep(0.2)\n+\n+        adapter.handle_message.assert_called_once()\n+        text = adapter.handle_message.call_args[0][0].text\n+        assert \"Part one\" in text\n+        assert \"split\" in text\n+\n+    @pytest.mark.asyncio\n+    async def test_three_way_split_aggregated(self):\n+        adapter = _make_discord_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"chunk 1\", Platform.DISCORD))\n+        await asyncio.sleep(0.02)\n+        adapter._enqueue_text_event(_make_event(\"chunk 2\", Platform.DISCORD))\n+        await asyncio.sleep(0.02)\n+        adapter._enqueue_text_event(_make_event(\"chunk 3\", Platform.DISCORD))\n+\n+        await asyncio.sleep(0.2)\n+\n+        adapter.handle_message.assert_called_once()\n+        text = adapter.handle_message.call_args[0][0].text\n+        assert \"chunk 1\" in text\n+        assert \"chunk 2\" in text\n+        assert \"chunk 3\" in text\n+\n+    @pytest.mark.asyncio\n+    async def test_different_chats_not_merged(self):\n+        adapter = _make_discord_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"from A\", Platform.DISCORD, chat_id=\"111\"))\n+        adapter._enqueue_text_event(_make_event(\"from B\", Platform.DISCORD, chat_id=\"222\"))\n+\n+        await asyncio.sleep(0.2)\n+\n+        assert adapter.handle_message.call_count == 2\n+\n+    @pytest.mark.asyncio\n+    async def test_batch_cleans_up_after_flush(self):\n+        adapter = _make_discord_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"test\", Platform.DISCORD))\n+        await asyncio.sleep(0.2)\n+\n+        assert len(adapter._pending_text_batches) == 0\n+\n+    @pytest.mark.asyncio\n+    async def test_adaptive_delay_for_near_limit_chunk(self):\n+        \"\"\"Chunks near the 2000-char limit should trigger longer delay.\"\"\"\n+        adapter = _make_discord_adapter()\n+        # Simulate a chunk near Discord's 2000-char split point\n+        long_text = \"x\" * 1950\n+        adapter._enqueue_text_event(_make_event(long_text, Platform.DISCORD))\n+\n+        # After the short delay (0.1s), should NOT have flushed yet (split delay is 0.3s)\n+        await asyncio.sleep(0.15)\n+        adapter.handle_message.assert_not_called()\n+\n+        # After the split delay, should be flushed\n+        await asyncio.sleep(0.25)\n+        adapter.handle_message.assert_called_once()\n+\n+\n+# =====================================================================\n+# Matrix text batching\n+# =====================================================================\n+\n+def _make_matrix_adapter():\n+    \"\"\"Create a minimal MatrixAdapter for testing text batching.\"\"\"\n+    from gateway.platforms.matrix import MatrixAdapter\n+\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter = object.__new__(MatrixAdapter)\n+    adapter._platform = Platform.MATRIX\n+    adapter.config = config\n+    adapter._pending_text_batches = {}\n+    adapter._pending_text_batch_tasks = {}\n+    adapter._text_batch_delay_seconds = 0.1\n+    adapter._text_batch_split_delay_seconds = 0.3\n+    adapter._active_sessions = {}\n+    adapter._pending_messages = {}\n+    adapter._message_handler = AsyncMock()\n+    adapter.handle_message = AsyncMock()\n+    return adapter\n+\n+\n+class TestMatrixTextBatching:\n+    @pytest.mark.asyncio\n+    async def test_single_message_dispatched_after_delay(self):\n+        adapter = _make_matrix_adapter()\n+        event = _make_event(\"hello world\", Platform.MATRIX)\n+\n+        adapter._enqueue_text_event(event)\n+\n+        adapter.handle_message.assert_not_called()\n+        await asyncio.sleep(0.2)\n+\n+        adapter.handle_message.assert_called_once()\n+        assert adapter.handle_message.call_args[0][0].text == \"hello world\"\n+\n+    @pytest.mark.asyncio\n+    async def test_split_messages_aggregated(self):\n+        adapter = _make_matrix_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"first part\", Platform.MATRIX))\n+        await asyncio.sleep(0.02)\n+        adapter._enqueue_text_event(_make_event(\"second part\", Platform.MATRIX))\n+\n+        adapter.handle_message.assert_not_called()\n+        await asyncio.sleep(0.2)\n+\n+        adapter.handle_message.assert_called_once()\n+        text = adapter.handle_message.call_args[0][0].text\n+        assert \"first part\" in text\n+        assert \"second part\" in text\n+\n+    @pytest.mark.asyncio\n+    async def test_different_rooms_not_merged(self):\n+        adapter = _make_matrix_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"room A\", Platform.MATRIX, chat_id=\"!aaa:matrix.org\"))\n+        adapter._enqueue_text_event(_make_event(\"room B\", Platform.MATRIX, chat_id=\"!bbb:matrix.org\"))\n+\n+        await asyncio.sleep(0.2)\n+\n+        assert adapter.handle_message.call_count == 2\n+\n+    @pytest.mark.asyncio\n+    async def test_adaptive_delay_for_near_limit_chunk(self):\n+        \"\"\"Chunks near the 4000-char limit should trigger longer delay.\"\"\"\n+        adapter = _make_matrix_adapter()\n+        long_text = \"x\" * 3950\n+        adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX))\n+\n+        await asyncio.sleep(0.15)\n+        adapter.handle_message.assert_not_called()\n+\n+        await asyncio.sleep(0.25)\n+        adapter.handle_message.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_batch_cleans_up_after_flush(self):\n+        adapter = _make_matrix_adapter()\n+        adapter._enqueue_text_event(_make_event(\"test\", Platform.MATRIX))\n+        await asyncio.sleep(0.2)\n+        assert len(adapter._pending_text_batches) == 0\n+\n+\n+# =====================================================================\n+# WeCom text batching\n+# =====================================================================\n+\n+def _make_wecom_adapter():\n+    \"\"\"Create a minimal WeComAdapter for testing text batching.\"\"\"\n+    from gateway.platforms.wecom import WeComAdapter\n+\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter = object.__new__(WeComAdapter)\n+    adapter._platform = Platform.WECOM\n+    adapter.config = config\n+    adapter._pending_text_batches = {}\n+    adapter._pending_text_batch_tasks = {}\n+    adapter._text_batch_delay_seconds = 0.1\n+    adapter._text_batch_split_delay_seconds = 0.3\n+    adapter._active_sessions = {}\n+    adapter._pending_messages = {}\n+    adapter._message_handler = AsyncMock()\n+    adapter.handle_message = AsyncMock()\n+    return adapter\n+\n+\n+class TestWeComTextBatching:\n+    @pytest.mark.asyncio\n+    async def test_single_message_dispatched_after_delay(self):\n+        adapter = _make_wecom_adapter()\n+        event = _make_event(\"hello world\", Platform.WECOM)\n+\n+        adapter._enqueue_text_event(event)\n+\n+        adapter.handle_message.assert_not_called()\n+        await asyncio.sleep(0.2)\n+\n+        adapter.handle_message.assert_called_once()\n+        assert adapter.handle_message.call_args[0][0].text == \"hello world\"\n+\n+    @pytest.mark.asyncio\n+    async def test_split_messages_aggregated(self):\n+        adapter = _make_wecom_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"first part\", Platform.WECOM))\n+        await asyncio.sleep(0.02)\n+        adapter._enqueue_text_event(_make_event(\"second part\", Platform.WECOM))\n+\n+        adapter.handle_message.assert_not_called()\n+        await asyncio.sleep(0.2)\n+\n+        adapter.handle_message.assert_called_once()\n+        text = adapter.handle_message.call_args[0][0].text\n+        assert \"first part\" in text\n+        assert \"second part\" in text\n+\n+    @pytest.mark.asyncio\n+    async def test_different_chats_not_merged(self):\n+        adapter = _make_wecom_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"chat A\", Platform.WECOM, chat_id=\"chat_a\"))\n+        adapter._enqueue_text_event(_make_event(\"chat B\", Platform.WECOM, chat_id=\"chat_b\"))\n+\n+        await asyncio.sleep(0.2)\n+\n+        assert adapter.handle_message.call_count == 2\n+\n+    @pytest.mark.asyncio\n+    async def test_adaptive_delay_for_near_limit_chunk(self):\n+        \"\"\"Chunks near the 4000-char limit should trigger longer delay.\"\"\"\n+        adapter = _make_wecom_adapter()\n+        long_text = \"x\" * 3950\n+        adapter._enqueue_text_event(_make_event(long_text, Platform.WECOM))\n+\n+        await asyncio.sleep(0.15)\n+        adapter.handle_message.assert_not_called()\n+\n+        await asyncio.sleep(0.25)\n+        adapter.handle_message.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_batch_cleans_up_after_flush(self):\n+        adapter = _make_wecom_adapter()\n+        adapter._enqueue_text_event(_make_event(\"test\", Platform.WECOM))\n+        await asyncio.sleep(0.2)\n+        assert len(adapter._pending_text_batches) == 0\n+\n+\n+# =====================================================================\n+# Telegram adaptive delay (PR #6891)\n+# =====================================================================\n+\n+def _make_telegram_adapter():\n+    \"\"\"Create a minimal TelegramAdapter for testing adaptive delay.\"\"\"\n+    from gateway.platforms.telegram import TelegramAdapter\n+\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter = object.__new__(TelegramAdapter)\n+    adapter._platform = Platform.TELEGRAM\n+    adapter.config = config\n+    adapter._pending_text_batches = {}\n+    adapter._pending_text_batch_tasks = {}\n+    adapter._text_batch_delay_seconds = 0.1\n+    adapter._text_batch_split_delay_seconds = 0.3\n+    adapter._active_sessions = {}\n+    adapter._pending_messages = {}\n+    adapter._message_handler = AsyncMock()\n+    adapter.handle_message = AsyncMock()\n+    return adapter\n+\n+\n+class TestTelegramAdaptiveDelay:\n+    @pytest.mark.asyncio\n+    async def test_short_chunk_uses_normal_delay(self):\n+        adapter = _make_telegram_adapter()\n+        adapter._enqueue_text_event(_make_event(\"short msg\", Platform.TELEGRAM))\n+\n+        # Should flush after the normal 0.1s delay\n+        await asyncio.sleep(0.15)\n+        adapter.handle_message.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_near_limit_chunk_uses_split_delay(self):\n+        \"\"\"A chunk near the 4096-char limit should trigger longer delay.\"\"\"\n+        adapter = _make_telegram_adapter()\n+        long_text = \"x\" * 4050  # near the 4096 limit\n+        adapter._enqueue_text_event(_make_event(long_text, Platform.TELEGRAM))\n+\n+        # After the short delay, should NOT have flushed yet\n+        await asyncio.sleep(0.15)\n+        adapter.handle_message.assert_not_called()\n+\n+        # After the split delay, should be flushed\n+        await asyncio.sleep(0.25)\n+        adapter.handle_message.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_split_continuation_merged(self):\n+        \"\"\"Two near-limit chunks should both be merged.\"\"\"\n+        adapter = _make_telegram_adapter()\n+\n+        adapter._enqueue_text_event(_make_event(\"x\" * 4050, Platform.TELEGRAM))\n+        await asyncio.sleep(0.05)\n+        adapter._enqueue_text_event(_make_event(\"continuation text\", Platform.TELEGRAM))\n+\n+        # Short chunk arrived → should use normal delay now\n+        await asyncio.sleep(0.15)\n+        adapter.handle_message.assert_called_once()\n+        text = adapter.handle_message.call_args[0][0].text\n+        assert \"continuation text\" in text\n+\n+\n+# =====================================================================\n+# Feishu adaptive delay\n+# =====================================================================\n+\n+def _make_feishu_adapter():\n+    \"\"\"Create a minimal FeishuAdapter for testing adaptive delay.\"\"\"\n+    from gateway.platforms.feishu import FeishuAdapter, FeishuBatchState\n+\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter = object.__new__(FeishuAdapter)\n+    adapter._platform = Platform.FEISHU\n+    adapter.config = config\n+    batch_state = FeishuBatchState()\n+    adapter._pending_text_batches = batch_state.events\n+    adapter._pending_text_batch_tasks = batch_state.tasks\n+    adapter._pending_text_batch_counts = batch_state.counts\n+    adapter._text_batch_delay_seconds = 0.1\n+    adapter._text_batch_split_delay_seconds = 0.3\n+    adapter._text_batch_max_messages = 20\n+    adapter._text_batch_max_chars = 50000\n+    adapter._active_sessions = {}\n+    adapter._pending_messages = {}\n+    adapter._message_handler = AsyncMock()\n+    adapter._handle_message_with_guards = AsyncMock()\n+    return adapter\n+\n+\n+class TestFeishuAdaptiveDelay:\n+    @pytest.mark.asyncio\n+    async def test_short_chunk_uses_normal_delay(self):\n+        adapter = _make_feishu_adapter()\n+        event = _make_event(\"short msg\", Platform.FEISHU)\n+        await adapter._enqueue_text_event(event)\n+\n+        await asyncio.sleep(0.15)\n+        adapter._handle_message_with_guards.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_near_limit_chunk_uses_split_delay(self):\n+        \"\"\"A chunk near the 4096-char limit should trigger longer delay.\"\"\"\n+        adapter = _make_feishu_adapter()\n+        long_text = \"x\" * 4050\n+        event = _make_event(long_text, Platform.FEISHU)\n+        await adapter._enqueue_text_event(event)\n+\n+        await asyncio.sleep(0.15)\n+        adapter._handle_message_with_guards.assert_not_called()\n+\n+        await asyncio.sleep(0.25)\n+        adapter._handle_message_with_guards.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_split_continuation_merged(self):\n+        adapter = _make_feishu_adapter()\n+\n+        await adapter._enqueue_text_event(_make_event(\"x\" * 4050, Platform.FEISHU))\n+        await asyncio.sleep(0.05)\n+        await adapter._enqueue_text_event(_make_event(\"continuation text\", Platform.FEISHU))\n+\n+        await asyncio.sleep(0.15)\n+        adapter._handle_message_with_guards.assert_called_once()\n+        text = adapter._handle_message_with_guards.call_args[0][0].text\n+        assert \"continuation text\" in text"]},{"file":"tests/gateway/test_wecom.py","hunks":["@@ -508,6 +508,7 @@ async def test_on_message_builds_event(self):\n         from gateway.platforms.wecom import WeComAdapter\n \n         adapter = WeComAdapter(PlatformConfig(enabled=True))\n+        adapter._text_batch_delay_seconds = 0  # disable batching for tests\n         adapter.handle_message = AsyncMock()\n         adapter._extract_media = AsyncMock(return_value=([\"/tmp/test.png\"], [\"image/png\"]))\n ","@@ -539,6 +540,7 @@ async def test_on_message_preserves_quote_context(self):\n         from gateway.platforms.wecom import WeComAdapter\n \n         adapter = WeComAdapter(PlatformConfig(enabled=True))\n+        adapter._text_batch_delay_seconds = 0  # disable batching for tests\n         adapter.handle_message = AsyncMock()\n         adapter._extract_media = AsyncMock(return_value=([], []))\n \n"]}]},{"kind":"pr","number":6982,"title":"[codex] Batch split inbound messages across gateway adapters","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}