{"repo":"NousResearch/hermes-agent","public_id":"50126","display_label":"Long message truncation vs. chunking across delivery adapters","summary":"Multiple bugs and fixes address how cron and agent output exceeding 4,000 characters is handled across email, SMS, and Discord delivery adapters, centering on whether a splits_long_messages flag is declared to enable chunked delivery instead of hard truncation.","subsystem":"gateway","counts":{"members":15,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50778},"resolution":{"state":"resolved","by":"pr-50778,pr-130866"},"freshness":{"state":"verified","last_verified_at":"2026-10-02T00:18:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13066666666666665},"fix_edges":[{"issue_number":19689,"pr_number":130866,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":50126,"pr_number":50778,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61990,"pr_number":130866,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":62751,"pr_number":130866,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":107443,"pr_number":130866,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":50126,"title":"Cron Discord delivery is truncated after routing through DeliveryRouter instead of being split into multiple messages","url":null,"state":"closed"},{"kind":"issue","number":61990,"title":"[Bug]: No way to override email delivery truncation","url":null,"state":"closed"},{"kind":"issue","number":62751,"title":"Bug: SmsAdapter doesn't declare splits_long_messages, so cron/agent output over 4000 chars gets hard-truncated instead of chunked","url":null,"state":"closed"},{"kind":"pr","number":50145,"title":"fix(delivery): configurable + adapter-aware cron output truncation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"86e4521cb","number":0},{"kind":"pr","value":"#50778","number":50778}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50778,"title":"fix(delivery): adapter-aware cron output chunking (salvage #50145)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/delivery.py","gateway/platforms/base.py","gateway/platforms/bluebubbles.py","gateway/platforms/weixin.py","gateway/platforms/whatsapp_cloud.py","gateway/platforms/yuanbao.py","plugins/platforms/discord/adapter.py","plugins/platforms/feishu/adapter.py","plugins/platforms/matrix/adapter.py","plugins/platforms/mattermost/adapter.py","plugins/platforms/slack/adapter.py","plugins/platforms/teams/adapter.py","plugins/platforms/telegram/adapter.py","plugins/platforms/whatsapp/adapter.py","scripts/release.py","tests/gateway/test_delivery.py"],"fix_hunks":[{"file":"gateway/delivery.py","hunks":["@@ -20,8 +20,13 @@\n \n logger = logging.getLogger(__name__)\n \n+# Cap before gateway-level truncation of cron output for non-chunking platform\n+# delivery.  Telegram's hard API limit is 4096; the headroom covers the \"full\n+# output saved to …\" footer appended on truncation.  Adapters that split long\n+# messages natively (BasePlatformAdapter.splits_long_messages) bypass this\n+# entirely — the adapter chunks in its own send() and the full output is\n+# preserved.\n MAX_PLATFORM_OUTPUT = 4000\n-TRUNCATED_VISIBLE = 3800\n \n # Matches strings that are *only* a \"silence\" narration with optional markdown\n # wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,","@@ -316,15 +321,55 @@ async def _deliver_to_platform(\n         if not target.chat_id:\n             raise ValueError(f\"No chat ID for {target.platform.value} delivery\")\n         \n-        # Guard: truncate oversized cron output to stay within platform limits\n+        # Guard: handle oversized cron output.\n+        #\n+        # Two independent decisions:\n+        #   1. AUDIT SAVE — when content exceeds MAX_PLATFORM_OUTPUT, the full\n+        #      output is always written to disk as a recoverable audit trail.\n+        #      This fires regardless of adapter capability (best-effort).\n+        #   2. TRUNCATION — for non-chunking adapters, content above the cap is\n+        #      truncated with a footer pointing to the saved file.  Chunking-\n+        #      capable adapters (splits_long_messages=True) receive the full\n+        #      payload and split natively in their send().\n+        job_id = (metadata or {}).get(\"job_id\", \"unknown\")\n+        saved_path: Optional[Path] = None\n+\n         if len(content) > MAX_PLATFORM_OUTPUT:\n-            job_id = (metadata or {}).get(\"job_id\", \"unknown\")\n-            saved_path = self._save_full_output(content, job_id)\n-            logger.info(\"Cron output truncated (%d chars) — full output: %s\", len(content), saved_path)\n-            content = (\n-                content[:TRUNCATED_VISIBLE]\n-                + f\"\\n\\n... [truncated, full output saved to {saved_path}]\"\n-            )\n+            # Step 1 — audit save (best-effort).  The save is a side-effect\n+            # audit trail, not essential to delivery.  If it fails (full disk,\n+            # permissions), delivery proceeds — the content reaches the adapter\n+            # regardless.\n+            try:\n+                saved_path = self._save_full_output(content, job_id)\n+            except OSError as exc:\n+                logger.warning(\n+                    \"Audit save failed for cron output (%d chars, job=%s): %s — \"\n+                    \"delivery proceeds without audit copy\",\n+                    len(content), job_id, exc,\n+                )\n+\n+            # Step 2 — truncation (only for non-chunking adapters).\n+            if getattr(adapter, \"splits_long_messages\", False):\n+                # Adapter chunks natively — deliver full payload.\n+                if saved_path:\n+                    logger.info(\n+                        \"Cron output preserved for chunking adapter (%d chars) — \"\n+                        \"full output saved to %s\",\n+                        len(content), saved_path,\n+                    )\n+            else:\n+                # Non-chunking adapter — truncate with footer.  The footer\n+                # needs a valid path, so if the best-effort save above failed,\n+                # retry it here (a failure now is a real delivery problem).\n+                if saved_path is None:\n+                    saved_path = self._save_full_output(content, job_id)\n+                footer = f\"\\n\\n... [truncated, full output saved to {saved_path}]\"\n+                visible = max(0, MAX_PLATFORM_OUTPUT - len(footer))\n+                logger.info(\n+                    \"Cron output truncated (%d chars) — full output: %s\",\n+                    len(content), saved_path,\n+                )\n+                content = content[:visible] + footer\n         \n         # Substrate-level anti-loop guard: drop hallucinated \"silence narration\"\n         # (*(silent)*, 🔇, a bare \".\", etc.) before it ever reaches the adapter."]},{"file":"gateway/platforms/base.py","hunks":["@@ -2077,6 +2077,14 @@ class BasePlatformAdapter(ABC):\n     # set this to False to stay correct-by-default.\n     supports_async_delivery: bool = True\n \n+    # Whether this adapter's ``send()`` splits long content into multiple\n+    # messages via ``truncate_message()``.  When True, the delivery router\n+    # (gateway/delivery.py) skips gateway-level truncation and lets the\n+    # adapter chunk natively — preserving full output on platforms that\n+    # support multi-message delivery (Discord, Telegram, …).  Default False\n+    # (conservative); adapters verified to chunk in ``send()`` set True.\n+    splits_long_messages: bool = False\n+\n     # The command prefix users can always TYPE on this platform to reach\n     # Hermes commands.  Default \"/\" (most platforms deliver \"/approve\" etc.\n     # as plain message text).  Platforms where typing a leading \"/\" is"]},{"file":"gateway/platforms/bluebubbles.py","hunks":["@@ -113,6 +113,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):\n     platform = Platform.BLUEBUBBLES\n     SUPPORTS_MESSAGE_EDITING = False\n     MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH\n+    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)\n \n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.BLUEBUBBLES)"]},{"file":"gateway/platforms/weixin.py","hunks":["@@ -1139,6 +1139,7 @@ class WeixinAdapter(BasePlatformAdapter):\n     \"\"\"Native Hermes adapter for Weixin personal accounts.\"\"\"\n \n     supports_code_blocks = True  # Weixin renders fenced code blocks\n+    splits_long_messages = True  # send() chunks via _split_text()\n \n     MAX_MESSAGE_LENGTH = 2000\n "]},{"file":"gateway/platforms/whatsapp_cloud.py","hunks":["@@ -187,6 +187,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):\n     syntax). The Baileys adapter does the same.\n     \"\"\"\n \n+    splits_long_messages = True  # send() chunks via truncate_message()\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.WHATSAPP_CLOUD)\n         extra = config.extra or {}"]},{"file":"gateway/platforms/yuanbao.py","hunks":["@@ -4983,6 +4983,7 @@ class YuanbaoAdapter(BasePlatformAdapter):\n \n     PLATFORM = Platform.YUANBAO\n     MAX_TEXT_CHUNK: int = 4000  # Yuanbao single message character limit\n+    splits_long_messages = True  # send() auto-chunks via truncate_message(MAX_TEXT_CHUNK)\n     MEDIA_MAX_SIZE_MB: int = 50  # Max media file size in MB for upload validation\n     REPLY_REF_MAX_ENTRIES: ClassVar[int] = 500  # Max capacity of reference dedup dict\n "]},{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -733,6 +733,7 @@ class DiscordAdapter(BasePlatformAdapter):\n     MAX_MESSAGE_LENGTH = 2000\n     _SPLIT_THRESHOLD = 1900  # near the 2000-char split point\n     supports_code_blocks = True  # Discord markdown renders fenced code blocks natively\n+    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)\n \n     # Auto-disconnect from voice channel after this many seconds of inactivity\n     VOICE_TIMEOUT = 300"]},{"file":"plugins/platforms/feishu/adapter.py","hunks":["@@ -1410,6 +1410,7 @@ class FeishuAdapter(BasePlatformAdapter):\n     \"\"\"Feishu/Lark bot adapter.\"\"\"\n \n     supports_code_blocks = True  # Feishu renders fenced code blocks\n+    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)\n \n     MAX_MESSAGE_LENGTH = 8000\n     # Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in."]},{"file":"plugins/platforms/matrix/adapter.py","hunks":["@@ -775,6 +775,7 @@ class MatrixAdapter(BasePlatformAdapter):\n     \"\"\"Gateway adapter for Matrix (any homeserver).\"\"\"\n \n     supports_code_blocks = True  # Matrix renders fenced code blocks (HTML/markdown)\n+    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)\n \n     # Matrix clients commonly reserve typed \"/\" for client-local commands;\n     # the adapter accepts \"!command\" as the alias that always reaches Hermes"]},{"file":"plugins/platforms/mattermost/adapter.py","hunks":["@@ -71,6 +71,8 @@ def check_mattermost_requirements() -> bool:\n class MattermostAdapter(BasePlatformAdapter):\n     \"\"\"Gateway adapter for Mattermost (self-hosted or cloud).\"\"\"\n \n+    splits_long_messages = True  # send() chunks via truncate_message(MAX_POST_LENGTH)\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.MATTERMOST)\n "]},{"file":"plugins/platforms/slack/adapter.py","hunks":["@@ -321,6 +321,7 @@ class SlackAdapter(BasePlatformAdapter):\n \n     MAX_MESSAGE_LENGTH = 39000  # Slack API allows 40,000 chars; leave margin\n     supports_code_blocks = True  # Slack mrkdwn renders fenced code blocks\n+    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)\n     # Slack blocks typed native slash commands inside threads (\"/approve is\n     # not supported in threads. Sorry!\").  The adapter rewrites a leading\n     # \"!\" to \"/\" for known commands (see _handle_slack_message), so \"!\" is"]},{"file":"plugins/platforms/teams/adapter.py","hunks":["@@ -691,6 +691,7 @@ class TeamsAdapter(BasePlatformAdapter):\n     \"\"\"Microsoft Teams adapter using the microsoft-teams-apps SDK.\"\"\"\n \n     MAX_MESSAGE_LENGTH = 28000  # Teams text message limit (~28 KB)\n+    splits_long_messages = True  # send() chunks via truncate_message()\n \n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform(\"teams\"))"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -417,6 +417,7 @@ class TelegramAdapter(BasePlatformAdapter):\n     # Telegram message limits\n     MAX_MESSAGE_LENGTH = 4096\n     supports_code_blocks = True  # Telegram MarkdownV2 renders fenced code blocks\n+    splits_long_messages = True  # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)\n     # Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768\n     # UTF-8 characters. Content above this is sent via the legacy chunking path.\n     RICH_MESSAGE_MAX_CHARS = 32768"]},{"file":"plugins/platforms/whatsapp/adapter.py","hunks":["@@ -337,6 +337,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):\n \n     # Default bridge location resolved via shared helper\n     _DEFAULT_BRIDGE_DIR = None  # resolved in __init__\n+    splits_long_messages = True  # send() chunks via truncate_message()\n \n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.WHATSAPP)"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"ScotterMonk\",  # PR #50145 salvage (cron output truncation: adapter-aware chunking, #50126)\n     \"[redacted:email]\": \"TutkuEroglu\",  # PR #50481 salvage (AGENTS.md stale token-lock adapter path)\n     \"[redacted:email]\": \"pmos69\",  # PR #29474 salvage (native Antigravity OAuth provider; Gemini CLI sunset #29294/#49701)\n     \"[redacted:email]\": \"orbisai0security\",  # PR #9560 salvage (session.py path-traversal guard, V-009)"]},{"file":"tests/gateway/test_delivery.py","hunks":["@@ -281,3 +281,143 @@ async def test_platform_send_failure_raises_for_delivery_result(tmp_path, monkey\n \n     with pytest.raises(RuntimeError, match=\"route failed\"):\n         await router._deliver_to_platform(target, \"hello\", metadata={\"telegram_reply_to_message_id\": \"9001\"})\n+\n+\n+# ---------------------------------------------------------------------------\n+# Cron output truncation / adapter-aware chunking (issue #50126)\n+# ---------------------------------------------------------------------------\n+\n+class ChunkingAdapter:\n+    \"\"\"Adapter that declares splits_long_messages=True (like Discord/Telegram).\"\"\"\n+    splits_long_messages = True\n+\n+    def __init__(self):\n+        self.calls = []\n+\n+    async def send(self, chat_id, content, metadata=None):\n+        self.calls.append({\"chat_id\": chat_id, \"content\": content, \"metadata\": metadata})\n+        return {\"success\": True}\n+\n+\n+class NonChunkingAdapter:\n+    \"\"\"Adapter without splits_long_messages (default False — legacy behavior).\"\"\"\n+\n+    def __init__(self):\n+        self.calls = []\n+\n+    async def send(self, chat_id, content, metadata=None):\n+        self.calls.append({\"chat_id\": chat_id, \"content\": content, \"metadata\": metadata})\n+        return {\"success\": True}\n+\n+\n[redacted:email]\n+async def test_long_output_truncated_for_non_chunking_adapter(tmp_path, monkeypatch):\n+    \"\"\"Non-chunking adapters receive truncated content with a footer + file save.\"\"\"\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    adapter = NonChunkingAdapter()\n+    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:123\")\n+\n+    long_content = \"x\" * 5000\n+    await router._deliver_to_platform(target, long_content, metadata={\"job_id\": \"job1\"})\n+\n+    delivered = adapter.calls[0][\"content\"]\n+    assert len(delivered) < 5000  # was truncated\n+    assert \"truncated\" in delivered.lower()\n+    assert \"full output saved to\" in delivered\n+    # Full output was saved to disk\n+    saved_files = list(tmp_path.glob(\"cron/output/job1_*.txt\"))\n+    assert len(saved_files) == 1\n+    assert saved_files[0].read_text() == long_content\n+\n+\n[redacted:email]\n+async def test_long_output_preserved_for_chunking_adapter(tmp_path, monkeypatch):\n+    \"\"\"Chunking adapters (splits_long_messages=True) receive the FULL content.\"\"\"\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    adapter = ChunkingAdapter()\n+    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:123\")\n+\n+    long_content = \"x\" * 5000\n+    await router._deliver_to_platform(target, long_content, metadata={\"job_id\": \"job2\"})\n+\n+    delivered = adapter.calls[0][\"content\"]\n+    assert delivered == long_content  # NOT truncated — adapter handles chunking\n+    assert \"truncated\" not in delivered.lower()\n+    # Full output still saved to disk as audit trail\n+    saved_files = list(tmp_path.glob(\"cron/output/job2_*.txt\"))\n+    assert len(saved_files) == 1\n+    assert saved_files[0].read_text() == long_content\n+\n+\n[redacted:email]\n+async def test_short_output_never_truncated(tmp_path, monkeypatch):\n+    \"\"\"Output under the limit passes through untouched for any adapter.\"\"\"\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+    adapter = NonChunkingAdapter()\n+    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:123\")\n+\n+    short_content = \"x\" * 100\n+    await router._deliver_to_platform(target, short_content, metadata={\"job_id\": \"job3\"})\n+\n+    assert adapter.calls[0][\"content\"] == short_content\n+    # Nothing saved to disk\n+    assert not list(tmp_path.glob(\"cron/output/*.txt\"))\n+\n+\n[redacted:email]\n+async def test_audit_save_failure_does_not_break_chunking_delivery(tmp_path, monkeypatch):\n+    \"\"\"If the audit save fails (disk full, permissions), chunking adapters\n+    still receive the full content — the save is best-effort.\"\"\"\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+\n+    adapter = ChunkingAdapter()\n+    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:123\")\n+\n+    long_content = \"x\" * 5000\n+\n+    call_count = {\"n\": 0}\n+\n+    def failing_save(content, job_id):\n+        call_count[\"n\"] += 1\n+        raise OSError(\"No space left on device\")\n+\n+    monkeypatch.setattr(router, \"_save_full_output\", failing_save)\n+\n+    # Should NOT raise — audit failure is caught for chunking adapters\n+    await router._deliver_to_platform(target, long_content, metadata={\"job_id\": \"job6\"})\n+\n+    # Adapter still got the full content\n+    assert adapter.calls[0][\"content\"] == long_content\n+    # Save was attempted (best-effort, swallowed)\n+    assert call_count[\"n\"] == 1\n+\n+\n[redacted:email]\n+async def test_save_failure_during_truncation_raises_for_non_chunking_adapter(tmp_path, monkeypatch):\n+    \"\"\"For a non-chunking adapter, the truncation footer needs a valid saved\n+    path. If the save fails there, that is a real delivery problem and the\n+    error propagates (not swallowed like the chunking best-effort save).\"\"\"\n+    monkeypatch.setattr(\"gateway.delivery.get_hermes_home\", lambda: tmp_path)\n+\n+    adapter = NonChunkingAdapter()\n+    router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})\n+    target = DeliveryTarget.parse(\"discord:123\")\n+\n+    long_content = \"x\" * 5000\n+\n+    def failing_save(content, job_id):\n+        raise OSError(\"No space left on device\")\n+\n+    monkeypatch.setattr(router, \"_save_full_output\", failing_save)\n+\n+    # Non-chunking adapter must truncate → needs a valid saved path → the\n+    # Step 1 best-effort catch swallows the first attempt, but the Step 2\n+    # retry (footer needs the path) re-raises.\n+    with pytest.raises(OSError, match=\"No space left on device\"):\n+        await router._deliver_to_platform(target, long_content, metadata={\"job_id\": \"job7\"})\n+\n+\n"]}]},{"kind":"pr","number":62000,"title":"fix(email): enable splits_long_messages to avoid 4000-char truncation cap","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"be5e9f72c6681af9dfb75bf480f08844f1499949","number":0},{"kind":"pr","value":"#130866","number":130866}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":62439,"title":"fix(email): declare splits_long_messages to prevent cron output truncation","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":62653,"title":"fix(email): set splits_long_messages on EmailAdapter to prevent cron output truncation","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":62752,"title":"fix(gateway): declare splits_long_messages on SmsAdapter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"be5e9f72c6681af9dfb75bf480f08844f1499949","number":0},{"kind":"pr","value":"#130866","number":130866}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":62843,"title":"fix(sms): add splits_long_messages flag to enable chunked delivery for cron/agent output >4000 chars (#62751)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62752","number":62752},{"kind":"pr","value":"#62751","number":62751}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":67728,"title":"fix(gateway): declare splits_long_messages on SmsAdapter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62752","number":62752}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":67734,"title":"fix(gateway): declare splits_long_messages on SmsAdapter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"be5e9f72c6681af9dfb75bf480f08844f1499949","number":0},{"kind":"pr","value":"#130866","number":130866}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":79842,"title":"fix(email): split long cron output instead of truncating at 4,000 chars","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":108476,"title":"fix(email): declare splits_long_messages so long cron output is delivered whole","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62000","number":62000},{"kind":"pr","value":"#61990","number":61990},{"kind":"pr","value":"#62000","number":62000}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":130866,"title":"fix(gateway): SMS, email and WeCom deliver long replies and cron output in full instead of failing or truncating","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler_delivery.py","gateway/delivery.py","gateway/platforms/helpers.py","plugins/platforms/email/adapter.py","plugins/platforms/sms/adapter.py","plugins/platforms/wecom/adapter.py","plugins/platforms/wecom/callback_adapter.py","tests/cron/test_scheduler.py","tests/gateway/test_email.py","tests/gateway/test_sms.py","tests/gateway/test_wecom.py","tests/gateway/test_wecom_callback.py","website/docs/user-guide/messaging/wecom-callback.md","website/docs/user-guide/messaging/wecom.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/wecom-callback.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/wecom.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}