# NousResearch/hermes-agent · Feishu markdown table rendering approaches and fixes

## Open now

- 9 issues, 9 PRs open on GitHub.
- #6003: \[Feature\]: \[Feishu\] Support Card 2.0 for richer message rendering (e.g. tables)
- #19226: \[Feature\]: use feishu card message to render the repsponse
- #21326: Feature Request: Support Card JSON 2.0 Tables in Feishu Adapter
- #21873: \[Feature\]: Native Feishu Interactive Card Support
- #24319: Feishu replies render raw Markdown while normal messages render correctly
- #37777: \[Feature\] Support Feishu interactive card (msg_type=interactive) in send_message
- #46187: feat(feishu): support interactive card messages in send_message for table rendering
- #54950: Feishu adapter: add renderMode config option for automatic card message wrapping
- #54951: Feishu/Lark adapter: add renderMode config option for automatic card messages
- PR #6015: feat(feishu): send markdown responses as Card 2.0 instead of post messages
- PR #10801: fix(feishu): WebSocket watchdog + approval card PATCH + markdown table rendering
- PR #19231: feat(feishu): use card to show complex message
- PR #22272: fix(feishu): render markdown tables as native card tables
- PR #32488: feat(feishu): Schema 2.0 markdown card for GFM tables, code blocks, and inline formatting
- PR #37830: feat(gateway): support Feishu interactive card (msg_type=interactive) in send_message
- PR #40445: feat(feishu): render markdown tables as native Card JSON 2.0 table components
- PR #48807: feat(feishu): render markdown tables as CardKit v2 native table components
- PR #51739: fix(plugins/feishu): render markdown tables as CardKit v2 interactive cards

This group centers on a single core problem: Feishu/Lark does not natively render markdown tables, causing raw source to appear in messages. The members represent the bug reports and multiple competing implementation attempts (post type, CardKit 2.0, interactive cards, native table components) to address this, along with related Feishu formatting and platform support work.

## TL;DR

- 92 member(s): 9 open issue(s), 32 closed issue(s), 9 open PR(s), 4 merged PR(s), 38 PR(s) closed without merging.
- A fix was merged to main in PR #20275. Verify it addresses your case.
- **Recommended:** read PR #20275 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-68122,pr-109140`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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.03`: the share of member 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.

## Open related issues

- #6003: \[Feature\]: \[Feishu\] Support Card 2.0 for richer message rendering (e.g. tables)
- #19226: \[Feature\]: use feishu card message to render the repsponse
- #21326: Feature Request: Support Card JSON 2.0 Tables in Feishu Adapter
- #21873: \[Feature\]: Native Feishu Interactive Card Support
- #24319: Feishu replies render raw Markdown while normal messages render correctly
- #37777: \[Feature\] Support Feishu interactive card (msg_type=interactive) in send_message
- #46187: feat(feishu): support interactive card messages in send_message for table rendering
- #54950: Feishu adapter: add renderMode config option for automatic card message wrapping
- #54951: Feishu/Lark adapter: add renderMode config option for automatic card messages

## Closed / duplicate issues

- #7022: \[Feishu Bug\] Markdown source code displayed instead of rendered card format
- #7310: ## Problem
When sending markdown table syntax (| col1 | col2 |) in Feishu, the message displays as plain text instead of being rendered as a table. This works correctly in OpenClaw.

## Environment
- Hermes Agent version: \[latest\]
- Feishu integration via WebSocket
- Using native markdown table syntax in messages

## Expected behavior
Markdown tables should render as formatted Feishu table messages, similar to how OpenClaw handles them.

## Screenshots
\[Attach screenshot comparing OpenClaw vs Hermes Agent rendering\]

