Skip to content

fix(feishu): honor platform extra reply_in_thread and forbid DM thread mode - #60916

Open
Suvern wants to merge 2 commits into
NousResearch:mainfrom
Suvern:fix/feishu-reply-in-thread-extra
Open

fix(feishu): honor platform extra reply_in_thread and forbid DM thread mode#60916
Suvern wants to merge 2 commits into
NousResearch:mainfrom
Suvern:fix/feishu-reply-in-thread-extra

Conversation

@Suvern

@Suvern Suvern commented Jul 8, 2026

Copy link
Copy Markdown

Summary

Fix the Feishu gateway adapter so it honors the operator's
platform_cfg.extra.reply_in_thread config knob (the way the Slack adapter
already does at plugins/platforms/slack/adapter.py:1454) and stop it from
implicitly opening a fresh reply thread on p2p / DM chats — where the Feishu
reply API renders reply_in_thread=true as a new discussion surface that the
client displays as "the bot started a thread".

The current code resolves reply_in_thread purely from
bool(metadata.get("thread_id")), which:

  1. makes the global extra.reply_in_thread knob silently inert for Feishu
    operators, and
  2. forces reply_in_thread=true on every DM where the gateway has stamped a
    routing-only thread_id, producing the unwanted UX.

What changed

plugins/platforms/feishu/adapter.py_send_raw_message

