Feishu message loss and code block rendering fixes

NousResearch/hermes-agent · cluster 8341

Data as of

Nothing open on GitHub. Fix merged in #10540. Check it covers your case.

The short answer

0open issues
0open PRs
8items grouped
Known?
Yes. 8 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #10540. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #10540. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #10540 fix(streaming): don't suppress final response when commentary message is sent

    merged to main

    files: gateway/stream_consumer.py

    Show the diff
    • gateway/stream_consumer.py
      @@ -609,12 +609,15 @@ async def _send_commentary(self, text: str) -> bool:
                       content=text,
                       metadata=self.metadata,
                   )
      -            if result.success:
      -                self._already_sent = True
      -                return True
      +            # Note: do NOT set _already_sent = True here.
      +            # Commentary messages are interim status updates (e.g. "Using browser
      +            # tool..."), not the final response. Setting already_sent would cause
      +            # the final response to be incorrectly suppressed when there are
      +            # multiple tool calls. See: https://github.com/NousResearch/hermes-agent/issues/10454
      +            return result.success
               except Exception as e:
                   logger.error("Commentary send error: %s", e)
      -        return False
      +            return False
       
           async def _send_or_edit(self, text: str) -> bool:
               """Send or edit the streaming message.
  • #12512 fix(feishu): split fenced code blocks into dedicated post rows

    merged to main

    files: gateway/platforms/feishu.py, tests/gateway/test_feishu.py

    Show the diff
    • gateway/platforms/feishu.py
      @@ -119,6 +119,8 @@
           re.MULTILINE,
       )
       _MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
      +_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
      +_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
       _MENTION_RE = re.compile(r"@_user_\d+")
       _MULTISPACE_RE = re.compile(r"[ \t]{2,}")
       _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:
       
       
       def _build_markdown_post_payload(content: str) -> str:
      +    rows = _build_markdown_post_rows(content)
           return json.dumps(
               {
                   "zh_cn": {
      -                "content": [
      -                    [
      -                        {
      -                            "tag": "md",
      -                            "text": content,
      -                        }
      -                    ]
      -                ],
      +                "content": rows,
                   }
               },
               ensure_ascii=False,
           )
       
       
      +def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]:
      +    """Build Feishu post rows while isolating fenced code blocks.
      +
      +    Feishu's `md` renderer can swallow trailing content when a fenced code block
      +    appears inside one large markdown element. Split the reply at real fence
      +    lines so prose before/after the code block remains visible while code stays
      +    in a dedicated row.
      +    """
      +    if not content:
      +        return [[{"tag": "md", "text": ""}]]
      +    if "```" not in content:
      +        return [[{"tag": "md", "text": content}]]
      +
      +    rows: List[List[Dict[str, str]]] = []
      +    current: List[str] = []
      +    in_code_block = False
      +
      +    def _flush_current() -> None:
      +        nonlocal current
      +        if not current:
      +            return
      +        segment = "\n".join(current)
      +        if segment.strip():
      +            rows.append([{"tag": "md", "text": segment}])
      +        current = []
      +
      +    for raw_line in content.splitlines():
      +        stripped_line = raw_line.strip()
      +        is_fence = bool(
      +            _MARKDOWN_FENCE_CLOSE_RE.match(stripped_line)
      +            if in_code_block
      +            else _MARKDOWN_FENCE_OPEN_RE.match(stripped_line)
      +        )
      +
      +        if is_fence:
      +            if not in_code_block:
      +                _flush_current()
      +            current.append(raw_line)
      +            in_code_block = not in_code_block
      +            if not in_code_block:
      +                _flush_current()
      +            continue
      +
      +        current.append(raw_line)
      +
      +    _flush_current()
      +    return rows or [[{"tag": "md", "text": content}]]
      +
      +
       def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
           resolved = _resolve_post_payload(payload)
           if not resolved:
    • tests/gateway/test_feishu.py
      @@ -2370,6 +2370,134 @@ async def _direct(func, *args, **kwargs):
               elements = payload["zh_cn"]["content"][0]
               self.assertEqual(elements, [{"tag": "md", "text": "可以用 **粗体** 和 *斜体*。"}])
       
      +    @patch.dict(os.environ, {}, clear=True)
      +    def test_send_splits_fenced_code_blocks_into_separate_post_rows(self):
      +        from gateway.config import PlatformConfig
      +        from gateway.platforms.feishu import FeishuAdapter
      +
      +        adapter = FeishuAdapter(PlatformConfig())
      +        captured = {}
      +
      +        class _MessageAPI:
      +            def create(self, request):
      +                captured["request"] = request
      +                return SimpleNamespace(
      +                    success=lambda: True,
      +                    data=SimpleNamespace(message_id="om_codeblock"),
      +                )
      +
      +        adapter._client = SimpleNamespace(
      +            im=SimpleNamespace(
      +                v1=SimpleNamespace(
      +                    message=_MessageAPI(),
      +                )
      +            )
      +        )
      +
      +        async def _direct(func, *args, **kwargs):
      +            return func(*args, **kwargs)
      +
      +        content = (
      +            "确认已入库 ✓\n"
      +            "文件路径:`/root/.hermes/profiles/agent_cto/cron/jobs.json`\n"
      +            "**解码后的内容:**\n"
      +            "```json\n"
      +            '{"cron": "list"}\n'
      +            "```\n"
      +            "后续说明仍应保留。"
      +        )
      +
      +        with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
      +            result = asyncio.run(
      +                adapter.send(
      +                    chat_id="oc_chat",
      +                    content=content,
      +                )
      +            )
      +
      +        self.assertTrue(result.success)
      +        self.assertEqual(captured["request"].request_body.msg_type, "post")
      +        payload = json.loads(captured["request"].request_body.content)
      +        rows = payload["zh_cn"]["content"]
      +        self.assertEqual(
      +            rows,
      +            [
      +                [
      +                    {
      +                        "tag": "md",
      +                        "text": "确认已入库 ✓\n文件路径:`/root/.hermes/profiles/agent_cto/cron/jobs.json`\n**解码后的内容:**",
      +                    }
      +                ],
      +                [{"tag": "md", "text": "```json\n{\"cron\": \"list\"}\n```"}],
      +                [{"tag": "md", "text": "后续说明仍应保留。"}],
      +            ],
      +        )
      +
      +    @patch.dict(os.environ, {}, clear=True)
      +    def test_build_post_payload_keeps_fence_like_code_lines_inside_code_block(self):
      +        from gateway.config import PlatformConfig
      +        from gateway.platforms.feishu import FeishuAdapter
      +
      +        adapter = FeishuAdapter(PlatformConfig())
      +        payload = json.loads(
      +            adapter._build_post_payload(
      +                "before\n```python\n```oops\n```\nafter"
      +            )
      +        )
      +
      +        self.assertEqual(
      +            payload["zh_cn"]["content"],
      +            [
      +                [{"tag": "md", "text": "before"}],
      +                [{"tag": "md", "text": "```python\n```oops\n```"}],
      +                [{"tag": "md", "text": "after"}],
      +            ],
      +        )
      +
      +    @patch.dict(os.environ, {}, clear=True)
      +    def test_build_post_payload_preserves_trailing_spaces_in_code_block(self):
      +        from gateway.config import PlatformConfig
      +        from gateway.platforms.feishu import FeishuAdapter
      +
      +        adapter = FeishuAdapter(PlatformConfig())
      +        payload = json.loads(
      +            adapter._build_post_payload(
      +                "before\n```python\nline with two spaces  \n```\nafter"
      +            )
      +        )
      +
      +        self.assertEqual(
      +            payload["zh_cn"]["content"],
      +            [
      +                [{"tag": "md", "text": "before"}],
      +                [{"tag": "md", "text": "```python\nline with two spaces  \n```"}],
      +                [{"tag": "md", "text": "after"}],
      +            ],
      +        )
      +
      +    @patch.dict(os.environ, {}, clear=True)
      +    def test_build_post_payload_splits_multiple_fenced_code_blocks(self):
      +        from gateway.config import PlatformConfig
      +        from gateway.platforms.feishu import FeishuAdapter
      +
      +        adapter = FeishuAdapter(PlatformConfig())
      +        payload = json.loads(
      +            adapter._build_post_payload(
      +                "before\n```python\nprint(1)\n```\nmiddle\n```json\n{}\n```\nafter"
      +            )
      +        )
      +
      +        self.assertEqual(
      +            payload["zh_cn"]["content"],
      +            [
      +                [{"tag": "md", "text": "before"}],
      +                [{"tag": "md", "text": "```python\nprint(1)\n```"}],
      +                [{"tag": "md", "text": "middle"}],
      +                [{"tag": "md", "text": "```json\n{}\n```"}],
      +                [{"tag": "md", "text": "after"}],
      +            ],
      +        )
      +
           @patch.dict(os.environ, {}, clear=True)
           def test_send_falls_back_to_text_when_post_payload_is_rejected(self):
               from gateway.config import PlatformConfig

4 PRs closed without merging

From the close comments: merged via another change 2 · closed without comment 1 · other 1

  • #8361 fix(feishu): avoid post rendering for fenced code block replies · other
  • #8638 fix(feishu): split fenced code blocks in post payload · merged via another change → #12512
  • #10519 fix(streaming): don't suppress final response when commentary message is sent · closed without comment
  • #10530 fix(streaming): don't suppress final response when commentary message is sent · merged via another change → #10540
Closed issues · 2
  • Closed#8341[Bug]: Lost last content of Feishu Channel
  • Closed#10454[Bug]: 飞书对话中最后一条消息偶尔会被吞掉
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.23: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means 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

At a glance

Merged PRs
#10540, #12512
Closed issues
2
Attempts
4 PRs closed without merging
Fix link checked
19 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.