## Additional context
The Feishu platform adapter uses \`post\` msg_type with markdown, but the markdown hint regex may not be detecting table syntax properly.
- #9536: fix(feishu): markdown tables not rendering as interactive cards — simplify \`_should_send_as_card()\` trigger    condition
- #9549: \[Feishu\] Markdown tables not rendering in Feishu messages
- #9816: Feishu/Lark messages poorly formatted due to excessive Markdown escaping
- #18704: feishu: Markdown tables render as garbled text — missing post-format table support
- #18756: Feishu gateway: Markdown tables silently dropped in outgoing messages
- #21778: Bug Report: Feishu (Lark) Markdown Table Rendering Regression in v0.13.0
- #21866: Feishu: markdown tables render as raw text instead of using interactive card
- #23938: \[Bug\]: Feishu adapter strips all Markdown formatting when message contains a table
- #25452: Feishu channel displays markdown tables as raw source code instead of rendering them
- #26078: \[Bug\]: tables remain markdown source code in feishu chat window
- #26174: \[Bug\]: hermes返回的内容无法在飞书渲染
- #26236: \[Feature\]: Request Feishu support to return messages in the form of streaming cards, making the messages more aesthetically pleasing.
- #26658: \[feishu\] Remove markdown table force-text workaround — Feishu now supports tables in post format
- #26841: \[Feishu\] First chunk of long messages sent as msg_type=text instead of post, breaking Markdown rendering
- #27469: \[feishu\] Unified fix for markdown rendering: inbound escaping + GFM table → Card 2.0
- #27529: \[feishu\] Fix markdown table rendering: use post+tag:md instead of force-text workaround
- #27692: feat(feishu): support markdown tables via CardKit table element
- #27695: feat(feishu): support markdown tables via CardKit table element
- #29245: Feishu gateway: Markdown tables render as broken plain text
- #29471: bug(feishu): first chunk of long messages sent as msg_type=text, breaking Markdown rendering
- #32607: Feishu messages: markdown tables not rendered, making them unreadable
- #38755: fix(feishu): remove outdated markdown table fallback, always use post format
- #43437: Feishu/Lark: _escape_markdown_text over-escapes characters causing Markdown rendering corruption
- #46470: \[feishu\] Render all outbound markdown via Card 2.0 interactive cards (unified fix)
- #50602: Feishu adapter sends raw Markdown source for table-containing messages
- #52046: \[Feishu\] A markdown table downgrades the entire message to plain text, breaking all other formatting
- #52786: Feishu adapter incorrectly downgrades markdown tables to plain text
- #56430: Feishu 消息中 Markdown 表格被降级为纯文本导致渲染错误
- #58269: \[feishu\] Remove outdated table-to-text fallback — post(md) now supports GFM tables
- #61643: 🐛 Bug: Feishu messages show raw Markdown table syntax instead of rendering

## Prior & competing PRs

- **#20275: fix(feishu): force text mode for markdown tables (salvage #13723)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/feishu.py`
  - fix hunks:
  - `gateway/platforms/feishu.py`

    ```diff
    @@ -153,6 +153,9 @@
         r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
         re.MULTILINE,
     )
    +# Detect markdown tables: a line starting with | followed by a separator line.
    +# Feishu post-type 'md' elements do not render tables, so we force text mode.
    +_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", 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*$")
    ```

    ```diff
    @@ -3995,6 +3998,12 @@ def _is_duplicate(self, message_id: str) -> bool:
         # =========================================================================
     
         def _build_outbound_payload(self, content: str) -> tuple[str, str]:
    +        # Feishu post-type 'md' elements do not render markdown tables; sending
    +        # table content as post causes the message to appear blank on the client.
    +        # Force plain text for anything that looks like a markdown table.
    +        if _MARKDOWN_TABLE_RE.search(content):
    +            text_payload = {"text": content}
    +            return "text", json.dumps(text_payload, ensure_ascii=False)
             if _MARKDOWN_HINT_RE.search(content):
                 return "post", _build_markdown_post_payload(content)
             text_payload = {"text": content}
    ```
