Skip to content

fix(mattermost): honor metadata.thread_id in send() so delivery-path replies land in-thread (#12063)#12076

Closed
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/mattermost-thread-id-metadata
Closed

fix(mattermost): honor metadata.thread_id in send() so delivery-path replies land in-thread (#12063)#12076
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/mattermost-thread-id-metadata

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #12063.

MattermostAdapter.send() accepted a metadata parameter but never read it — root_id was only set when callers passed the explicit reply_to argument. The gateway delivery path at gateway/delivery.py:249-252 injects thread context via metadata["thread_id"] instead:

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 silently posted top-level to the channel root. The reporter's minimal repro:

await adapter.send('chan', 'hello', metadata={'thread_id': 'root123'})
# Observed payload on 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 any caller that already uses reply_to.
  • Type safety: non-string / empty metadata["thread_id"] is rejected defensively (Mattermost's POST /posts would otherwise 400 on a non-string root_id).
  • Multi-chunk: the resolver is hoisted out of the chunk loop so long messages carry the same root_id on every chunk.

Precedence / compat truth 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 string "thread" no root_id (bug) root_id=metadata.thread_id
None valid string "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 but route thread context through _send_remote_file (mattermost.py:471) / _send_local_file (:509) using the same reply_to-only pattern. Those helpers have the analogous gap, but the reporter specifically scoped #12063 to send() 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 by SessionSource.thread_id across the delivery-target abstraction. Grep-verified: no other writer in the codebase.

Q: Could a malformed metadata dict crash send()?
No — metadata and isinstance(metadata, dict) guards against None and non-dict values, and isinstance(md_thread, str) and md_thread rejects None, empty strings, ints, lists, and nested dicts. Covered by test_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::TestMattermostSend gains 6 new tests covering the full precedence table:

  • test_send_metadata_thread_id_sets_root_id — the 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.

How to test

  1. Focused suite:

    source venv/bin/activate
    python -m pytest tests/gateway/test_mattermost.py -q
    

    49 passed (43 existing + 6 new).

  2. Verify tests catch the bug on origin/main:

    git stash push gateway/platforms/mattermost.py
    python -m pytest tests/gateway/test_mattermost.py::TestMattermostSend -v
    

    → 2 fail with KeyError: 'root_id'. Restore with git stash pop.

  3. CI-aligned broad suite — baseline failure set intact, none in gateway/platforms/mattermost.py or tests/gateway/test_mattermost.py.

Tested on: macOS 15 (Darwin 25.5.0), Python 3.11.15.

Related Issue

Fixes #12063

Type of Change

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

Changes Made

  • gateway/platforms/mattermost.py: send() now resolves root_id from both reply_to (existing) and metadata["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 — confirmed send_metadata["thread_id"] injection contract. Unchanged.
  • _send_remote_file / _send_local_file — noted in "Narrow scope" above.
  • Other adapters (Slack, Matrix, Telegram, Discord) — out of scope; each has its own threading semantics.

Checklist

Code

Documentation & Housekeeping

  • No docs changes needed — internal adapter behavior, no config-key or public API changes
  • No cross-platform impact — Mattermost adapter only
  • No tool descriptions/schemas touched

Notes for reviewers

  • No standalone lint command is defined; CI runs python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto.
  • No hermes_cli/** changes — the Nix workflow should not trigger.

…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>
Copilot AI review requested due to automatic review settings April 18, 2026 08:58

Copilot AI 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.

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 resolve root_id from reply_to (preferred) or metadata["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.

@briandevans

Copy link
Copy Markdown
Contributor Author

CI audit — all 9 test failures are pre-existing baselines, none in touched code:

Direct baseline on origin/main (I ran each on a clean checkout):

  • tests/tools/test_browser_camofox_state.py::test_config_version_matches_current_schemaassert 19 == 18, camofox schema version mismatch on main.
  • tests/hermes_cli/test_web_server.py::test_no_single_field_categoriesCategory 'code_execution' has only 1 field(s); schema drift from the recent feat(execute_code): add project/strict execution modes landing.

Ordering-dependent flakes (pass in isolation on clean origin/main):

  • tests/tools/test_send_message_tool.py × 4 — source_label='cli' vs '' — module-level default that flips depending on what imported first in the worker.
  • tests/tools/test_skills_tool.py × 2 — KeyError: 'gateway_setup_hint' / assert True is False — same ordering class.
  • tests/tools/test_tts_mistral.py::test_telegram_produces_ogg_and_voice_compatible.mp3 vs .ogg — converter import order.

Reproduction on clean main:

$ python -m pytest tests/tools/test_browser_camofox_state.py::TestCamofoxConfigDefaults::test_config_version_matches_current_schema tests/hermes_cli/test_web_server.py::TestBuildSchemaFromConfig::test_no_single_field_categories -v
2 failed in 2.17s

$ python -m pytest tests/tools/test_tts_mistral.py::TestMistralTtsOpus::test_telegram_produces_ogg_and_voice_compatible tests/tools/test_skills_tool.py::TestSkillViewSecureSetupOnLoad::test_gateway_load_returns_guidance_without_secret_capture tests/tools/test_send_message_tool.py::TestSendMessageTool::test_cron_different_target_still_sends -v
3 passed

None of these touch gateway/platforms/mattermost.py or tests/gateway/test_mattermost.py. The 6 new regression tests in this PR all pass locally (49 total in the file).

@teknium1

Copy link
Copy Markdown
Contributor

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.

@teknium1 teknium1 closed this Apr 21, 2026
@briandevans

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mattermost adapter ignores metadata.thread_id, so gateway thread replies post to channel root

3 participants