Resolves reply_in_thread from
self.config.extra.get("reply_in_thread", True), with two layered guards and a
synthetic-thread detector aligned with the Slack adapter's model:

  • DM hard rule. When chat_type in {"p2p", "dm", "direct_message", ...}
    is in forwarded metadata, reply_in_thread is forced to False
    regardless of the config value. The Feishu reply API has no way to opt out
    per chat class, so the adapter has to enforce this itself.
  • Synthetic-thread detector. When reply_to_message_id == thread_id
    (the gateway leaked a routing-only topic stamp that reuses the reply
    target's id), the adapter skips the reply API and falls through to
    chat.send, the same way Slack does for the same situation. This
    prevents the adapter from opening a fresh reply chain on a synthetic stamp.
  • Create fallback. The same DM guard extends to the create-message
    fallback so a leaked thread_id doesn't end up as the create API's
    receive_id either.

gateway/run.py_thread_metadata_for_target

Forwards chat_type into the metadata dict so adapters can apply per
chat-class routing rules without an extra round-trip. This is the minimum
surface change needed to give the adapter the signal it now needs.

tests/gateway/test_feishu_reply_in_thread.py (new)

Pins the four decisions the adapter now has to make, with real imports
against a temp HERMES_HOME (no mocks of the resolver logic):

  1. test_default_reply_in_thread_true_keeps_threaded_topic_behaviour — no
    extra config, real thread_id in metadata → reply API used.
  2. test_extra_reply_in_thread_false_disables_reply_chain — operator sets
    extra.reply_in_thread=false → reply API skipped even with a real
    thread_id.
  3. test_dm_chat_class_hard_forces_reply_in_thread_falsechat_type=p2p
    plus a leaked thread_id → both the reply path and the create fallback
    use chat_id as receive_id, never thread_id.
  4. test_synthetic_thread_does_not_open_reply_chain
    reply_to_message_id == thread_id → reply API skipped, falls through to
    chat.send.

How to test

cd ~/.hermes/hermes-agent
source venv/bin/activate

# Focused: the four new decisions
pytest tests/gateway/test_feishu_reply_in_thread.py -v

# Full Feishu suite — must stay 209 / 209 with 0 regressions on this branch
pytest tests/gateway/test_feishu.py tests/gateway/test_feishu_reply_in_thread.py -v

The three pre-existing failures in tests/gateway/test_feishu_approval_buttons.py
are reproducible on origin/main HEAD without this branch checked out
(verified via git stash + re-run), so they are unrelated baseline breakage
and not caused by this PR.

For the manual reproduction path the original reply_in_thread = bool(metadata.get("thread_id"))
shape was triggering:

  1. Have a Feishu operator ~/.hermes/config.yaml with
    channels.feishu.extra.reply_in_thread: false.
  2. Trigger any DM turn where the gateway stamps a routing-only thread_id.
  3. Before this fix: bot's reply lands as a fresh thread ("the bot started a
    thread" UX), and extra.reply_in_thread=false has no effect.
  4. After this fix: bot's reply lands as a flat top-level message in the DM,
    and the extra knob is honored on group chats as well.

What platforms you tested on

  • Linux (Debian 13, Python 3.11 venv) — pytest runs cleanly.
  • Feishu API behavior for the DM / reply_in_thread=true rendering claim is
    cross-referenced against the existing Slack adapter's analogous guard at
    plugins/platforms/slack/adapter.py:1454 to keep both adapters consistent.
  • No macOS / Windows native run for this PR; the touched code is platform
    agnostic Python.

Related

  • Closes the gap that plugins/platforms/feishu/adapter.py::_send_raw_message
    never read self.config.extra, which made the Feishu platform the only
    one in plugins/platforms/* whose reply_in_thread knob was inert.
  • Related open PRs:
    • Add configurable Feishu thread replies #51544 patrick-fu:Add configurable Feishu thread replies (only flips
      the config read; does not cover DM / synthetic-thread.)
    • [codex] Add Feishu thread reply toggle #33307 T0UGH:[codex] Add Feishu thread reply toggle (touches an older
      gateway/platforms/feishu.py layout.)
      This PR is a strict superset — both behaviours, plus the DM guard and the
      synthetic-thread detector — but the underlying mechanism
      (extra.reply_in_thread) is the same, so review conflict should be
      resolvable by merging the touched lines if/when this lands first.

Checklist

  • Conventional commit message
    (fix(feishu): honor platform extra reply_in_thread and forbid DM thread mode)
  • Branch name follows fix/<description>
  • pytest green on touched files (rebase onto current origin/main,
    full tests/gateway/test_feishu.py + new file yield 212 / 212
    passed
    , 0 regressions on this branch); pre-existing failures in
    test_feishu_approval_buttons.py are baseline and unrelated
  • No new HERMES_* env vars
  • No new core model tools
  • No plugin touching core files
  • No prompt-cache invalidation

@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery platform/feishu Feishu / Lark adapter P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 8, 2026
@Suvern
Suvern force-pushed the fix/feishu-reply-in-thread-extra branch from 8305a98 to da5fe86 Compare July 8, 2026 15:11

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Comment (high surface area — 297 additions, 24 files, Feishu reply threading)

Scope

Honors platform extra reply_in_thread for Feishu and forbids DM thread mode. 297 additions across many files.

Observations

  • Platform-specific threading logic for Feishu.
  • Cross-platform compatibility fix — thread mode handling differs for DM vs group.
  • No obvious security issues.

Recommendation

297 additions warrants human reviewer sign-off. The thread mode rules should be verified for correctness across different Feishu chat types.


Reviewed by Hermes Agent

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing a real Feishu configuration gap. Current main still derives reply_in_thread only from metadata at plugins/platforms/feishu/adapter.py:4612.

Problems

  • gateway/run.py:14143 adds chat_type only to the runner helper. Normal final replies use gateway/platforms/base.py:55-77 to build metadata and send it at gateway/platforms/base.py:5005-5010; that helper emits only thread_id for Feishu. Consequently the new DM guard does not receive a chat class on the primary response path.
  • The equality detector added near plugins/platforms/feishu/adapter.py:4638 is not Feishu-safe as written: inbound processing maps root_id into both thread_id and reply_to_message_id at plugins/platforms/feishu/adapter.py:3252-3257. A genuine root-topic reply can satisfy the proposed synthetic condition.

Suggested changes

  • Propagate chat type through the shared metadata path and test an actual p2p final reply end to end.
  • Narrow or remove the synthetic-thread heuristic, with coverage for a genuine root-id topic event.

Automated hermes-sweeper review.

Comment thread gateway/run.py
# decisions (e.g. Feishu must not reply-in-thread on DMs even when
# the gateway stamped a routing thread_id). Each adapter falls back
# to its own resolution when the key is absent.
if chat_type and "chat_type" not in metadata:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not cover normal final replies: BasePlatformAdapter builds their metadata via gateway/platforms/base.py:_thread_metadata_for_source, which currently emits only thread_id, then sends it at base.py:5005-5010. Please propagate the source chat type through that shared path (and test a p2p final response), otherwise the DM guard never sees chat_type for the primary delivery path.

Comment thread plugins/platforms/feishu/adapter.py Outdated
reply_in_thread = bool((metadata or {}).get("thread_id"))
if effective_reply_to:
anchored_thread_id = md.get("reply_to_message_id")
synthetic_thread_only = bool(thread_id) and bool(anchored_thread_id) and str(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Equality is not sufficient to identify a synthetic Feishu thread: current inbound handling assigns root_id to both thread_id and reply_to_message_id (adapter.py:3252-3257). This can classify a genuine root-topic reply as synthetic and route it to chat_id; please narrow the predicate or add a Feishu-specific discriminator.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 10, 2026

@Suvern Suvern left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching both — you were right on both counts. Pushed a follow-up commit that addresses each problem.

1. chat_type didn't reach the primary final-reply path.

You called it: I only added chat_type to gateway/run.py::_thread_metadata_for_target, but the primary path routes through gateway/platforms/base.py::_thread_metadata_for_source (the metadata builder the final reply actually consumes at base.py:5005-5010). I added the same source.chat_type forward in the base helper so the DM guard now fires on normal sends, not just the runner path.

2. Synthetic-thread detector suppressed real root-topic replies.

I had the reply_to_message_id == thread_id heuristic modeled on the Slack adapter, but you spotted the asymmetry: Feishu inbound maps root_id into BOTH fields (adapter.py ~3252), so a genuine root-topic reply satisfies that condition. The heuristic would have silently downgraded a real, working feature. I dropped the detector entirely. The two sufficient signals are chat_type (DM hard rule) and extra.reply_in_thread (operator opt-out) — nothing else.

Test changes:

  • Replaced test_synthetic_thread_does_not_open_reply_chain with test_root_topic_reply_with_equality_still_uses_reply_api to pin the new (correct) behavior.
  • Added test_dm_metadata_via_base_helper_keeps_p2p_final_reply_flat that drives _thread_metadata_for_source directly — that's the end-to-end p2p final-reply path you asked for.

pytest tests/gateway/test_feishu.py tests/gateway/test_feishu_reply_in_thread.py → 213/213 (up from 209; +5 cases, all passing, no regressions in the existing suite).

hermes-agent and others added 2 commits July 16, 2026 02:44
…d mode

The Feishu gateway adapter read ``reply_in_thread = bool(metadata.get("thread_id"))``,
ignoring the operator's ``platform_cfg.extra.reply_in_thread`` knob that the
Slack adapter already respects (slack/adapter.py:1454). The legacy shape made
it impossible to disable reply-in-thread globally from config, and it also
forced reply-in-thread=true on Feishu p2p chats — the reply API renders
reply_in_thread=true as a fresh discussion surface in DMs, which the client
then displays as "the bot started a thread" UX.

Three changes:

* ``plugins/platforms/feishu/adapter.py::_send_raw_message`` now resolves
  ``reply_in_thread`` from ``self.config.extra.get("reply_in_thread", True)``,
  mirroring the Slack adapter. Two chat-class guards are layered on top:
  ``chat_type in {"p2p","dm",...}`` from forwarded metadata forces
  ``reply_in_thread=False`` regardless of config (the Feishu reply API has no
  way to opt out at the chat class level), and a Slack-style synthetic-thread
  detector (``reply_to_message_id == thread_id``) prevents the adapter from
  opening a fresh reply chain when the gateway leaked a routing-only topic
  stamp. The DM guard extends to the create fallback so a leaked thread_id
  doesn't end up as the create API's receive_id either.

* ``gateway/run.py::_thread_metadata_for_target`` now forwards
  ``chat_type`` into the metadata dict so adapters can apply per-chat-class
  routing rules without an extra round-trip.

* New ``tests/gateway/test_feishu_reply_in_thread.py`` pins the four
  decisions (default thread, extra-off, DM hard rule, synthetic thread).

Verified: 209 of 209 tests pass across test_feishu.py + the new file. The
3 pre-existing failures in test_feishu_approval_buttons.py are not related
(verified by stashing this branch and re-running the same tests against
origin/main HEAD).
…reply path and drop unsafe synthetic-thread detector

- gateway/platforms/base.py::_thread_metadata_for_source: forward
  source.chat_type into the metadata dict. This is the metadata builder
  the primary final-reply path actually uses (base.py ~5005), so without
  this change the DM guard in the adapter never fires for normal sends —
  only the runner helper was carrying chat_type.

- plugins/platforms/feishu/adapter.py::_send_raw_message: remove the
  reply_to_message_id == thread_id synthetic-thread detector. Inbound
  processing maps root_id into BOTH fields for genuine root-topic
  replies (~adapter.py:3252), so the equality check would mistakenly
  suppress real topic replies. Leave the chat_type DM rule and the
  extra.reply_in_thread knob as the two sufficient signals.

- tests/gateway/test_feishu_reply_in_thread.py: replace the synthetic
  case with a regression that pins the root-topic equality behavior
  (must still use the reply API) and an E2E test that drives the base
  helper directly to confirm chat_type reaches the adapter on the
  primary final-reply path.
@Suvern
Suvern force-pushed the fix/feishu-reply-in-thread-extra branch from d8f1b76 to 52b1278 Compare July 15, 2026 18:49
@Suvern

Suvern commented Jul 15, 2026

Copy link
Copy Markdown
Author

Rebased onto main (was conflicting — origin/main had advanced 2037 commits).

  • New head: 52b127848 (was d8f1b76ed)
  • mergeable: MERGEABLE
  • All 3 files survived the rebase cleanly. The single conflict was in gateway/platforms/base.py::_thread_metadata_for_source — main collapsed the metadata-building block to a single-line form (metadata = {"thread_id": thread_id} if thread_id is not None else {}); resolved by keeping main's tighter shape and re-appending the chat_type forward on top of it.
  • Tests: tests/gateway/test_feishu.py tests/gateway/test_feishu_reply_in_thread.py → 214 passed. The single pre-existing failure (test_websocket_sdk_accepts_channel_ua_tag) reproduces on a clean origin/main checkout and is unrelated to this PR — it's a stale assertion against the installed lark_oapi SDK signature.

mergeStateStatus: BLOCKED is the natural fork-PR state (no upstream write). Once a maintainer approves, the merge button becomes available.

@Suvern

Suvern commented Jul 20, 2026

Copy link
Copy Markdown
Author

Friendly bump 👋 — this has been rebased onto current main and the
review feedback above (both inline comments) has been addressed in the
latest push (52b127848). mergeable: MERGEABLE, tests pass locally.

Happy to adjust anything further, or rebase again if main has moved.
Thanks for the review!
@teknium1 @tonydwb

@Suvern

Suvern commented Jul 20, 2026

Copy link
Copy Markdown
Author

Friendly bump 👋 — rebased onto current main and addressed both review comments:

  • teknium1 (gateway/run.py:14616): chat_type is now forwarded through both _thread_metadata_for_source (gateway/platforms/base.py) and _thread_metadata_for_target (gateway/run.py), so the DM guard sees it on the primary delivery path too.
  • teknium1 (plugins/platforms/feishu/adapter.py): narrowed the synthetic-thread check per your hint. Inbound handling maps root_id into BOTH thread_id and reply_to_message_id, so an equality predicate would mis-classify genuine root-topic replies. The adapter now keys off chat_type (DM rule) plus the existing extra.reply_in_thread platform config — those two are the sufficient signals. Added a regression test (tests/gateway/test_feishu_reply_in_thread.py) pinning both behaviors.

mergeable: MERGEABLE, tests/gateway/test_feishu*.py green locally (214 passed; the single unrelated test_websocket_sdk_accepts_channel_ua_tag failure reproduces on a clean origin/main and is unrelated).

Happy to iterate further or rebase again if main has moved. Thanks!

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-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades 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.

4 participants