- **#22259: fix(feishu): wrap markdown tables in code_block tags for post rendering**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#25453: fix(feishu): render markdown tables as interactive card table components**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#46472`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#26108: fix(feishu): render markdown tables via post md tags instead of raw text**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#27400: fix(feishu): remove markdown table force-text workaround, add pipe table to post hint (#26658)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#27922`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#43486: fix(feishu): stop over-escaping Markdown in _escape_markdown_text (#43437)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#9816`, `#9991`, `#43437`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#45583: fix(feishu): route markdown tables through post instead of broken interactive card**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#53453`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#50640: fix(feishu): route markdown tables through post+md instead of forcing text**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#29166`, `#29166`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#68121: fix(feishu): render markdown tables via post+md, remove force-text downgrade**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `contributors/emails/[redacted:email]`, `contributors/emails/[redacted:email]`, `plugins/platforms/feishu/adapter.py`, `tests/gateway/test_feishu.py`, `tests/gateway/test_feishu_table_markdown.py`
  - fix hunks:
  - `contributors/emails/[redacted:email]`

    ```diff
    @@ -0,0 +1 @@
    +M1racleShih
    ```
  - `contributors/emails/[redacted:email]`

    ```diff
    @@ -0,0 +1 @@
    +JasonFang1993
    ```
  - `plugins/platforms/feishu/adapter.py`

    ```diff
    @@ -152,11 +152,24 @@
     # ---------------------------------------------------------------------------
     
     _MARKDOWN_HINT_RE = re.compile(
    -    r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
    +    # Pipe table: any header line + separator line both starting with '|'.
    +    r"(^\|.*\|\s*\n\|[-:|\s]+\|)"
    +    # Headings, lists, code, bold/italic/strike/underline, links, blockquotes.
    +    r"|(^#{1,6}\s)"
    +    r"|(^\s*[-*]\s)"
    +    r"|(^\s*\d+\.\s)"
    +    r"|(^\s*---+\s*$)"
    +    r"|(```)"
    +    r"|(`[^`\n]+`)"
    +    r"|(\*\*[^*\n].+?\*\*)"
    +    r"|(~~[^~\n].+?~~)"
    +    r"|(<u>.+?</u>)"
    +    r"|(\*[^*\n]+\*)"
    +    r"|(\[[^\]]+\]\([^)]+\))"
    +    r"|(^>\s)",
         re.MULTILINE,
     )
    -# Detect markdown tables: a line starting with | followed by a separator line.
    -# Feishu post-type 'md' elements do not render tables, so we force text mode.
    +# Backwards-compatible alias retained because external callers reference it.
     _MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
     _MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
     _MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
    ```

    ```diff
    @@ -4533,12 +4546,12 @@ def _is_duplicate(self, message_id: str) -> bool:
         # =========================================================================
     
         def _build_outbound_payload(self, content: str) -> tuple[str, str]:
    -        # Feishu post-type 'md' elements do not render markdown tables; sending
    -        # table content as post causes the message to appear blank on the client.
    -        # Force plain text for anything that looks like a markdown table.
    -        if _MARKDOWN_TABLE_RE.search(content):
    -            text_payload = {"text": content}
    -            return "text", json.dumps(text_payload, ensure_ascii=False)
    +        # Empirically (issue #52786), current Feishu clients render markdown
    +        # tables inside ``post``-type ``md`` elements natively. The previous
    +        # table-downgrade branch forced any table-containing message to
    +        # ``text``, which left Feishu readers seeing the raw pipe-and-dash
    +        # source instead of a rendered table. Trust the common markdown path
    +        # for table content too.
             if _MARKDOWN_HINT_RE.search(content):
                 return "post", _build_markdown_post_payload(content)
             text_payload = {"text": content}
    ```
  - `tests/gateway/test_feishu.py`

    ```diff
    @@ -2690,6 +2690,23 @@ def test_build_post_payload_keeps_full_markdown_text(self):
                 [[{"tag": "md", "text": "---\n1. 第一项\n  2. 子项\n- 外层\n  - 内层\n<u>下划线</u> 和 ~~删除线~~"}]],
             )
     
    +    @patch.dict(os.environ, {}, clear=True)
    +    def test_build_outbound_payload_uses_post_for_markdown_table(self):
    +        from gateway.config import PlatformConfig
    +        from plugins.platforms.feishu.adapter import FeishuAdapter
    +
    +        adapter = FeishuAdapter(PlatformConfig())
    +        content = "| Name | Score |\n| --- | ---: |\n| Ada | 10 |"
    +
    +        msg_type, raw_payload = adapter._build_outbound_payload(content)
    +
    +        self.assertEqual(msg_type, "post")
    +        payload = json.loads(raw_payload)
    +        self.assertEqual(
    +            payload["zh_cn"]["content"],
    +            [[{"tag": "md", "text": content}]],
    +        )
    +
         @patch.dict(os.environ, {}, clear=True)
         def test_send_uses_post_for_inline_markdown(self):
             from gateway.config import PlatformConfig
    ```
  - `tests/gateway/test_feishu_table_markdown.py`

    ```diff
    @@ -0,0 +1,133 @@
    +"""Tests for Feishu adapter outbound markdown payload construction.
    +
    +Reproduces the bug tracked in hermes-agent issue #52786:
    +`_build_outbound_payload` was force-downgrading any message containing a
    +markdown pipe table to ``msg_type=text``, so Feishu clients rendered the raw
    +pipe-and-dash source instead of a table.  Empirically current Feishu clients
    +render ``post``+``md`` tables natively, so the downgrade branch must be removed.
    +
    +These tests guard the fix.  They invoke the real adapter via the project's
    +plugin-loader helper so that no ``sys.path`` / ``sys.modules`` games are
    +needed.
    +"""
    +
    +from __future__ import annotations
    +
    +import json
    +
    +from tests.gateway._plugin_adapter_loader import load_plugin_adapter
    +
    +_adapter = load_plugin_adapter("feishu")
    +
    +
    +def _call_build_outbound_payload(content: str) -> tuple[str, str]:
    +    """Invoke ``_build_outbound_payload`` on a bare adapter instance.
    +
    +    ``_build_outbound_payload`` is a method that only uses module-level
    +    helpers (``_MARKDOWN_TABLE_RE``, ``_MARKDOWN_HINT_RE``,
    +    ``_build_markdown_post_payload``) and never touches ``self.*``, so a bare
    +    object is sufficient.
    +    """
    +    inst = object.__new__(_adapter.FeishuAdapter)
    +    return inst._build_outbound_payload(content)
    +
    +
    +def _md_texts_from_post_payload(payload_str: str) -> list[str]:
    +    """Pull every ``{tag:'md', text:'...'}`` element out of a Feishu post payload.
    +
    +    Real payload shape::
    +
    +        {"zh_cn": {"content": [[{"tag": "md", "text": "..."}], ...]}}
    +
    +    Helpers and tests need to introspect the ``md`` blocks regardless of
    +    locale, so we walk the structure generically.
    +    """
    +    payload = json.loads(payload_str)
    +    if not isinstance(payload, dict):
    +        return []
    +    texts: list[str] = []
    +    for lang_val in payload.values():
    +        if not isinstance(lang_val, dict):
    +            continue
    +        content = lang_val.get("content", [])
    +        if not isinstance(content, list):
    +            continue
    +        for block in content:
    +            if isinstance(block, list):
    +                candidates = block
    +            else:
    +                candidates = [block]
    +            for el in candidates:
    +                if isinstance(el, dict) and el.get("tag") == "md":
    +                    texts.append(el.get("text", ""))
    +    return texts
    +
    +
    +def test_markdown_table_uses_post_not_text():
    +    """Regression test for issue #52786 (and its older sibling #23938).
    +
    +    A message whose only markdown is a table must take the ``post`` path,
    +    not be downgraded to plain text.
    +    """
    +    content = (
    +        "| col A | col B |\n"
    +        "| ----- | ----- |\n"
    +        "| 1     | 2     |"
    +    )
    +    msg_type, payload_str = _call_build_outbound_payload(content)
    +    assert msg_type == "post", (
    +        f"expected 'post' for a markdown table (issue #52786), got {msg_type!r}; "
    +        "the table-downgrade branch in _build_outbound_payload has been re-introduced"
    +    )
    +    md_texts = _md_texts_from_post_payload(payload_str)
    +    assert md_texts, f"post payload must include at least one md element; got {payload_str!r}"
    +    joined = "".join(md_texts)
    +    assert "col A" in joined and "|" in joined, (
    +        "table text was lost or reformatted when switching from text to post"
    +    )
    +
    +
    +def test_plain_text_without_markdown_still_uses_text():
    +    """Negative control: a message with no markdown hints and no table must
    +    still go to plain text.  Guards against accidentally promoting everything
    +    to ``post``."""
    +    msg_type, _ = _call_build_outbound_payload("just a plain sentence with no markup")
    +    assert msg_type == "text"
    +
    +
    +def test_existing_markdown_heading_still_uses_post():
    +    """Sanity: the existing ``post`` path (heading / list / code / bold /
    +    link) must still work after the table downgrade is removed."""
    +    msg_type, payload_str = _call_build_outbound_payload("# hello world\n")
    +    assert msg_type == "post"
    +    md_texts = _md_texts_from_post_payload(payload_str)
    +    assert md_texts, f"expected at least one md element; got {payload_str!r}"
    +    assert any("hello world" in t for t in md_texts), (
    +        f"expected 'hello world' in md elements; got {md_texts!r}"
    +    )
    +
    +
    +def test_table_combined_with_other_markdown_does_not_downgrade():
    +    """A message that mixes a table with surrounding markdown must also
    +    take the ``post`` path.
    +
    +    The old ``_MARKDOWN_TABLE_RE`` branch returned ``text`` unconditionally
    +    and stripped all the surrounding markdown formatting, so a Feishu
    +    reader saw literal pipes and lost the prose framing the table.
    +    """
    +    content = (
    +        "Here is the data:\n\n"
    +        "| col A | col B |\n"
    +        "| ----- | ----- |\n"
    +        "| 1     | 2     |\n\n"
    +        "Let me know."
    +    )
    +    msg_type, payload_str = _call_build_outbound_payload(content)
    +    assert msg_type == "post"
    +    md_texts = _md_texts_from_post_payload(payload_str)
    +    joined = "\n".join(md_texts)
    +    assert "Here is the data" in joined, (
    +        "leading prose was lost when downgrading a mixed-table message"
    +    )
    +    assert "col A" in joined, "table header was lost"
    +    assert "Let me know" in joined, "trailing prose was lost"
    ```
- **#68122: fix(feishu): keep msg_type=post consistent across chunks of long markdown replies**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/platforms/feishu/adapter.py`, `tests/gateway/test_feishu.py`
  - fix hunks:
  - `plugins/platforms/feishu/adapter.py`

    ```diff
    @@ -1910,11 +1910,21 @@ async def send(
     
             formatted = self.format_message(content)
             chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
    +        # When chunking splits a long markdown response, an individual chunk
    +        # can end up as plain prose that doesn't match the per-chunk hint
    +        # regex — so it would be sent as ``msg_type=text`` and the user would
    +        # see literal ``**bold``/``## heading``/code fences in the Feishu
    +        # client while other chunks render correctly. Lock the markdown
    +        # decision at the whole-message level so every chunk consistently
    +        # uses ``post``. See #26841.
    +        prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted))
             last_response = None
     
             try:
                 for chunk in chunks:
    -                msg_type, payload = self._build_outbound_payload(chunk)
    +                msg_type, payload = self._build_outbound_payload(
    +                    chunk, prefer_post=prefer_post,
    +                )
                     try:
                         response = await self._feishu_send_with_retry(
                             chat_id=chat_id,
    ```

    ```diff
    @@ -4545,14 +4555,21 @@ def _is_duplicate(self, message_id: str) -> bool:
         # Outbound payload construction and send pipeline
         # =========================================================================
     
    -    def _build_outbound_payload(self, content: str) -> tuple[str, str]:
    +    def _build_outbound_payload(
    +        self, content: str, *, prefer_post: bool = False,
    +    ) -> tuple[str, str]:
             # Empirically (issue #52786), current Feishu clients render markdown
             # tables inside ``post``-type ``md`` elements natively. The previous
             # table-downgrade branch forced any table-containing message to
             # ``text``, which left Feishu readers seeing the raw pipe-and-dash
             # source instead of a rendered table. Trust the common markdown path
             # for table content too.
    -        if _MARKDOWN_HINT_RE.search(content):
    +        #
    +        # ``prefer_post`` lets ``send`` treat the chunk as part of a larger
    +        # markdown document: when a long markdown reply is split at
    +        # MAX_MESSAGE_LENGTH, the per-chunk regex would otherwise
    +        # mis-classify a plain-prose chunk as ``text``. See #26841.
    +        if prefer_post or _MARKDOWN_HINT_RE.search(content):
                 return "post", _build_markdown_post_payload(content)
             text_payload = {"text": content}
             return "text", json.dumps(text_payload, ensure_ascii=False)
    ```
  - `tests/gateway/test_feishu.py`

    ```diff
    @@ -2707,6 +2707,110 @@ def test_build_outbound_payload_uses_post_for_markdown_table(self):
                 [[{"tag": "md", "text": content}]],
             )
     
    +    @patch.dict(os.environ, {}, clear=True)
    +    def test_send_uses_post_for_every_chunk_of_multi_chunk_markdown(self):
    +        """Regression for #26841: when a long Markdown message is split
    +        across multiple chunks, every chunk must go out as
    +        ``msg_type=post`` — including chunk 1.  The bug was that the
    +        first chunk often had only plain prose (the per-chunk regex
    +        didn't match) and was sent as ``text``, so users saw literal
    +        ``**bold``/``## heading``/code fences while later chunks
    +        rendered correctly.
    +        """
    +        from gateway.config import PlatformConfig
    +        from plugins.platforms.feishu.adapter import FeishuAdapter
    +
    +        adapter = FeishuAdapter(PlatformConfig())
    +        captured = []
    +
    +        class _MessageAPI:
    +            def create(self, request):
    +                captured.append(request)
    +                return SimpleNamespace(
    +                    success=lambda: True,
    +                    data=SimpleNamespace(
    +                        message_id=f"om_chunk_{len(captured)}",
    +                    ),
    +                )
    +
    +        adapter._client = SimpleNamespace(
    +            im=SimpleNamespace(
    +                v1=SimpleNamespace(
    +                    message=_MessageAPI(),
    +                )
    +            )
    +        )
    +
    +        async def _direct(func, *args, **kwargs):
    +            return func(*args, **kwargs)
    +
    +        # Force a deterministic split so the test doesn't depend on the
    +        # exact 8000-char limit.  Chunk 1 is plain prose; chunk 2 has
    +        # the markdown markers.  Without the fix, chunk 1 went out as
    +        # ``msg_type=text``.
    +        first_chunk = "Here is a short intro that has no markdown markers at all."
    +        second_chunk = "## Heading\nAnd then some **bold** text."
    +
    +        with patch.object(
    +            adapter, "truncate_message", return_value=[first_chunk, second_chunk],
    +        ), patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct):
    +            result = asyncio.run(
    +                adapter.send(
    +                    chat_id="oc_chat",
    +                    content=first_chunk + "\n" + second_chunk,
    +                )
    +            )
    +
    +        self.assertTrue(result.success)
    +        self.assertEqual(len(captured), 2)
    +        msg_types = [r.request_body.msg_type for r in captured]
    +        self.assertEqual(msg_types, ["post", "post"])
    +
    +    @patch.dict(os.environ, {}, clear=True)
    +    def test_send_plain_text_message_not_upgraded_by_prefer_post(self):
    +        """A message with no markdown at all must still go out as plain
    +        ``msg_type=text`` — the whole-message ``prefer_post`` decision
    +        only flips on when the formatted message matches the hint regex.
    +        """
    +        from gateway.config import PlatformConfig
    +        from plugins.platforms.feishu.adapter import FeishuAdapter
    +
    +        adapter = FeishuAdapter(PlatformConfig())
    +        captured = []
    +
    +        class _MessageAPI:
    +            def create(self, request):
    +                captured.append(request)
    +                return SimpleNamespace(
    +                    success=lambda: True,
    +                    data=SimpleNamespace(
    +                        message_id=f"om_chunk_{len(captured)}",
    +                    ),
    +                )
    +
    +        adapter._client = SimpleNamespace(
    +            im=SimpleNamespace(
    +                v1=SimpleNamespace(
    +                    message=_MessageAPI(),
    +                )
    +            )
    +        )
    +
    +        async def _direct(func, *args, **kwargs):
    +            return func(*args, **kwargs)
    +
    +        with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct):
    +            result = asyncio.run(
    +                adapter.send(
    +                    chat_id="oc_chat",
    +                    content="just a plain sentence",
    +                )
    +            )
    +
    +        self.assertTrue(result.success)
    +        self.assertEqual(len(captured), 1)
    +        self.assertEqual(captured[0].request_body.msg_type, "text")
    +
         @patch.dict(os.environ, {}, clear=True)
         def test_send_uses_post_for_inline_markdown(self):
             from gateway.config import PlatformConfig
    ```
- **#109140: Feishu inbound post text is no longer markdown-escaped (#9816, salvage #9991)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/platforms/feishu/adapter.py`, `tests/gateway/test_feishu.py`
- **#6015: feat(feishu): send markdown responses as Card 2.0 instead of post messages**. open
  - maintainer direction: category `merged_via`; implication `superseded`
  - files: `gateway/platforms/feishu.py`, `tests/gateway/test_feishu.py`, `website/docs/user-guide/messaging/feishu.md`
- **#7085: feat(feishu): send markdown responses as Card 2.0 instead of post messages**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#9991: fix: remove excessive markdown escaping in Feishu text element rendering**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `d131391ff9`, `#109140`
- **#10801: fix(feishu): WebSocket watchdog + approval card PATCH + markdown table rendering**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#10251`, `#10251`, `#77364`
  - files: `gateway/platforms/feishu.py`, `gateway/run.py`, `tests/gateway/test_feishu.py`
- **#12114: fix(feishu): render markdown tables via card v2 \`table\` component**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#68121`
- **#16474: fix(feishu): unify outbound to post and degrade tables to text element**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#68121`
- **#19015: fix(feishu): wrap pipe tables in code fences so columns survive**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `8e18d10318f9fb69f0b748db11e37de44b71da85`, `#18756`
- **#19038: fix(feishu): route tables and multi-line code blocks to CardKit 2.0**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#20275`, `#13723`, `#19035`, `#9549`
- **#19231: feat(feishu): use card to show complex message**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#12459`
  - files: `gateway/platforms/feishu.py`
- **#22047: fix(feishu): keep markdown tables in post payload so prose still renders**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`, `#58019`, `#29552`
- **#22272: fix(feishu): render markdown tables as native card tables**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `5600105478ffde29d7566b45421b100eaa29c4ef`
  - files: `evidence/S1-summary-store-migration-notes.md`, `evidence/hermes-0.18-upgrade-review.md`, `gateway/display_config.py`, `gateway/run.py`, `plugins/memory/hindsight/README.md`, `plugins/memory/hindsight/__init__.py`, `plugins/memory/hindsight/plugin.yaml`, `plugins/memory/hindsight/session_summary.py`, `plugins/memory/hindsight/session_summary_assembly.py`, `plugins/memory/hindsight/session_summary_generator.py`, `plugins/platforms/feishu/adapter.py`, `tests/gateway/test_feishu.py`, `tests/gateway/test_feishu_markdown_table.py`, `tests/gateway/test_run_progress_topics.py`, `tests/gateway/test_send_image_file.py`, `tests/gateway/test_stream_media_delivery_failure.py`, `tests/plugins/memory/test_hindsight_provider.py`, `tests/plugins/memory/test_hindsight_session_summary.py`, `tests/plugins/memory/test_hindsight_session_summary_assembly.py`, `tests/plugins/memory/test_hindsight_session_summary_generator.py`
