Skip to content

fix(feishu): route topic sends via reply API; remove invalid thread_id receive_id - #59444

Open
DarkMagicCK wants to merge 1 commit into
NousResearch:mainfrom
DarkMagicCK:fix/feishu-thread-routing-invalid-receive-id
Open

fix(feishu): route topic sends via reply API; remove invalid thread_id receive_id#59444
DarkMagicCK wants to merge 1 commit into
NousResearch:mainfrom
DarkMagicCK:fix/feishu-thread-routing-invalid-receive-id

Conversation

@DarkMagicCK

Copy link
Copy Markdown

What does this PR do?

Fixes a Feishu send failure where the agent's reply after an async-delegation completion (and other background-notification synthetic events) is rejected by the Feishu API with [99992402] field validation failed, because the adapter emits an invalid receive_id_type=thread_id on the create-message API.

Root cause. A message can only land in a Feishu topic via the reply API (reply_in_thread=true) against a real om_ message id. There is no "send by thread_id" path — the create-message API's receive_id_type accepts only open_id/union_id/user_id/email/chat_id, not thread_id (per the create-message docs and message field docs, which define thread_id as a topic identifier distinct from message_id's om_ prefix).

The broken branch was added in #13077 (ff14666cd) to route "reply→create fallback" messages into a topic. Its regression test (tests/gateway/test_stream_consumer_thread_routing.py::TestFeishuFallbackThreadRouting::test_create_uses_thread_id_when_available) was a pure mock that only asserted the request was shaped as receive_id_type=thread_id and the client returned success() — it never exercised the real Feishu API, so the server rejection never surfaced. This is a green-unit-mock-hides-integration-bug case.

When it triggers. Real inbound messages route fine — they carry event.reply_to_message_id (the topic root om_), so the adapter uses the reply API. The broken branch is only reached when a synthetic / resumed send has no reply anchor:

  • delegate_task(background=true) completions — the async-delegation event carries session_key but no message_id.
  • terminal(background=True, notify_on_complete=True) agent re-entry — watcher_message_id was sourced from source.message_id, which Feishu never populated.
  • terminal text-only notifications and watch_pattern events in a topic.

All of these hit the invalid thread_id create path and fail with [99992402].

Why this approach. The fix keeps the existing "reply API lands the message in a topic" contract and makes the routing layer guarantee a stable om_ anchor end-to-end, so every path that needs to send into a topic reaches the reply API with a valid id. The adapter's illegal branch is removed and replaced with a defensive top-level fallback (strictly better than a hard send failure), which stays unreached in normal operation once anchors are threaded through.

Related Issue

No existing issue found. Searched open/closed issues and PRs for thread_id receive_id, field validation failed, feishu topic send, async delegation send.

Fixes #

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

End-to-end anchor threading so a stable om_ reply id reaches every topic send, plus removal of the invalid receive_id_type=thread_id create branch.

Routing layer — populate the anchor and carry it to synthetic events:

  • plugins/platforms/feishu/adapter.py (inbound): extract root_id, set thread_reply_anchor = root_id or message_id, and pass it as message_id= to build_source. This populates source.message_id (previously always None for Feishu), which flows into _SESSION_MESSAGE_ID and is captured by background watchers at spawn time.
  • tools/approval.py: add get_current_session_message_id() helper mirroring get_current_session_key(), reading HERMES_SESSION_MESSAGE_ID.
  • tools/delegate_tool.py: capture _message_id before detaching onto the daemon worker thread (mirrors _session_key capture) and pass it to dispatch_async_delegation_batch(message_id=...).
  • tools/async_delegation.py: both dispatch_async_delegation and dispatch_async_delegation_batch accept message_id: str = "", store it on the record, and carry it onto the completion event ("message_id": ...).
  • gateway/run.py (terminal text notifications): pass reply_to=message_id on the two adapter.send() call sites (completion + running-update) so topic-capable platforms route via the reply API.
  • gateway/run.py (_inject_watch_notification): fall back to source.message_id (persisted session origin) when the event lacks an explicit message_id — covers in-flight background processes dispatched before the anchor was captured.

