{"repo":"NousResearch/hermes-agent","public_id":"12063","display_label":"Mattermost gateway thread reply routing bugs","summary":"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.","subsystem":"gateway","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28354},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-05T21:41:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15854545454545452},"fix_edges":[{"issue_number":20649,"pr_number":59127,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25181,"pr_number":59127,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25184,"pr_number":59127,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28005,"pr_number":28354,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":12063,"title":"Mattermost adapter ignores metadata.thread_id, so gateway thread replies post to channel root","url":null,"state":"closed"},{"kind":"issue","number":25181,"title":"[Bug]: Mattermost gateway threading doesn't work after first reply.","url":null,"state":"closed"},{"kind":"issue","number":28005,"title":"bug(mattermost): CRT Thread replies fail with 400 Invalid RootId when using reply_to as root_id","url":null,"state":"closed"},{"kind":"pr","number":12076,"title":"fix(mattermost): honor metadata.thread_id in send() so delivery-path replies land in-thread (#12063)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12096","number":12096}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":12096,"title":"fix(mattermost): honor metadata.thread_id for thread replies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5a0e0d35b94fefae4ff6463c24f53e348f4679e6","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":25187,"title":"fix(gateway): Mattermost Thread and Command Handling Fixes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"06161c6ed8d3b878e0076f0eba244de8f3208bc8","number":0},{"kind":"commit","value":"1197d2bc966727194b52fb227c805793e7327a15","number":0},{"kind":"pr","value":"#59127","number":59127},{"kind":"pr","value":"#20874","number":20874}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28012,"title":"fix(mattermost): use thread root_id from metadata for CRT Thread replies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"06161c6ed8d3b878e0076f0eba244de8f3208bc8","number":0},{"kind":"pr","value":"#28354","number":28354},{"kind":"pr","value":"#28192","number":28192},{"kind":"pr","value":"#28251","number":28251},{"kind":"pr","value":"#28354","number":28354}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":28192,"title":"fix(mattermost): use thread root_id from metadata for CRT thread replies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"06161c6ed","number":0},{"kind":"pr","value":"#28251","number":28251},{"kind":"pr","value":"#28354","number":28354}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28354,"title":"fix(mattermost): resolve thread root_id properly for CRT replies (#28251)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/mattermost.py","gateway/run.py"],"fix_hunks":[{"file":"gateway/platforms/mattermost.py","hunks":["@@ -249,6 +249,23 @@ async def disconnect(self) -> None:\n \n         logger.info(\"Mattermost: disconnected\")\n \n+\n+    async def _resolve_root_id(self, post_id: str) -> str:\n+        \"\"\"Resolve a post_id to the thread root_id for Mattermost.\n+\n+        Mattermost requires root_id to be the *root* post of a thread.\n+        If the post is a reply (has its own root_id), we must use that\n+        root_id instead.  Using a reply's own ID as root_id causes\n+        \"Invalid RootId parameter\" errors.\n+        \"\"\"\n+        if not post_id:\n+            return post_id\n+        # Check if this post has a root_id (meaning it's a reply)\n+        data = await self._api_get(f\"posts/{post_id}\")\n+        if data and data.get(\"root_id\"):\n+            return data[\"root_id\"]\n+        return post_id\n+\n     async def send(\n         self,\n         chat_id: str,","@@ -271,7 +288,10 @@ async def send(\n             }\n             # Thread support: reply_to is the root post ID.\n             if reply_to and self._reply_mode == \"thread\":\n-                payload[\"root_id\"] = reply_to\n+                # Ensure root_id points to the thread root, not a reply.\n+                # Mattermost rejects non-root post IDs as root_id.\n+                resolved_root = await self._resolve_root_id(reply_to)\n+                payload[\"root_id\"] = resolved_root\n \n             data = await self._api_post(\"posts\", payload)\n             if not data or \"id\" not in data:","@@ -451,7 +471,7 @@ async def _send_url_as_file(\n             \"file_ids\": [file_id],\n         }\n         if reply_to and self._reply_mode == \"thread\":\n-            payload[\"root_id\"] = reply_to\n+            payload[\"root_id\"] = await self._resolve_root_id(reply_to)\n \n         data = await self._api_post(\"posts\", payload)\n         if not data or \"id\" not in data:","@@ -490,7 +510,7 @@ async def _send_local_file(\n             \"file_ids\": [file_id],\n         }\n         if reply_to and self._reply_mode == \"thread\":\n-            payload[\"root_id\"] = reply_to\n+            payload[\"root_id\"] = await self._resolve_root_id(reply_to)\n \n         data = await self._api_post(\"posts\", payload)\n         if not data or \"id\" not in data:"]},{"file":"gateway/run.py","hunks":["@@ -15063,7 +15063,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n         ) if _progress_thread_id else None\n         _progress_reply_to = (\n             event_message_id\n-            if source.platform == Platform.FEISHU and source.thread_id and event_message_id\n+            if source.platform in (Platform.FEISHU, Platform.MATTERMOST) and source.thread_id and event_message_id\n             else None\n         )\n \n"]}]},{"kind":"pr","number":41947,"title":"fix(mattermost): resolve /approve and /deny failing in thread mode","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":59127,"title":"fix(mattermost): accept leading-space slash commands","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/mattermost/adapter.py","scripts/release.py","tests/gateway/test_mattermost.py"],"fix_hunks":[{"file":"plugins/platforms/mattermost/adapter.py","hunks":["@@ -878,6 +878,8 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None:\n         # Determine message type.\n         file_ids = post.get(\"file_ids\") or []\n         msg_type = MessageType.TEXT\n+        if message_text[:1].isspace() and message_text.lstrip().startswith(\"/\"):\n+            message_text = message_text.lstrip()\n         if message_text.startswith(\"/\"):\n             msg_type = MessageType.COMMAND\n "]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"l0h1nth\",  # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184)\n     \"[redacted:email]\": \"devatnull\",  # PR #58704 salvage (whatsapp: native Baileys polls, clarify-as-poll, location pins, structured quoted replies, PTT/audio split, bridge_helpers extraction)\n     \"[redacted:email]\": \"tjp2021\",  # PR #4097 salvage (copilot: per-turn x-initiator header so user prompts bill as premium requests; #3040)\n     \"[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)"]},{"file":"tests/gateway/test_mattermost.py","hunks":["@@ -6,6 +6,7 @@\n from unittest.mock import MagicMock, patch, AsyncMock\n \n from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import MessageType\n from gateway.run import (\n     _resolve_gateway_display_bool,\n     _resolve_progress_thread_id,","@@ -588,6 +589,55 @@ async def test_channel_type_mapping(self):\n         msg_event = self.adapter.handle_message.call_args[0][0]\n         assert msg_event.source.chat_type == \"dm\"\n \n+    @pytest.mark.asyncio\n+    async def test_leading_space_slash_command_is_command(self):\n+        \"\"\"Mattermost mobile suggests leading-space slash commands.\"\"\"\n+        post_data = {\n+            \"id\": \"post_cmd\",\n+            \"user_id\": \"user_123\",\n+            \"channel_id\": \"chan_dm\",\n+            \"message\": \" /new\",\n+        }\n+        event = {\n+            \"event\": \"posted\",\n+            \"data\": {\n+                \"post\": json.dumps(post_data),\n+                \"channel_type\": \"D\",\n+                \"sender_name\": \"@bob\",\n+            },\n+        }\n+\n+        await self.adapter._handle_ws_event(event)\n+        assert self.adapter.handle_message.called\n+        msg_event = self.adapter.handle_message.call_args[0][0]\n+        assert msg_event.text == \"/new\"\n+        assert msg_event.message_type is MessageType.COMMAND\n+        assert msg_event.get_command() == \"new\"\n+\n+    @pytest.mark.asyncio\n+    async def test_leading_space_normal_text_is_preserved(self):\n+        \"\"\"Only command-shaped mobile messages should be normalized.\"\"\"\n+        post_data = {\n+            \"id\": \"post_text\",\n+            \"user_id\": \"user_123\",\n+            \"channel_id\": \"chan_dm\",\n+            \"message\": \" hello\",\n+        }\n+        event = {\n+            \"event\": \"posted\",\n+            \"data\": {\n+                \"post\": json.dumps(post_data),\n+                \"channel_type\": \"D\",\n+                \"sender_name\": \"@bob\",\n+            },\n+        }\n+\n+        await self.adapter._handle_ws_event(event)\n+        assert self.adapter.handle_message.called\n+        msg_event = self.adapter.handle_message.call_args[0][0]\n+        assert msg_event.text == \" hello\"\n+        assert msg_event.message_type is MessageType.TEXT\n+\n     @pytest.mark.asyncio\n     async def test_thread_id_from_root_id(self):\n         \"\"\"Post with root_id should have thread_id set.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}