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
- 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
Open PRs0
Fixes on record
-
#10540 fix(streaming): don't suppress final response when commentary message is sent
files:
gateway/stream_consumer.pyShow 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
files:
gateway/platforms/feishu.py,tests/gateway/test_feishu.pyShow 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
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.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab