mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
17a99f6b15
commit
9403b4f8ba
2 changed files with 124 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue