fix(mattermost): honor metadata.thread_id in send() so delivery-path replies land in-thread (#12063)#12076
Conversation
…replies land in-thread (NousResearch#12063) ``MattermostAdapter.send()`` accepted a ``metadata`` parameter but never read it — it only set ``root_id`` when callers passed the explicit ``reply_to`` argument. The gateway delivery path (``gateway/delivery.py:249-252``) injects thread context via ``metadata["thread_id"]`` rather than ``reply_to``: send_metadata = dict(metadata or {}) if target.thread_id and "thread_id" not in send_metadata: send_metadata["thread_id"] = target.thread_id return await adapter.send(target.chat_id, content, metadata=send_metadata or None) So with ``reply_mode='thread'`` and a valid ``target.thread_id``, Mattermost still silently posted top-level to the channel root. Reporter's minimal repro (issue NousResearch#12063): await adapter.send('chan', 'hello', metadata={'thread_id': 'root123'}) Observed payload on ``origin/main``: {'channel_id': 'chan', 'message': 'hello'} ``root_id`` missing. Fix --- Resolve the Mattermost ``root_id`` once per call from both sources: if self._reply_mode == "thread": if reply_to: root_post_id = reply_to elif metadata and isinstance(metadata, dict): md_thread = metadata.get("thread_id") if isinstance(md_thread, str) and md_thread: root_post_id = md_thread Precedence: explicit ``reply_to`` wins when both are present — preserves the pre-fix contract for callers that already use ``reply_to``. Non-string / empty ``metadata["thread_id"]`` is rejected defensively (Mattermost's POST /posts API would otherwise 400 on a non-string ``root_id``). The resolver is hoisted out of the chunk loop so multi-chunk replies carry the same ``root_id`` on every chunk. Compat table ------------ | ``reply_to`` | ``metadata["thread_id"]`` | ``_reply_mode`` | Before | After | | --- | --- | --- | --- | --- | | set | any | "thread" | root_id=reply_to | unchanged | | set | any | "off" | no root_id | unchanged | | None | valid str | "thread" | **no root_id (bug)** | root_id=metadata.thread_id | | None | valid str | "off" | no root_id | unchanged | | None | None/"" / non-str | "thread" | no root_id | unchanged | | None | None | "thread" | no root_id | unchanged | Narrow scope — explicitly not changed ------------------------------------- ``send_image``, ``send_image_file`` and ``send_document`` all accept ``metadata`` and use the same ``reply_to``-only pattern internally (``_send_remote_file`` at mattermost.py:471, ``_send_local_file`` at :509). Those helpers have the analogous gap but the reporter specifically scoped NousResearch#12063 to ``send()`` — extending to the file- upload path is a clean follow-up issue if reviewers want it here. Text-message replies are the dominant gateway delivery path, and keeping this PR to the reported method gives reviewers a single surface to approve. Regression coverage ------------------- ``tests/gateway/test_mattermost.py::TestMattermostSend`` gains 6 new tests covering the full precedence truth table: * ``test_send_metadata_thread_id_sets_root_id`` — core regression; fails on ``origin/main`` with ``KeyError: 'root_id'``. * ``test_send_reply_to_takes_precedence_over_metadata_thread_id`` — pins the explicit-argument-wins rule. * ``test_send_metadata_thread_id_ignored_when_reply_mode_off`` — off-mode canary. * ``test_send_without_thread_metadata_no_root_id`` — no-override default canary. * ``test_send_metadata_thread_id_non_string_is_ignored`` — type safety against None / "" / int / list / dict. * ``test_send_all_chunks_carry_root_id_from_metadata`` — multi-chunk fidelity; also fails on ``origin/main``. Validation ---------- ``source venv/bin/activate && python -m pytest tests/gateway/test_mattermost.py -q`` -> 49 passed (43 existing + 6 new; 2 of the new ones fail on ``origin/main`` with the reporter's exact symptom). Broader suite: run via CI. No baseline failures are in ``gateway/platforms/mattermost.py`` or ``tests/gateway/test_mattermost.py``. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes Mattermost thread reply routing so MattermostAdapter.send() honors metadata["thread_id"] (used by the gateway delivery path) when _reply_mode == "thread", ensuring delivery-path replies land in-thread.
Changes:
- Update
MattermostAdapter.send()to resolveroot_idfromreply_to(preferred) ormetadata["thread_id"](fallback) and apply it consistently across multi-chunk sends. - Add targeted regression tests covering precedence, reply-mode behavior, invalid metadata types, and multi-chunk consistency.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
gateway/platforms/mattermost.py |
Adds root_id resolution from metadata["thread_id"] (with reply_to precedence) and applies it to all chunks in send(). |
tests/gateway/test_mattermost.py |
Adds regression tests ensuring thread routing works via metadata and remains consistent across modes and chunking. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
CI audit — all 9 Direct baseline on
Ordering-dependent flakes (pass in isolation on clean
Reproduction on clean main: None of these touch |
|
Thanks for the contribution, @briandevans! Closing this as a duplicate of #12096 (by @YangManBOBO), which targets the same fix/feature. We're consolidating on that PR for review. If you want to help push it over the line, please jump in there — or if you think your approach is better for a specific reason that isn't covered in the other PR, let us know and we can reopen. |
|
Thanks for the clear explanation @teknium1 — no problem, happy to defer to @YangManBOBO's version for consolidation. I'll drop any helpful edge-case observations on #12096 in case they're useful for strengthening the test coverage there. |
What does this PR do?
Fixes #12063.
MattermostAdapter.send()accepted ametadataparameter but never read it —root_idwas only set when callers passed the explicitreply_toargument. The gateway delivery path atgateway/delivery.py:249-252injects thread context viametadata["thread_id"]instead:So with
reply_mode='thread'and a validtarget.thread_id, Mattermost silently posted top-level to the channel root. The reporter's minimal repro:Fix
Resolve the Mattermost
root_idonce per call from both sources:reply_towins when both are present — preserves the pre-fix contract for any caller that already usesreply_to.metadata["thread_id"]is rejected defensively (Mattermost'sPOST /postswould otherwise 400 on a non-stringroot_id).root_idon every chunk.Precedence / compat truth table
reply_tometadata["thread_id"]_reply_mode"thread"root_id=reply_to"off"root_idNone"thread"root_id(bug)root_id=metadata.thread_id✓None"off"root_idNoneNone/""/ non-str"thread"root_idNoneNone"thread"root_idNarrow scope — explicitly not changed
send_image,send_image_file, andsend_documentall acceptmetadatabut route thread context through_send_remote_file(mattermost.py:471) /_send_local_file(:509) using the samereply_to-only pattern. Those helpers have the analogous gap, but the reporter specifically scoped #12063 tosend()and text-message replies are the dominant gateway delivery path. I deliberately did not sweep — extending to the file-upload path is a clean follow-up if reviewers want it bundled here. Happy to do it in this PR if that's preferred, or to open a tracking issue.Pre-empt reviewer Q&A
Q: Why not normalize at the delivery layer so Mattermost always gets
reply_to?That would push Mattermost-specific routing knowledge into
gateway/delivery.py, which is intentionally platform-agnostic. The platform adapter is the right place to reconcile its own threading semantics. Other adapters (Slack, Matrix, etc.) may handle this differently and shouldn't be forced through a shared normalizer.Q: Is
metadata["thread_id"]a stable key?Yes — it's pinned by
gateway/delivery.py:251(send_metadata["thread_id"] = target.thread_id) and bySessionSource.thread_idacross the delivery-target abstraction. Grep-verified: no other writer in the codebase.Q: Could a malformed
metadatadict crashsend()?No —
metadata and isinstance(metadata, dict)guards againstNoneand non-dict values, andisinstance(md_thread, str) and md_threadrejectsNone, empty strings, ints, lists, and nested dicts. Covered bytest_send_metadata_thread_id_non_string_is_ignored.Q: Multi-chunk handling?
Resolved once before the chunk loop, applied to every chunk. Covered by
test_send_all_chunks_carry_root_id_from_metadata.Regression coverage
tests/gateway/test_mattermost.py::TestMattermostSendgains 6 new tests covering the full precedence table:test_send_metadata_thread_id_sets_root_id— the core regression; fails onorigin/mainwithKeyError: 'root_id'.test_send_reply_to_takes_precedence_over_metadata_thread_id— pins the explicit-argument-wins rule.test_send_metadata_thread_id_ignored_when_reply_mode_off— off-mode canary.test_send_without_thread_metadata_no_root_id— no-override default canary.test_send_metadata_thread_id_non_string_is_ignored— type safety againstNone/""/ int / list / dict.test_send_all_chunks_carry_root_id_from_metadata— multi-chunk fidelity; also fails onorigin/main.How to test
Focused suite:
→ 49 passed (43 existing + 6 new).
Verify tests catch the bug on origin/main:
→ 2 fail with
KeyError: 'root_id'. Restore withgit stash pop.CI-aligned broad suite — baseline failure set intact, none in
gateway/platforms/mattermost.pyortests/gateway/test_mattermost.py.Tested on: macOS 15 (Darwin 25.5.0), Python 3.11.15.
Related Issue
Fixes #12063
Type of Change
Changes Made
gateway/platforms/mattermost.py:send()now resolvesroot_idfrom bothreply_to(existing) andmetadata["thread_id"](new), with explicit-argument precedence, defensive type checking, and multi-chunk fidelity.tests/gateway/test_mattermost.py: 6 new regression tests covering the precedence truth table.Adjacent surfaces checked
gateway/delivery.py:249-252— confirmedsend_metadata["thread_id"]injection contract. Unchanged._send_remote_file/_send_local_file— noted in "Narrow scope" above.Checklist
Code
origin/mainwithout the fix)Documentation & Housekeeping
Notes for reviewers
python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto.hermes_cli/**changes — the Nix workflow should not trigger.