{"repo":"NousResearch/hermes-agent","public_id":"8341","display_label":"Feishu message loss and code block rendering fixes","summary":"A cluster of bugs and fixes related to Feishu (Lark) channel message content being dropped or lost, alongside fixes for how fenced code blocks are split and rendered in Feishu post payloads. Also includes streaming fixes to prevent final responses from being suppressed when commentary messages are sent.","subsystem":null,"counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10540},"resolution":{"state":"partially_resolved","by":"pr-10540"},"freshness":{"state":"stale","last_verified_at":"2026-04-19T10:30:37+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":8341,"pr_number":12512,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":10454,"pr_number":10540,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":8341,"title":"[Bug]: Lost last content of Feishu Channel","url":null,"state":"closed"},{"kind":"issue","number":10454,"title":"[Bug]: 飞书对话中最后一条消息偶尔会被吞掉","url":null,"state":"closed"},{"kind":"pr","number":8361,"title":"fix(feishu): avoid post rendering for fenced code block replies","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":8638,"title":"fix(feishu): split fenced code blocks in post payload","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12512","number":12512}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10519,"title":"fix(streaming): don't suppress final response when commentary message is sent","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":10530,"title":"fix(streaming): don't suppress final response when commentary message is sent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10540","number":10540}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10540,"title":"fix(streaming): don't suppress final response when commentary message is sent","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/stream_consumer.py"],"fix_hunks":[{"file":"gateway/stream_consumer.py","hunks":["@@ -609,12 +609,15 @@ async def _send_commentary(self, text: str) -> bool:\n                 content=text,\n                 metadata=self.metadata,\n             )\n-            if result.success:\n-                self._already_sent = True\n-                return True\n+            # Note: do NOT set _already_sent = True here.\n+            # Commentary messages are interim status updates (e.g. \"Using browser\n+            # tool...\"), not the final response. Setting already_sent would cause\n+            # the final response to be incorrectly suppressed when there are\n+            # multiple tool calls. See: https://github.com/NousResearch/hermes-agent/issues/10454\n+            return result.success\n         except Exception as e:\n             logger.error(\"Commentary send error: %s\", e)\n-        return False\n+            return False\n \n     async def _send_or_edit(self, text: str) -> bool:\n         \"\"\"Send or edit the streaming message.\n"]}]},{"kind":"pr","number":12512,"title":"fix(feishu): split fenced code blocks into dedicated post rows","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/feishu.py","tests/gateway/test_feishu.py"],"fix_hunks":[{"file":"gateway/platforms/feishu.py","hunks":["@@ -119,6 +119,8 @@\n     re.MULTILINE,\n )\n _MARKDOWN_LINK_RE = re.compile(r\"\\[([^\\]]+)\\]\\(([^)]+)\\)\")\n+_MARKDOWN_FENCE_OPEN_RE = re.compile(r\"^```([^\\n`]*)\\s*$\")\n+_MARKDOWN_FENCE_CLOSE_RE = re.compile(r\"^```\\s*$\")\n _MENTION_RE = re.compile(r\"@_user_\\d+\")\n _MULTISPACE_RE = re.compile(r\"[ \\t]{2,}\")\n _POST_CONTENT_INVALID_RE = re.compile(r\"content format of the post type is incorrect\", re.IGNORECASE)","@@ -430,23 +432,66 @@ def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int:\n \n \n def _build_markdown_post_payload(content: str) -> str:\n+    rows = _build_markdown_post_rows(content)\n     return json.dumps(\n         {\n             \"zh_cn\": {\n-                \"content\": [\n-                    [\n-                        {\n-                            \"tag\": \"md\",\n-                            \"text\": content,\n-                        }\n-                    ]\n-                ],\n+                \"content\": rows,\n             }\n         },\n         ensure_ascii=False,\n     )\n \n \n+def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]:\n+    \"\"\"Build Feishu post rows while isolating fenced code blocks.\n+\n+    Feishu's `md` renderer can swallow trailing content when a fenced code block\n+    appears inside one large markdown element. Split the reply at real fence\n+    lines so prose before/after the code block remains visible while code stays\n+    in a dedicated row.\n+    \"\"\"\n+    if not content:\n+        return [[{\"tag\": \"md\", \"text\": \"\"}]]\n+    if \"```\" not in content:\n+        return [[{\"tag\": \"md\", \"text\": content}]]\n+\n+    rows: List[List[Dict[str, str]]] = []\n+    current: List[str] = []\n+    in_code_block = False\n+\n+    def _flush_current() -> None:\n+        nonlocal current\n+        if not current:\n+            return\n+        segment = \"\\n\".join(current)\n+        if segment.strip():\n+            rows.append([{\"tag\": \"md\", \"text\": segment}])\n+        current = []\n+\n+    for raw_line in content.splitlines():\n+        stripped_line = raw_line.strip()\n+        is_fence = bool(\n+            _MARKDOWN_FENCE_CLOSE_RE.match(stripped_line)\n+            if in_code_block\n+            else _MARKDOWN_FENCE_OPEN_RE.match(stripped_line)\n+        )\n+\n+        if is_fence:\n+            if not in_code_block:\n+                _flush_current()\n+            current.append(raw_line)\n+            in_code_block = not in_code_block\n+            if not in_code_block:\n+                _flush_current()\n+            continue\n+\n+        current.append(raw_line)\n+\n+    _flush_current()\n+    return rows or [[{\"tag\": \"md\", \"text\": content}]]\n+\n+\n def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:\n     resolved = _resolve_post_payload(payload)\n     if not resolved:"]},{"file":"tests/gateway/test_feishu.py","hunks":["@@ -2370,6 +2370,134 @@ async def _direct(func, *args, **kwargs):\n         elements = payload[\"zh_cn\"][\"content\"][0]\n         self.assertEqual(elements, [{\"tag\": \"md\", \"text\": \"可以用 **粗体** 和 *斜体*。\"}])\n \n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_send_splits_fenced_code_blocks_into_separate_post_rows(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        captured = {}\n+\n+        class _MessageAPI:\n+            def create(self, request):\n+                captured[\"request\"] = request\n+                return SimpleNamespace(\n+                    success=lambda: True,\n+                    data=SimpleNamespace(message_id=\"om_codeblock\"),\n+                )\n+\n+        adapter._client = SimpleNamespace(\n+            im=SimpleNamespace(\n+                v1=SimpleNamespace(\n+                    message=_MessageAPI(),\n+                )\n+            )\n+        )\n+\n+        async def _direct(func, *args, **kwargs):\n+            return func(*args, **kwargs)\n+\n+        content = (\n+            \"确认已入库 ✓\\n\"\n+            \"文件路径：`/root/.hermes/profiles/agent_cto/cron/jobs.json`\\n\"\n+            \"**解码后的内容：**\\n\"\n+            \"```json\\n\"\n+            '{\"cron\": \"list\"}\\n'\n+            \"```\\n\"\n+            \"后续说明仍应保留。\"\n+        )\n+\n+        with patch(\"gateway.platforms.feishu.asyncio.to_thread\", side_effect=_direct):\n+            result = asyncio.run(\n+                adapter.send(\n+                    chat_id=\"oc_chat\",\n+                    content=content,\n+                )\n+            )\n+\n+        self.assertTrue(result.success)\n+        self.assertEqual(captured[\"request\"].request_body.msg_type, \"post\")\n+        payload = json.loads(captured[\"request\"].request_body.content)\n+        rows = payload[\"zh_cn\"][\"content\"]\n+        self.assertEqual(\n+            rows,\n+            [\n+                [\n+                    {\n+                        \"tag\": \"md\",\n+                        \"text\": \"确认已入库 ✓\\n文件路径：`/root/.hermes/profiles/agent_cto/cron/jobs.json`\\n**解码后的内容：**\",\n+                    }\n+                ],\n+                [{\"tag\": \"md\", \"text\": \"```json\\n{\\\"cron\\\": \\\"list\\\"}\\n```\"}],\n+                [{\"tag\": \"md\", \"text\": \"后续说明仍应保留。\"}],\n+            ],\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_build_post_payload_keeps_fence_like_code_lines_inside_code_block(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        payload = json.loads(\n+            adapter._build_post_payload(\n+                \"before\\n```python\\n```oops\\n```\\nafter\"\n+            )\n+        )\n+\n+        self.assertEqual(\n+            payload[\"zh_cn\"][\"content\"],\n+            [\n+                [{\"tag\": \"md\", \"text\": \"before\"}],\n+                [{\"tag\": \"md\", \"text\": \"```python\\n```oops\\n```\"}],\n+                [{\"tag\": \"md\", \"text\": \"after\"}],\n+            ],\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_build_post_payload_preserves_trailing_spaces_in_code_block(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        payload = json.loads(\n+            adapter._build_post_payload(\n+                \"before\\n```python\\nline with two spaces  \\n```\\nafter\"\n+            )\n+        )\n+\n+        self.assertEqual(\n+            payload[\"zh_cn\"][\"content\"],\n+            [\n+                [{\"tag\": \"md\", \"text\": \"before\"}],\n+                [{\"tag\": \"md\", \"text\": \"```python\\nline with two spaces  \\n```\"}],\n+                [{\"tag\": \"md\", \"text\": \"after\"}],\n+            ],\n+        )\n+\n+    @patch.dict(os.environ, {}, clear=True)\n+    def test_build_post_payload_splits_multiple_fenced_code_blocks(self):\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = FeishuAdapter(PlatformConfig())\n+        payload = json.loads(\n+            adapter._build_post_payload(\n+                \"before\\n```python\\nprint(1)\\n```\\nmiddle\\n```json\\n{}\\n```\\nafter\"\n+            )\n+        )\n+\n+        self.assertEqual(\n+            payload[\"zh_cn\"][\"content\"],\n+            [\n+                [{\"tag\": \"md\", \"text\": \"before\"}],\n+                [{\"tag\": \"md\", \"text\": \"```python\\nprint(1)\\n```\"}],\n+                [{\"tag\": \"md\", \"text\": \"middle\"}],\n+                [{\"tag\": \"md\", \"text\": \"```json\\n{}\\n```\"}],\n+                [{\"tag\": \"md\", \"text\": \"after\"}],\n+            ],\n+        )\n+\n     @patch.dict(os.environ, {}, clear=True)\n     def test_send_falls_back_to_text_when_post_payload_is_rejected(self):\n         from gateway.config import PlatformConfig\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}