# NousResearch/hermes-agent · Mattermost gateway thread reply routing bugs

## Open now

- Nothing open on GitHub.

Multiple bug reports and fix attempts addressing Mattermost's failure to correctly route replies into threads, primarily involving improper use of root_id and metadata.thread_id. Also includes a related fix for slash command handling in thread contexts.

## TL;DR

- 11 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 2 merged PR(s), 6 PR(s) closed without merging.
- A fix was merged to main in PR #28354. Verify it addresses your case.
- **Recommended:** read PR #28354 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.16`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #12063: Mattermost adapter ignores metadata.thread_id, so gateway thread replies post to channel root
- #25181: \[Bug\]: Mattermost gateway threading doesn't work after first reply.
- #28005: bug(mattermost): CRT Thread replies fail with 400 Invalid RootId when using reply_to as root_id

## Prior & competing PRs

- **#12096: fix(mattermost): honor metadata.thread_id for thread replies**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `5a0e0d35b94fefae4ff6463c24f53e348f4679e6`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#28354: fix(mattermost): resolve thread root_id properly for CRT replies (#28251)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/mattermost.py`, `gateway/run.py`
  - fix hunks:
  - `gateway/platforms/mattermost.py`

    ```diff
    @@ -249,6 +249,23 @@ async def disconnect(self) -> None:
     
             logger.info("Mattermost: disconnected")
     
    +
    +    async def _resolve_root_id(self, post_id: str) -> str:
    +        """Resolve a post_id to the thread root_id for Mattermost.
    +
    +        Mattermost requires root_id to be the *root* post of a thread.
    +        If the post is a reply (has its own root_id), we must use that
    +        root_id instead.  Using a reply's own ID as root_id causes
    +        "Invalid RootId parameter" errors.
    +        """
    +        if not post_id:
    +            return post_id
    +        # Check if this post has a root_id (meaning it's a reply)
    +        data = await self._api_get(f"posts/{post_id}")
    +        if data and data.get("root_id"):
    +            return data["root_id"]
    +        return post_id
    +
         async def send(
             self,
             chat_id: str,
    ```

    ```diff
    @@ -271,7 +288,10 @@ async def send(
                 }
                 # Thread support: reply_to is the root post ID.
                 if reply_to and self._reply_mode == "thread":
    -                payload["root_id"] = reply_to
    +                # Ensure root_id points to the thread root, not a reply.
    +                # Mattermost rejects non-root post IDs as root_id.
    +                resolved_root = await self._resolve_root_id(reply_to)
    +                payload["root_id"] = resolved_root
     
                 data = await self._api_post("posts", payload)
                 if not data or "id" not in data:
    ```

    ```diff
    @@ -451,7 +471,7 @@ async def _send_url_as_file(
                 "file_ids": [file_id],
             }
             if reply_to and self._reply_mode == "thread":
    -            payload["root_id"] = reply_to
    +            payload["root_id"] = await self._resolve_root_id(reply_to)
     
             data = await self._api_post("posts", payload)
             if not data or "id" not in data:
    ```

    ```diff
    @@ -490,7 +510,7 @@ async def _send_local_file(
                 "file_ids": [file_id],
             }
             if reply_to and self._reply_mode == "thread":
    -            payload["root_id"] = reply_to
    +            payload["root_id"] = await self._resolve_root_id(reply_to)
     
             data = await self._api_post("posts", payload)
             if not data or "id" not in data:
    ```
  - `gateway/run.py`

    ```diff
    @@ -15063,7 +15063,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
             ) if _progress_thread_id else None
             _progress_reply_to = (
                 event_message_id
    -            if source.platform == Platform.FEISHU and source.thread_id and event_message_id
    +            if source.platform in (Platform.FEISHU, Platform.MATTERMOST) and source.thread_id and event_message_id
                 else None
             )
     
    ```
- **#59127: fix(mattermost): accept leading-space slash commands**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/platforms/mattermost/adapter.py`, `scripts/release.py`, `tests/gateway/test_mattermost.py`
  - fix hunks:
  - `plugins/platforms/mattermost/adapter.py`

    ```diff
    @@ -878,6 +878,8 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None:
             # Determine message type.
             file_ids = post.get("file_ids") or []
             msg_type = MessageType.TEXT
    +        if message_text[:1].isspace() and message_text.lstrip().startswith("/"):
    +            message_text = message_text.lstrip()
             if message_text.startswith("/"):
                 msg_type = MessageType.COMMAND
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "l0h1nth",  # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184)
         "[redacted:email]": "devatnull",  # PR #58704 salvage (whatsapp: native Baileys polls, clarify-as-poll, location pins, structured quoted replies, PTT/audio split, bridge_helpers extraction)
         "[redacted:email]": "tjp2021",  # PR #4097 salvage (copilot: per-turn x-initiator header so user prompts bill as premium requests; #3040)
         "[redacted:email]": "kevinrajaram",  # PR #3850 salvage (gateway: add POSIX system dirs to PATH so launchctl/systemctl resolve under UV's minimal-PATH bundled Python; #3849)
    ```
  - `tests/gateway/test_mattermost.py`

    ```diff
    @@ -6,6 +6,7 @@
     from unittest.mock import MagicMock, patch, AsyncMock
     
     from gateway.config import Platform, PlatformConfig
    +from gateway.platforms.base import MessageType
     from gateway.run import (
         _resolve_gateway_display_bool,
         _resolve_progress_thread_id,
    ```

    ```diff
    @@ -588,6 +589,55 @@ async def test_channel_type_mapping(self):
             msg_event = self.adapter.handle_message.call_args[0][0]
             assert msg_event.source.chat_type == "dm"
     
    +    @pytest.mark.asyncio
    +    async def test_leading_space_slash_command_is_command(self):
    +        """Mattermost mobile suggests leading-space slash commands."""
    +        post_data = {
    +            "id": "post_cmd",
    +            "user_id": "user_123",
    +            "channel_id": "chan_dm",
    +            "message": " /new",
    +        }
    +        event = {
    +            "event": "posted",
    +            "data": {
    +                "post": json.dumps(post_data),
    +                "channel_type": "D",
    +                "sender_name": "@bob",
    +            },
    +        }
    +
    +        await self.adapter._handle_ws_event(event)
    +        assert self.adapter.handle_message.called
    +        msg_event = self.adapter.handle_message.call_args[0][0]
    +        assert msg_event.text == "/new"
    +        assert msg_event.message_type is MessageType.COMMAND
    +        assert msg_event.get_command() == "new"
    +
    +    @pytest.mark.asyncio
    +    async def test_leading_space_normal_text_is_preserved(self):
    +        """Only command-shaped mobile messages should be normalized."""
    +        post_data = {
    +            "id": "post_text",
    +            "user_id": "user_123",
    +            "channel_id": "chan_dm",
    +            "message": " hello",
    +        }
    +        event = {
    +            "event": "posted",
    +            "data": {
    +                "post": json.dumps(post_data),
    +                "channel_type": "D",
    +                "sender_name": "@bob",
    +            },
    +        }
    +
    +        await self.adapter._handle_ws_event(event)
    +        assert self.adapter.handle_message.called
    +        msg_event = self.adapter.handle_message.call_args[0][0]
    +        assert msg_event.text == " hello"
    +        assert msg_event.message_type is MessageType.TEXT
    +
         @pytest.mark.asyncio
         async def test_thread_id_from_root_id(self):
             """Post with root_id should have thread_id set."""
    ```
- **#12076: fix(mattermost): honor metadata.thread_id in send() so delivery-path replies land in-thread (#12063)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#12096`
- **#25187: fix(gateway): Mattermost Thread and Command Handling Fixes**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `06161c6ed8d3b878e0076f0eba244de8f3208bc8`, `1197d2bc966727194b52fb227c805793e7327a15`, `#59127`, `#20874`
- **#28012: fix(mattermost): use thread root_id from metadata for CRT Thread replies**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `06161c6ed8d3b878e0076f0eba244de8f3208bc8`, `#28354`, `#28192`, `#28251`, `#28354`
- **#28192: fix(mattermost): use thread root_id from metadata for CRT thread replies**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `06161c6ed`, `#28251`, `#28354`
- **#41947: fix(mattermost): resolve /approve and /deny failing in thread mode**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`

## Fix status

- Merged to main in PR #28354 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-05`.
- Subsystem: `gateway`
