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
- 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
Open PRs0
Fixes on record
-
#28354 fix(mattermost): resolve thread root_id properly for CRT replies (#28251)
files:
gateway/platforms/mattermost.py,gateway/run.pyShow 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
files:
plugins/platforms/mattermost/adapter.py,scripts/release.py,tests/gateway/test_mattermost.pyShow 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
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.00means 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