Adapter — remove the invalid branch:

  • plugins/platforms/feishu/adapter.py (_send_raw_message): delete the receive_id_type="thread_id" create branch. When a threaded send reaches the no-anchor point anyway, fall back to a top-level chat_id create with a logger.warning (thread context lost, but no hard failure). The _feishu_send_with_retry 230011/231003 topic guard is untouched (preserves PR fix(gateway): stream consumer first message drops thread context #13077's intentional fail-closed for revoked-root cases).

How to Test

Unit tests (hermetic):

scripts/run_tests.sh tests/gateway/test_stream_consumer_thread_routing.py tests/tools/test_async_delegation.py tests/gateway/test_feishu.py

The old test that froze the bug is replaced. test_create_uses_thread_id_when_available (asserted receive_id_type == "thread_id") is replaced by:

  • test_thread_send_with_anchor_uses_reply_api — reply API + reply_in_thread=True
  • test_thread_send_with_metadata_reply_to_uses_reply_api — metadata-anchor path
  • test_thread_send_without_anchor_falls_back_to_chat_create — asserts receive_id_type != "thread_id" and == "chat_id"

New tests added:

  • tests/gateway/test_feishu.py: test_inbound_thread_message_populates_source_message_id_anchor (topic root → source.message_id), test_inbound_thread_seed_message_populates_source_message_id_self (seed message → self as root).
  • tests/tools/test_async_delegation.py: test_completion_event_carries_message_id_single, test_completion_event_carries_message_id_batch, test_completion_event_message_id_defaults_empty (invariant: key always present, empty when no anchor).

End-to-end reproduction (the original failure scenario):

  1. In a Feishu group topic, message the agent asking it to dispatch a subagent (which internally calls delegate_task(background=true)).
  2. Continue chatting while the subagent runs in the background.
  3. When the async-delegation completion re-enters the session, the agent's reply must land in the topic (not fail, not land in the main chat).

Before the fix, the gateway log showed:

WARNING gateway.platforms.base: [Feishu] Send failed: [99992402] field validation failed — trying plain-text fallback
ERROR gateway.platforms.base: [Feishu] Fallback send also failed: [99992402] field validation failed

After the fix: no [99992402]; the reply API is used and the message appears in the topic.

Cross-platform regression (other IM platforms unaffected):

scripts/run_tests.sh tests/gateway/test_stream_consumer_thread_routing.py

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Linux (Feishu gateway, end-to-end async-delegation scenario)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A (no user-facing config/schema change; inline docstrings added)
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A (no config keys added)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A (no architecture/workflow change)
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A (changes are platform-neutral; scripts/check-windows-footguns.py --diff reports 0 issues)
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A (no tool schema changes; delegate_task signature unchanged at the model-facing level)

Screenshots / Logs

Before (async-delegation completion reply fails — IDs redacted):

WARNING gateway.platforms.base: [Feishu] Send failed: [99992402] field validation failed — trying plain-text fallback
ERROR gateway.platforms.base: [Feishu] Fallback send also failed: [99992402] field validation failed

After (reply routes into the topic via the reply API — no [99992402]).

@alt-glitch alt-glitch added type/bug Something isn't working platform/feishu Feishu / Lark adapter comp/gateway Gateway runner, session dispatch, delivery tool/delegate Subagent delegation sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages P3 Low — cosmetic, nice to have labels Jul 6, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Competing with open #33584 and #37322 for the same Feishu [99992402] topic-routing bug (open issue #33568) via different approaches. Related, not a duplicate -- a maintainer should pick the canonical fix. Note this PR also bundles delegate/async-delegation send-path changes (tools/async_delegation.py, tools/delegate_tool.py, gateway/run.py) that trigger the anchorless synthetic send.

@DarkMagicCK

Copy link
Copy Markdown
Author

This was generated by AI during triage.

Competing with open #33584 and #37322 for the same Feishu [99992402] topic-routing bug (open issue #33568) via different approaches. Related, not a duplicate -- a maintainer should pick the canonical fix. Note this PR also bundles delegate/async-delegation send-path changes (tools/async_delegation.py, tools/delegate_tool.py, gateway/run.py) that trigger the anchorless synthetic send.

Thanks for the triage note. To help a maintainer pick the canonical fix, here's why this PR necessarily touches delegate_task / async_delegation / run.py, and why it's not detachable from the adapter change.

The anchor has no source without the routing-layer changes

Every existing PR in this cluster removes the invalid receive_id_type=thread_id branch — but that only stops the 99992402 error; it doesn't make the reply land in the topic. Landing in a Feishu topic requires the reply API with a real om_ anchor, and that anchor has to come from somewhere. For synthetic events (async-delegation completions, terminal background notifications), the anchor is None on current main, and no existing PR populates it:

Anchor source #33584 #37322 #55067 This PR
event.message_id (synthetic) returns it, but it's None n/a (queries API at runtime) n/a populated via completion event
source.message_id (session origin) untouched (None for Feishu) untouched reads it, but Feishu inbound never set it set on Feishu inbound
_thread_metadata_for_source Feishu branch no no adds it, but anchor = reply_to or source.message_id = None works once A+B populate the inputs

Traced end-to-end with #33584 + #55067 merged (without this PR): async-delegation completion → event.message_id=None_reply_anchor_for_event returns None_thread_metadata_for_source Feishu branch: anchor = None or None = None → metadata has no reply_to_message_id → adapter degrades to chat_id create → reply lands in the main chat, not the topic. The 99992402 is gone, but the feature (reply in topic) is still broken.

Why the delegate/async-delegation/run.py changes are necessary, not bundled

The maintainer note flags that this PR "also bundles delegate/async-delegation send-path changes." They're not a separate concern — they're the other half of the same fix:

  • async_delegation.py / delegate_tool.py: the daemon worker thread doesn't carry contextvars, so message_id must be captured before dispatch and carried onto the completion event — exactly mirroring how session_key is already captured (delegate_tool.py:2798). Without this, the completion event has session_key but no message_id, and the synthetic re-entry event has no anchor. The new message_id: str = "" param defaults to empty — zero breakage for every existing caller (CLI, cron, other platforms).
  • run.py (terminal notifications): the terminal background adapter.send() calls construct {"thread_id": thread_id} metadata directly and never go through _thread_metadata_for_source — so fix(feishu): media/file sends in topic groups fail with 99992402 (invalid receive_id_type=thread_id) #55067's metadata fix doesn't reach this path. Passing reply_to=message_id here is the only way the terminal notification gets an anchor. For platforms that ignore reply_to (Telegram topics route by thread metadata), this is a no-op; only Feishu uses it to enter the reply API path.

The minimal-variant question

A truly minimal variant (only Feishu inbound source.message_id + adapter branch removal) would fix the async-delegation path only if #55067 is also merged (it supplies the _thread_metadata_for_source Feishu branch that reads source.message_id). It would not fix the terminal background notification path at all (that path bypasses _thread_metadata_for_source). So the minimal variant is both dependent on another PR and incomplete. The full PR is self-contained and covers all anchorless synthetic-send paths in one change — which is what "end-to-end anchor threading" means.

How this relates to #37322's approach

#37322 resolves the anchor at adapter-send time by querying im.v1.message.list for the thread root. That works for any path (including synthetic events) without routing-layer changes, but at the cost of an extra API call per anchorless send and pushing anchor-resolution into the adapter (against the narrow-waist principle — the routing layer is where session context lives). This PR resolves the anchor where it originates (inbound → session env → daemon capture → completion event), so the adapter receives a ready anchor and doesn't need to call back into the Feishu API.

Happy to narrow scope or adjust if a maintainer prefers a different split — but the routing-layer changes are what make the fix actually land replies in the topic, not just suppress the error.

@DarkMagicCK
DarkMagicCK force-pushed the fix/feishu-thread-routing-invalid-receive-id branch from 22414b0 to 587379d Compare July 9, 2026 05:17
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for tracing the synthetic-send path end to end. The premise remains present on current main: plugins/platforms/feishu/adapter.py:4627-4655 sends an anchorless threaded message through message.create(... receive_id_type="thread_id"); the current regression test still asserts that behavior at tests/gateway/test_stream_consumer_thread_routing.py:207-240.

The propagation changes address verified gaps: inbound Feishu currently omits message_id from build_source() at plugins/platforms/feishu/adapter.py:3286-3295, detached delegation dispatch does not preserve a message id at tools/delegate_tool.py:2795-2907, and terminal watcher text sends omit reply_to at gateway/run.py:15847-15852 and gateway/run.py:15977-15982.

No blocking defect was identified in the submitted diff. GitHub reports the PR as mergeable; the patch is a high-salvage candidate rather than stale work.

This is an automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state 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 15, 2026
@alt-glitch alt-glitch added P2 Medium — degraded but workaround exists comp/plugins Plugin system and bundled plugins and removed P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 16, 2026
…d receive_id

Async-delegation completions and terminal background notifications re-enter the
originating session as synthetic events with no om_ reply anchor. The Feishu
adapter's fallback branch then sent these via receive_id_type=thread_id on the
create-message API, which the Feishu server rejects with
[99992402] field validation failed — thread_id is not a valid receive_id_type
(only open_id/union_id/user_id/email/chat_id are). A message can only land in
a topic through the reply API (reply_in_thread=true) against a real om_ id.

The broken branch was added in NousResearch#13077 (ff14666) and its regression test was a
pure mock that only asserted the request was shaped as thread_id, never
exercising the real API — a green-mock-hides-integration-bug case.

Root fix — anchor everything on a stable om_ id threaded end-to-end:
- Feishu inbound: populate source.message_id with the topic root (om_),
  so it flows into _SESSION_MESSAGE_ID and is captured by background watchers.
- delegate_task(background=true): capture message_id before detaching onto the
  daemon worker thread (mirrors session_key capture) and carry it onto the
  completion event.
- terminal text notifications: pass reply_to=message_id for thread routing.
- _inject_watch_notification: fall back to the persisted session origin's
  message_id when the event lacks one.
- Feishu adapter: remove the illegal receive_id_type=thread_id create branch;
  fall back to a top-level chat create with a warning when no anchor is
  available (strictly better than a hard send failure, and unreachable in
  normal operation once the routing layer populates anchors).

Tests:
- Replace the test that asserted receive_id_type=thread_id (it was freezing the
  bug) with reply-API-contract assertions + a no-anchor top-level fallback case.
- Add Feishu inbound source.message_id tests (topic root + seed message).
- Add async-delegation message_id propagation tests (single, batch, default).
@DarkMagicCK
DarkMagicCK force-pushed the fix/feishu-thread-routing-invalid-receive-id branch from 587379d to b1efd65 Compare August 20, 2026 08:17
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 comp/plugins Plugin system and bundled plugins 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 tool/delegate Subagent delegation type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants