Mattermost gateway thread reply routing bugs

NousResearch/hermes-agent · cluster 12063 · gateway

Data as of

Nothing open on GitHub. Fix merged in #28354. Check it covers your case.

The short answer

0open issues
0open PRs
11items grouped
Known?
Yes. 11 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #28354. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #28354 fix(mattermost): resolve thread root_id properly for CRT replies (#28251)

    merged to main

    files: gateway/platforms/mattermost.py, gateway/run.py

    Show the diff
    • gateway/platforms/mattermost.py
      @@ -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,
      @@ -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:
      @@ -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:
      @@ -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
      @@ -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

    files: plugins/platforms/mattermost/adapter.py, scripts/release.py, tests/gateway/test_mattermost.py

    Show the diff
    • plugins/platforms/mattermost/adapter.py
      @@ -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
      @@ -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
      @@ -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,
      @@ -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."""

6 PRs closed without merging

From the close comments: done elsewhere 2 · merged via another change 2 · duplicate 1 · superseded by another PR 1

  • #12076 fix(mattermost): honor metadata.thread_id in send() so delivery-path replies land in-thread (#12063) · duplicate → #12096
  • #12096 fix(mattermost): honor metadata.thread_id for thread replies · done elsewhere → 5a0e0d3
  • #25187 fix(gateway): Mattermost Thread and Command Handling Fixes · merged via another change → 06161c6, 1197d2b, #59127, #20874
  • #28012 fix(mattermost): use thread root_id from metadata for CRT Thread replies · done elsewhere → 06161c6, #28354, #28192, #28251
  • #28192 fix(mattermost): use thread root_id from metadata for CRT thread replies · merged via another change → 06161c6, #28251, #28354
Show 1 more
  • #41947 fix(mattermost): resolve /approve and /deny failing in thread mode · superseded by another PR
Closed issues · 3
  • Closed#12063Mattermost adapter ignores metadata.thread_id, so gateway thread replies post to channel root
  • Closed#25181[Bug]: Mattermost gateway threading doesn't work after first reply.
  • Closed#28005bug(mattermost): CRT Thread replies fail with 400 Invalid RootId when using reply_to as root_id
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#28354, #59127
Closed issues
3
Attempts
6 PRs closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.