Skip to content

fix(feishu): send every chunk of a long markdown reply as msg_type=post (#26841) - #26848

Closed
xxxigm wants to merge 2 commits into
NousResearch:mainfrom
xxxigm:fix/feishu-first-chunk-msg-type-26841
Closed

fix(feishu): send every chunk of a long markdown reply as msg_type=post (#26841)#26848
xxxigm wants to merge 2 commits into
NousResearch:mainfrom
xxxigm:fix/feishu-first-chunk-msg-type-26841

Conversation

@xxxigm

@xxxigm xxxigm commented May 16, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Stops the Feishu (Lark) adapter from sending the first chunk of a long Markdown reply as msg_type=text while later chunks go out as msg_type=post — so users no longer see literal **bold** / ## heading / code fences in the first message and rendered Markdown in the rest.

Issue #26841 reported that when Hermes produced a long, Markdown-rich response (headings + bold + code blocks), the first chunk in the Feishu client showed raw Markdown while subsequent chunks rendered correctly. The cause is per-chunk classification:

  • FeishuAdapter.send formats the full reply, then calls truncate_message (which splits at MAX_MESSAGE_LENGTH = 8000) and walks the chunks.
  • For each chunk it calls _build_outbound_payload(chunk), which inspects only that chunk with _MARKDOWN_HINT_RE. If the chunk doesn't match (because, e.g., the intro paragraph happens to be plain prose), the function returns msg_type=text and the user sees literal Markdown markers.
  • The headings / bold / fences usually live in later chunks, so chunks 2..N go out as post and render correctly.

There is no if i == 0 branch — the bug is implicit in the split point. This PR fixes it by locking the Markdown decision at the whole-message level:

  • send() computes prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted)) once, before the chunk loop.
  • _build_outbound_payload(chunk, *, prefer_post=False) honours prefer_post after the table guard. Per-chunk table override still wins — any chunk that contains a Markdown table stays text (Feishu's post-type md element can't render tables and would otherwise blank that chunk).

Single-chunk sends, non-Markdown sends, and the existing post → text API-rejection fallback are unchanged.

Related Issue

Fixes #26841

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/platforms/feishu.py::_build_outbound_payload — add keyword-only prefer_post: bool = False. After the table guard, fall into the post branch when either _MARKDOWN_HINT_RE.search(content) matches or the caller signalled prefer_post=True. Table chunks still short-circuit to text (unchanged).
  • gateway/platforms/feishu.py::send — before the chunk loop, compute prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted)) from the full formatted message and pass it into each _build_outbound_payload(chunk, prefer_post=...) call. edit_message is single-payload so it's unaffected.
  • tests/gateway/test_feishu.py — two regression tests:
    • test_send_uses_post_for_every_chunk_of_multi_chunk_markdown: patches truncate_message to return [plain_prose_first, markdown_second]; both API calls must use msg_type=post (was ["text", "post"] before the fix).
    • test_send_keeps_table_chunks_as_text_even_when_message_is_markdown: a markdown table in chunk 2 still goes as text even when the whole-message prefer_post is on, so we don't accidentally blank table content in the client.

How to Test

# 1. New regression tests + existing feishu tests pass
./scripts/run_tests.sh tests/gateway/test_feishu.py
# expected: 200 passed (198 existing + 2 new)

# 2. Cross-suite sweep — adapter + approval buttons + comment plugin
./scripts/run_tests.sh tests/gateway/test_feishu.py \
                      tests/gateway/test_feishu_approval_buttons.py \
                      tests/gateway/test_feishu_comment.py
# expected: 251 passed

# 3. Manual reproduction
#    a. Configure Feishu credentials and have an agent produce a long
#       Markdown reply (headings, bold, fenced code, ≥ ~8000 chars so
#       the adapter chunks it).
#    b. Use Feishu's +chat-messages-list to inspect outgoing messages.
#    c. Expected: every non-table chunk has msg_type=post (was
#       ["text", "post", "post", ...] before this PR). Table chunks,
#       if any, still use msg_type=text.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(feishu):, test(feishu):)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run the Feishu test suites and all 251 tests pass
  • I've added tests for my changes (required for bug fixes)
  • I've tested on my platform: macOS 15.6 (darwin 24.6.0)

Documentation & Housekeeping

  • I've updated relevant documentation — N/A (internal adapter behavior; no user-facing surface added)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no new config keys)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A (no architecture change)
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure Python adapter logic, identical on every platform
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A (no tool/schema change; only adapter-internal msg_type selection)