- **#23861: fix(feishu): route tables and code blocks to CardKit 2.0 with post/text fallback**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#26119: fix(feishu): render markdown tables via rich post type instead of plain text**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#26108`
- **#26848: fix(feishu): send every chunk of a long markdown reply as msg_type=post (#26841)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68122`, `#68121`
- **#27739: fix(feishu): render markdown tables via post+md instead of force-text**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#26108`, `#27469`
- **#28837: fix(feishu): render markdown tables via cards**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `977884e6cd`, `#68121`, `#58019`
- **#29472: fix(feishu): use consistent msg_type across all message chunks**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#26848`, `#16474`, `#18371`
- **#29510: fix(feishu): convert markdown tables to bullet lists instead of raw text**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#29552: fix(feishu): render markdown tables via post+tag:md instead of plain text**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`
- **#31038: feat(feishu): support markdown tables via CardKit table element (JSON 2.0 schema)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `977884e6cd`, `#68121`, `#58019`
- **#32488: feat(feishu): Schema 2.0 markdown card for GFM tables, code blocks, and inline formatting**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `plugins/memory/tianren-anima/__init__.py`, `plugins/platforms/feishu/adapter.py`, `tests/gateway/test_feishu.py`
- **#32648: fix(feishu): render markdown tables as aligned code blocks instead of raw text**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#27469`, `#9549`, `#12114`, `#21872`, `#20580`, `#32488`, `#20580`, `#21872`
- **#33310: fix(feishu): render markdown tables and code blocks via CardKit 2.0 cards**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#53453`, `#53355`, `#36877`
- **#33800: fix(feishu): render markdown tables via Schema 2.0 interactive cards**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#26108`, `#29552`, `#31056`, `#31410`, `#9549`
- **#37830: feat(gateway): support Feishu interactive card (msg_type=interactive) in send_message**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `552adbe08`
  - files: `gateway/platforms/feishu.py`
