fix(feishu): keep msg_type=post consistent across every chunk of a long markdown reply (#26841)

Transplant of PR #26848 onto the plugin adapter path
(plugins/platforms/feishu/adapter.py — the original PR targeted the
since-removed gateway/platforms/feishu.py).

``send`` classifies each chunk independently, so chunk 1 of a long
markdown reply (often plain prose) went out as msg_type=text while
later chunks rendered as post — literal **bold**/## heading markers
in the Feishu client. Lock the decision at the whole-message level:
compute prefer_post once from the full formatted message and pass it
to _build_outbound_payload per chunk.

The original PR's per-chunk table exemption is intentionally dropped:
tables now route through post/md (issue #52786 cluster fix), so the
exemption would reintroduce the raw-table downgrade.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
This commit is contained in:
xxxigm 2026-07-20 08:51:43 -07:00 committed by Teknium
parent 17a99f6b15
commit 9403b4f8ba
2 changed files with 124 additions and 3 deletions

View file

@ -1910,11 +1910,21 @@ class FeishuAdapter(BasePlatformAdapter):
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,
@ -4545,14 +4555,21 @@ class FeishuAdapter(BasePlatformAdapter):
# 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)

View file

@ -2707,6 +2707,110 @@ class TestAdapterBehavior(unittest.TestCase):
[[{"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