Screenshots / Logs

Before the fix (issue #26841 reproduction, +chat-messages-list):

chunk 1/3  msg_type=text   → user sees: "Here is a short intro… ## Heading **bold**" (raw)
chunk 2/3  msg_type=post   → renders correctly
chunk 3/3  msg_type=post   → renders correctly

After the fix:

chunk 1/3  msg_type=post   → renders correctly
chunk 2/3  msg_type=post   → renders correctly
chunk 3/3  msg_type=post   → renders correctly

# Mixed-content message with a table somewhere in the middle:
chunk 1/3  msg_type=post   → headings/bold render
chunk 2/3  msg_type=text   → table chunk stays text (post-type 'md'
                              can't render tables; would otherwise
                              show blank in the Feishu client)
chunk 3/3  msg_type=post   → trailing prose renders

xxxigm added 2 commits May 16, 2026 16:07
…ng markdown reply (NousResearch#26841)

``FeishuAdapter._build_outbound_payload`` picks ``msg_type=post`` only
when the *current* chunk matches ``_MARKDOWN_HINT_RE``.  When ``send``
splits a long markdown response at ``MAX_MESSAGE_LENGTH``, the first
chunk can easily end up as plain prose (the headings / bold / code
fences live in later chunks).  The first chunk then goes out as
``msg_type=text`` and the user sees literal ``**bold**`` / ``## heading``
markers, while later chunks render correctly — a confusing per-message
split UX on Feishu.

Lock the decision at the whole-message level: ``send`` computes
``prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted))`` once before
chunking and passes it into ``_build_outbound_payload(chunk,
prefer_post=...)``.  Per-chunk table override still wins so chunks that
carry a markdown table stay ``text`` (post-type ``md`` can't render
tables and would otherwise blank the message).  Single-chunk and
non-markdown sends are unaffected.
…ption (NousResearch#26841)

Two regression tests on ``FeishuAdapter.send``:
- ``test_send_uses_post_for_every_chunk_of_multi_chunk_markdown`` —
  ``truncate_message`` is patched to return a plain-prose first chunk
  followed by a markdown second chunk; both API calls must use
  ``msg_type=post`` (was ``["text", "post"]`` before the fix).
- ``test_send_keeps_table_chunks_as_text_even_when_message_is_markdown``
  — the new whole-message override must not clobber the per-chunk
  table guard, so a chunk carrying a markdown table still goes as
  ``text`` even when ``prefer_post`` is on.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists platform/feishu Feishu / Lark adapter comp/gateway Gateway runner, session dispatch, delivery labels May 16, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for isolating the per-chunk classification failure and preserving the table fallback.

Problems

  • Current main moved the active adapter from gateway/platforms/feishu.py to plugins/platforms/feishu/adapter.py in 560010547. The same defect remains there: send() classifies each chunk at plugins/platforms/feishu/adapter.py:1898-1904, and _build_outbound_payload() decides from that chunk alone at :4524-4534.
  • The regression tests must also move from the removed gateway.platforms.feishu import path to plugins.platforms.feishu.adapter; current tests already use that path at tests/gateway/test_feishu.py:2694-2720.

Suggested changes

  • Transplant this narrow whole-message prefer_post decision and its table-chunk exemption into the active plugin adapter, then update the tests for that surface.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
teknium1 pushed a commit that referenced this pull request Jul 20, 2026
…ng 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>
teknium1 pushed a commit that referenced this pull request Jul 20, 2026
…ng 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>
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #68122 — your whole-message prefer_post fix was transplanted onto the current plugin adapter path (plugins/platforms/feishu/adapter.py; your PR predated the move out of gateway/platforms/feishu.py) with your authorship preserved in git log. One deliberate change: the per-chunk table exemption was dropped, because tables now route through post/md (#68121) and the exemption would have reintroduced the raw-table downgrade. Your two regression tests were adapted to the new path. Thanks for isolating the per-chunk classification failure!

@teknium1 teknium1 closed this Jul 20, 2026
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ng markdown reply (NousResearch#26841)

Transplant of PR NousResearch#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 NousResearch#52786 cluster fix), so the
exemption would reintroduce the raw-table downgrade.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/feishu Feishu / Lark adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feishu] First chunk of long messages sent as msg_type=text instead of post, breaking Markdown rendering

3 participants