- **#38834: fix(feishu): remove outdated markdown table fallback, always use post format**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`, `#58019`, `#29552`
- **#38867: fix(feishu): remove outdated markdown table fallback, always use post format #38755**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#38834`, `#38755`, `#27469`, `#31410`, `#34181`
- **#39955: fix(feishu): remove outdated markdown table text-mode fallback**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`, `#58019`, `#29552`
- **#40445: feat(feishu): render markdown tables as native Card JSON 2.0 table components**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `gateway/platforms/feishu.py`, `tests/gateway/test_feishu.py`
- **#46472: fix(feishu): render all markdown via Card 2.0 interactive cards**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#68121`, `#25452`, `#68121`, `#68121`
- **#46502: fix(feishu): render markdown tables as interactive cards**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#48807: feat(feishu): render markdown tables as CardKit v2 native table components**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#12114`, `#61496`
  - files: `gateway/platforms/helpers.py`, `plugins/platforms/feishu/adapter.py`, `tests/gateway/test_feishu_table_cards.py`, `tests/gateway/test_table_helpers.py`
- **#51739: fix(plugins/feishu): render markdown tables as CardKit v2 interactive cards**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#12114`, `#17006`, `#48807`, `#48334`, `#12114`, `#50602`
  - files: `plugins/platforms/feishu/adapter.py`
- **#52066: fix(feishu): preserve markdown around tables**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#68121`, `#52046`
- **#52790: fix: send Feishu markdown tables as post**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`, `#58019`, `#58019`, `#29552`
- **#58019: fix(feishu): render markdown tables via post+md, not text downgrade**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`
- **#58391: fix(feishu): use post(md) for messages containing markdown tables**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`, `#38834`, `#58019`, `#29552`
- **#61647: fix(feishu): render Markdown tables natively instead of falling back to plain text (#61643)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`, `#58019`, `#29552`
- **#65529: fix(feishu): remove outdated markdown table fallback, always use post format**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#68121`, `#58019`, `#58019`, `#29552`
- **#79420: fix(feishu): render clarify prompts as interactive card buttons with text fallback**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54304`, `#76274`

## Fix status

- Merged to main in PR #20275 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-12`.
- Subsystem: `gateway`
