{"repo":"NousResearch/hermes-agent","public_id":"48551","display_label":"Matrix DM room misclassification fixes and detection logic","summary":"Multiple bug reports and fixes addressing Matrix DM rooms being incorrectly classified as group rooms, requiring @mentions. Approaches include using m.direct account data, member count, and recording DM rooms on invite as classification signals.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51731,"merged_at":"2026-06-24T06:57:38+00:00"},"resolution":{"state":"resolved","by":"pr-51731"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T06:57:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":48551,"pr_number":51731,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-24T06:57:38+00:00"}],"members":[{"kind":"issue","number":48551,"title":"[Bug]: Matrix DM rooms with auto-set names incorrectly classified as room, require @mention","url":null,"state":"closed"},{"kind":"pr","number":48554,"title":"fix(matrix): use member_count as tiebreaker for named DM room classification","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4aa793345","number":0},{"kind":"pr","value":"#51731","number":51731},{"kind":"pr","value":"#48551","number":48551}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":48753,"title":"fix(matrix): treat m.direct as authoritative for DM classification (#48551)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4aa793345","number":0},{"kind":"pr","value":"#48551","number":48551},{"kind":"pr","value":"#51731","number":51731}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51731,"title":"fix(matrix): use member_count as DM signal for named DM rooms (#48551)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/matrix/adapter.py","tests/gateway/test_matrix.py","tests/gateway/test_matrix_project_context_isolation.py"],"fix_hunks":[{"file":"plugins/platforms/matrix/adapter.py","hunks":["@@ -3573,21 +3573,29 @@ def _state_event_value(event: Any, key: str) -> Optional[str]:\n         return None\n \n     async def _get_room_member_count(self, room_id: str) -> Optional[int]:\n+        # Tier 1: state_store (fast, cache-backed).\n         state_store = (\n             getattr(self._client, \"state_store\", None) if self._client else None\n         )\n-        if not state_store:\n-            return None\n-        try:\n-            members = await state_store.get_members(room_id)\n-        except Exception:\n-            return None\n-        if members is None:\n-            return None\n-        try:\n-            return len(members)\n-        except TypeError:\n-            return None\n+        if state_store:\n+            try:\n+                members = await state_store.get_members(room_id)\n+                if members is not None:\n+                    return len(members)\n+            except Exception:\n+                pass\n+\n+        # Tier 2: API fallback (direct server query) when the cache is empty.\n+        client = getattr(self, \"_client\", None)\n+        if client is not None and hasattr(client, \"joined_members\"):\n+            try:\n+                resp = await client.joined_members(room_id)\n+                if getattr(resp, \"members\", None) is not None:\n+                    return len(resp.members)\n+            except Exception:\n+                pass\n+\n+        return None\n \n     async def _get_room_name(self, room_id: str) -> Optional[str]:\n         if not self._client or not hasattr(self._client, \"get_state_event\"):","@@ -3674,8 +3682,23 @@ async def _resolve_room_identity(\n         member_count = await self._get_room_member_count(room_id)\n         has_explicit_name = bool(room_name)\n         is_direct = bool(self._dm_rooms.get(room_id, False))\n-        conflict = bool(is_direct and has_explicit_name)\n-        chat_type = \"dm\" if is_direct and not has_explicit_name else \"room\"\n+        # member_count is the primary DM signal: <=2 members means this is\n+        # necessarily a 1:1 conversation (or self-DM), regardless of m.direct\n+        # or room name. Most Matrix clients auto-name DM rooms (e.g.\n+        # \"Alice & Bot\"), so the old `not has_explicit_name` check\n+        # misclassified virtually all client-created DMs as rooms. Falls back\n+        # to the m.direct + name heuristic when the count is unavailable (e.g.\n+        # state_store and API query both fail). A room that grew to 3+ members\n+        # but is still in stale m.direct is correctly classified as a room.\n+        is_likely_dm = (member_count is not None and member_count <= 2) or (\n+            is_direct and not has_explicit_name\n+        )\n+        conflict = bool(\n+            is_direct\n+            and has_explicit_name\n+            and (member_count is None or member_count > 2)\n+        )\n+        chat_type = \"dm\" if is_likely_dm else \"room\"\n         display_name = room_name or canonical_alias or room_id\n \n         identity = MatrixRoomIdentity("]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -502,8 +502,9 @@ async def test_m_direct_room_is_dm(self):\n         assert await self.adapter._is_dm_room(\"!dm_room:ex.org\") is True\n \n     @pytest.mark.asyncio\n-    async def test_named_two_member_room_is_not_dm(self):\n-        \"\"\"A named two-member room must remain a room, not a DM.\"\"\"\n+    async def test_named_two_member_room_is_dm_by_member_count(self):\n+        \"\"\"A named two-member room NOT in m.direct is treated as a DM because\n+        <=2 members means it's necessarily a 1:1 conversation.\"\"\"\n         self.adapter._joined_rooms = {\"!project:ex.org\"}\n         self.adapter._dm_rooms = {}\n         self.adapter._client = MagicMock()","@@ -519,14 +520,41 @@ async def test_named_two_member_room_is_not_dm(self):\n \n         identity = await self.adapter._resolve_room_identity(\"!project:ex.org\")\n \n-        assert identity.chat_type == \"room\"\n+        assert identity.chat_type == \"dm\"\n         assert identity.display_name == \"Project Room\"\n         assert identity.joined_member_count == 2\n-        assert await self.adapter._is_dm_room(\"!project:ex.org\") is False\n+        assert await self.adapter._is_dm_room(\"!project:ex.org\") is True\n+\n+    @pytest.mark.asyncio\n+    async def test_named_two_member_dm_is_dm(self):\n+        \"\"\"A named two-member room in m.direct is a DM (not a room).\n+\n+        Most Matrix clients auto-name DM rooms (e.g. \"Alice & Bot\"), so the\n+        old `not has_explicit_name` override misclassified them as rooms.\n+        \"\"\"\n+        self.adapter._joined_rooms = {\"!named_dm:ex.org\"}\n+        self.adapter._dm_rooms = {\"!named_dm:ex.org\": True}\n+        self.adapter._client = MagicMock()\n+        self.adapter._client.get_state_event = AsyncMock(\n+            side_effect=lambda room_id, event_type: {\"name\": \"Alice & Bot\"}\n+            if event_type == \"m.room.name\"\n+            else (_ for _ in ()).throw(Exception(\"no alias\"))\n+        )\n+        self.adapter._client.state_store = MagicMock()\n+        self.adapter._client.state_store.get_members = AsyncMock(\n+            return_value=[\"@bot:ex.org\", \"@alice:ex.org\"]\n+        )\n+\n+        identity = await self.adapter._resolve_room_identity(\"!named_dm:ex.org\")\n+\n+        assert identity.chat_type == \"dm\"\n+        assert identity.conflict is False\n+        assert identity.joined_member_count == 2\n+        assert await self.adapter._is_dm_room(\"!named_dm:ex.org\") is True\n \n     @pytest.mark.asyncio\n     async def test_named_room_overrides_stale_dm_cache(self):\n-        \"\"\"Explicit room names should defeat stale/conflicting m.direct data.\"\"\"\n+        \"\"\"Explicit room names defeat stale/conflicting m.direct data when 3+ members.\"\"\"\n         self.adapter._joined_rooms = {\"!stale:ex.org\"}\n         self.adapter._dm_rooms = {\"!stale:ex.org\": True}\n         self.adapter._client = MagicMock()","@@ -536,12 +564,15 @@ async def test_named_room_overrides_stale_dm_cache(self):\n             else (_ for _ in ()).throw(Exception(\"no alias\"))\n         )\n         self.adapter._client.state_store = MagicMock()\n-        self.adapter._client.state_store.get_members = AsyncMock(return_value=[\"@bot:ex.org\", \"@alice:ex.org\"])\n+        self.adapter._client.state_store.get_members = AsyncMock(\n+            return_value=[\"@bot:ex.org\", \"@alice:ex.org\", \"@bob:ex.org\"]\n+        )\n \n         identity = await self.adapter._resolve_room_identity(\"!stale:ex.org\")\n \n         assert identity.chat_type == \"room\"\n         assert identity.conflict is True\n+        assert identity.joined_member_count == 3\n         assert await self.adapter._is_dm_room(\"!stale:ex.org\") is False\n \n     @pytest.mark.asyncio","@@ -2057,7 +2088,7 @@ def handle_sync(sync_data):\n         fake_client.join_room.assert_awaited_once()\n         assert \"!room:example.org\" in adapter._joined_rooms\n         assert len(captured) == 1\n-        assert captured[0].source.chat_type == \"group\"\n+        assert captured[0].source.chat_type == \"dm\"\n \n     @pytest.mark.asyncio\n     async def test_seconds_timestamp_is_not_treated_as_milliseconds(self):"]},{"file":"tests/gateway/test_matrix_project_context_isolation.py","hunks":["@@ -168,6 +168,9 @@ async def test_matrix_project_context_survives_sequential_messages():\n @pytest.mark.asyncio\n async def test_matrix_session_scope_auto_and_thread_preserve_synthetic_threads():\n     adapter = _make_adapter()\n+    # Override member_count to 3 so the named project room is NOT classified as\n+    # a DM (the DM fix uses member_count <= 2 as the primary DM signal).\n+    adapter._get_room_member_count = AsyncMock(return_value=3)\n     adapter._auto_thread = True\n     adapter._matrix_session_scope = \"auto\"\n     auto_source = await _source_for(adapter, PROJECT_B_ROOM_ID, \"$auto\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}