{"repo":"NousResearch/hermes-agent","public_id":"44679","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":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51731},"resolution":{"state":"resolved","by":"pr-51731,pr-54129"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T09:37:53+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285715},"fix_edges":[{"issue_number":44679,"pr_number":54129,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":48551,"pr_number":51731,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":44679,"title":"[Bug]: matrix gateway treats DM room as group and requires mention","url":null,"state":"closed"},{"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":44700,"title":"fix(matrix): record DM rooms in m.direct on invite to prevent group misclassification","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"14baeefe1d0b80e67429ffb47934acc4cd399fd1","number":0},{"kind":"pr","value":"#54129","number":54129}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":44787,"title":"fix(matrix): update m.direct on DM invite and add member-count fallback (#44679)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44700","number":44700},{"kind":"pr","value":"#44679","number":44679}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"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","implication":"superseded"},"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"]}]},{"kind":"pr","number":54129,"title":"fix(matrix): record DM rooms in m.direct on invite (#44679)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/matrix/adapter.py","tests/gateway/test_matrix_dm_invite_recording.py"],"fix_hunks":[{"file":"plugins/platforms/matrix/adapter.py","hunks":["@@ -2885,15 +2885,27 @@ async def _handle_media_message(\n         await self.handle_message(msg_event)\n \n     async def _on_invite(self, event: Any) -> None:\n-        \"\"\"Auto-join rooms when invited.\"\"\"\n+        \"\"\"Auto-join rooms when invited, recording DM rooms in m.direct.\"\"\"\n \n         room_id = str(getattr(event, \"room_id\", \"\"))\n+        content = getattr(event, \"content\", None)\n+        is_direct = bool(getattr(content, \"is_direct\", False))\n+        inviter = str(getattr(event, \"sender\", \"\"))\n \n         logger.info(\n-            \"Matrix: invited to %s — joining\",\n+            \"Matrix: invited to %s — joining (is_direct=%s)\",\n+            room_id,\n+            is_direct,\n+        )\n+        # When the invite declares this as a DM, record it in m.direct after\n+        # the (non-blocking) join completes so that _resolve_room_identity\n+        # treats it correctly even when the bot account has no prior DM\n+        # history. The join itself stays off the sync path.\n+        self._schedule_invite_join(\n             room_id,\n+            is_direct=is_direct and bool(inviter),\n+            inviter=inviter,\n         )\n-        self._schedule_invite_join(room_id)\n \n     async def _join_room_by_id(self, room_id: str) -> bool:\n         \"\"\"Join a room by ID and refresh local caches on success.\"\"\"","@@ -2913,7 +2925,13 @@ async def _join_room_by_id(self, room_id: str) -> bool:\n             logger.warning(\"Matrix: error joining %s: %s\", room_id, exc)\n             return False\n \n-    def _schedule_invite_join(self, room_id: str) -> None:\n+    def _schedule_invite_join(\n+        self,\n+        room_id: str,\n+        *,\n+        is_direct: bool = False,\n+        inviter: str = \"\",\n+    ) -> None:\n         \"\"\"Schedule an invite join without blocking sync or gateway readiness.\"\"\"\n         if not room_id or room_id in self._joined_rooms:\n             return","@@ -2923,7 +2941,13 @@ def _schedule_invite_join(self, room_id: str) -> None:\n \n         async def _join_invite() -> None:\n             try:\n-                await asyncio.wait_for(self._join_room_by_id(room_id), timeout=45.0)\n+                joined = await asyncio.wait_for(\n+                    self._join_room_by_id(room_id), timeout=45.0\n+                )\n+                # Persist the DM signal from the invite once the join lands,\n+                # so m.direct is authoritative even on a fresh bot account.\n+                if joined and is_direct and inviter:\n+                    await self._record_dm_room(room_id, inviter)\n             except asyncio.TimeoutError:\n                 logger.warning(\"Matrix: timed out joining invite %s\", room_id)\n             finally:","@@ -3779,6 +3803,47 @@ async def _refresh_dm_cache(self) -> None:\n         self._room_identities.clear()\n         self._room_identity_cached_at.clear()\n \n+    async def _record_dm_room(self, room_id: str, inviter: str) -> None:\n+        \"\"\"Persist a room as DM in m.direct account data after an invite.\n+\n+        When the bot account has never been used for DMs, ``m.direct`` is\n+        absent (404).  This method fetches the current mapping (if any),\n+        appends *room_id* under the *inviter*'s entry, and writes it back\n+        so that subsequent ``_refresh_dm_cache`` calls treat the room as a\n+        DM without requiring manual ``m.direct`` setup.\n+        \"\"\"\n+        if not self._client:\n+            return\n+\n+        dm_data: Dict[str, list] = {}\n+        try:\n+            resp = await self._client.get_account_data(\"m.direct\")\n+            if hasattr(resp, \"content\") and isinstance(resp.content, dict):\n+                dm_data = resp.content\n+            elif isinstance(resp, dict):\n+                dm_data = resp\n+        except Exception:\n+            pass  # m.direct doesn't exist yet — start fresh\n+\n+        rooms_for_user = dm_data.get(inviter, [])\n+        if not isinstance(rooms_for_user, list):\n+            rooms_for_user = []\n+        if room_id not in rooms_for_user:\n+            rooms_for_user.append(room_id)\n+            dm_data[inviter] = rooms_for_user\n+            try:\n+                await self._client.set_account_data(\"m.direct\", dm_data)\n+                logger.info(\n+                    \"Matrix: recorded %s as DM room (inviter=%s)\", room_id, inviter\n+                )\n+            except Exception as exc:\n+                logger.warning(\"Matrix: failed to update m.direct: %s\", exc)\n+\n+        # Update local cache so _resolve_room_identity sees it immediately.\n+        self._dm_rooms[room_id] = True\n+        self._room_identities.pop(room_id, None)\n+        self._room_identity_cached_at.pop(room_id, None)\n+\n     # ------------------------------------------------------------------\n     # Mention detection helpers\n     # ------------------------------------------------------------------"]},{"file":"tests/gateway/test_matrix_dm_invite_recording.py","hunks":["@@ -0,0 +1,255 @@\n+\"\"\"Tests for Matrix DM room recording on invite (issue #44679).\n+\n+When the bot's Matrix account has no ``m.direct`` account data (common for\n+accounts created solely for Hermes), DM rooms are silently treated as groups.\n+This tests the fix that records DM rooms in ``m.direct`` when the invite\n+event carries ``is_direct: true``.\n+\"\"\"\n+\n+import time\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _make_adapter(tmp_path=None):\n+    \"\"\"Create a MatrixAdapter with mocked config.\"\"\"\n+    from plugins.platforms.matrix.adapter import MatrixAdapter\n+\n+    config = PlatformConfig(\n+        enabled=True,\n+        token=\"[redacted:secret-assignment]\",\n+        extra={\n+            \"homeserver\": \"https://matrix.example.org\",\n+            \"user_id\": \"@hermes:example.org\",\n+        },\n+    )\n+    adapter = MatrixAdapter(config)\n+    adapter._text_batch_delay_seconds = 0\n+    adapter.handle_message = AsyncMock()\n+    adapter._startup_ts = time.time() - 10\n+    return adapter\n+\n+\n+def _make_invite_event(\n+    room_id=\"!dm_room:example.org\",\n+    sender=\"@alice:example.org\",\n+    is_direct=True,\n+):\n+    \"\"\"Create a fake invite event with is_direct in content.\"\"\"\n+    content = SimpleNamespace(is_direct=is_direct)\n+    return SimpleNamespace(\n+        room_id=room_id,\n+        sender=sender,\n+        content=content,\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# _on_invite DM recording\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestOnInviteRecordsDM:\n+    \"\"\"_on_invite schedules a join that records the DM when is_direct is True.\n+\n+    The join itself is non-blocking (``_schedule_invite_join`` spawns a task),\n+    so these tests drive ``_on_invite`` and then await the scheduled task to\n+    observe its side effects.\n+    \"\"\"\n+\n+    @staticmethod\n+    async def _drain_invite_tasks(adapter):\n+        \"\"\"Await any tasks _schedule_invite_join spawned.\"\"\"\n+        tasks = list(adapter._invite_join_tasks.values())\n+        for task in tasks:\n+            await task\n+\n+    @pytest.mark.asyncio\n+    async def test_dm_invite_records_room(self):\n+        adapter = _make_adapter()\n+        adapter._join_room_by_id = AsyncMock(return_value=True)\n+        adapter._record_dm_room = AsyncMock()\n+\n+        event = _make_invite_event(is_direct=True, sender=\"@alice:example.org\")\n+        await adapter._on_invite(event)\n+        await self._drain_invite_tasks(adapter)\n+\n+        adapter._join_room_by_id.assert_awaited_once_with(\"!dm_room:example.org\")\n+        adapter._record_dm_room.assert_awaited_once_with(\n+            \"!dm_room:example.org\", \"@alice:example.org\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_non_dm_invite_does_not_record(self):\n+        adapter = _make_adapter()\n+        adapter._join_room_by_id = AsyncMock(return_value=True)\n+        adapter._record_dm_room = AsyncMock()\n+\n+        event = _make_invite_event(is_direct=False)\n+        await adapter._on_invite(event)\n+        await self._drain_invite_tasks(adapter)\n+\n+        adapter._join_room_by_id.assert_awaited_once()\n+        adapter._record_dm_room.assert_not_awaited()\n+\n+    @pytest.mark.asyncio\n+    async def test_missing_is_direct_does_not_record(self):\n+        \"\"\"Invite events without is_direct attribute should not trigger recording.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._join_room_by_id = AsyncMock(return_value=True)\n+        adapter._record_dm_room = AsyncMock()\n+\n+        event = SimpleNamespace(\n+            room_id=\"!room:example.org\",\n+            sender=\"@alice:example.org\",\n+            content=SimpleNamespace(),  # no is_direct attr\n+        )\n+        await adapter._on_invite(event)\n+        await self._drain_invite_tasks(adapter)\n+\n+        adapter._record_dm_room.assert_not_awaited()\n+\n+    @pytest.mark.asyncio\n+    async def test_join_failure_does_not_record(self):\n+        adapter = _make_adapter()\n+        adapter._join_room_by_id = AsyncMock(return_value=False)\n+        adapter._record_dm_room = AsyncMock()\n+\n+        event = _make_invite_event(is_direct=True)\n+        await adapter._on_invite(event)\n+        await self._drain_invite_tasks(adapter)\n+\n+        adapter._record_dm_room.assert_not_awaited()\n+\n+    @pytest.mark.asyncio\n+    async def test_empty_inviter_does_not_record(self):\n+        adapter = _make_adapter()\n+        adapter._join_room_by_id = AsyncMock(return_value=True)\n+        adapter._record_dm_room = AsyncMock()\n+\n+        event = SimpleNamespace(\n+            room_id=\"!room:example.org\",\n+            sender=\"\",\n+            content=SimpleNamespace(is_direct=True),\n+        )\n+        await adapter._on_invite(event)\n+        await self._drain_invite_tasks(adapter)\n+\n+        adapter._record_dm_room.assert_not_awaited()\n+\n+\n+# ---------------------------------------------------------------------------\n+# _record_dm_room\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestRecordDMRoom:\n+    \"\"\"_record_dm_room should update m.direct account data and local cache.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_creates_m_direct_when_absent(self):\n+        \"\"\"When m.direct doesn't exist (404), creates it from scratch.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._client = MagicMock()\n+        adapter._client.get_account_data = AsyncMock(side_effect=Exception(\"M_NOT_FOUND\"))\n+        adapter._client.set_account_data = AsyncMock()\n+\n+        await adapter._record_dm_room(\"!new:example.org\", \"@alice:example.org\")\n+\n+        adapter._client.set_account_data.assert_awaited_once_with(\n+            \"m.direct\", {\"@alice:example.org\": [\"!new:example.org\"]}\n+        )\n+        assert adapter._dm_rooms.get(\"!new:example.org\") is True\n+\n+    @pytest.mark.asyncio\n+    async def test_appends_to_existing_m_direct(self):\n+        \"\"\"When m.direct exists with other rooms, appends the new room.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._client = MagicMock()\n+        existing_data = {\"@bob:example.org\": [\"!old:example.org\"]}\n+        adapter._client.get_account_data = AsyncMock(return_value=existing_data)\n+        adapter._client.set_account_data = AsyncMock()\n+\n+        await adapter._record_dm_room(\"!new:example.org\", \"@alice:example.org\")\n+\n+        expected = {\n+            \"@bob:example.org\": [\"!old:example.org\"],\n+            \"@alice:example.org\": [\"!new:example.org\"],\n+        }\n+        adapter._client.set_account_data.assert_awaited_once_with(\"m.direct\", expected)\n+\n+    @pytest.mark.asyncio\n+    async def test_no_duplicate_room_in_m_direct(self):\n+        \"\"\"If room is already in m.direct, does not append again.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._client = MagicMock()\n+        existing_data = {\"@alice:example.org\": [\"!room:example.org\"]}\n+        adapter._client.get_account_data = AsyncMock(return_value=existing_data)\n+        adapter._client.set_account_data = AsyncMock()\n+\n+        await adapter._record_dm_room(\"!room:example.org\", \"@alice:example.org\")\n+\n+        adapter._client.set_account_data.assert_not_awaited()\n+        assert adapter._dm_rooms.get(\"!room:example.org\") is True\n+\n+    @pytest.mark.asyncio\n+    async def test_set_failure_is_handled_gracefully(self):\n+        \"\"\"If set_account_data fails, local cache is still updated.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._client = MagicMock()\n+        adapter._client.get_account_data = AsyncMock(side_effect=Exception(\"not found\"))\n+        adapter._client.set_account_data = AsyncMock(\n+            side_effect=Exception(\"M_FORBIDDEN\")\n+        )\n+\n+        # Should not raise\n+        await adapter._record_dm_room(\"!room:example.org\", \"@alice:example.org\")\n+\n+        # Local cache updated despite server error\n+        assert adapter._dm_rooms.get(\"!room:example.org\") is True\n+\n+    @pytest.mark.asyncio\n+    async def test_clears_room_identity_cache(self):\n+        \"\"\"After recording a DM, room identity cache should be invalidated.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._client = MagicMock()\n+        adapter._client.get_account_data = AsyncMock(side_effect=Exception(\"404\"))\n+        adapter._client.set_account_data = AsyncMock()\n+\n+        adapter._room_identities[\"!room:example.org\"] = \"stale\"\n+        adapter._room_identity_cached_at[\"!room:example.org\"] = time.monotonic()\n+\n+        await adapter._record_dm_room(\"!room:example.org\", \"@alice:example.org\")\n+\n+        assert \"!room:example.org\" not in adapter._room_identities\n+        assert \"!room:example.org\" not in adapter._room_identity_cached_at\n+\n+    @pytest.mark.asyncio\n+    async def test_no_client_is_noop(self):\n+        \"\"\"If _client is None, does nothing.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._client = None\n+\n+        # Should not raise\n+        await adapter._record_dm_room(\"!room:example.org\", \"@alice:example.org\")\n+\n+    @pytest.mark.asyncio\n+    async def test_m_direct_response_with_content_attr(self):\n+        \"\"\"get_account_data may return an object with .content attribute.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._client = MagicMock()\n+        resp = SimpleNamespace(content={\"@bob:example.org\": [\"!old:example.org\"]})\n+        adapter._client.get_account_data = AsyncMock(return_value=resp)\n+        adapter._client.set_account_data = AsyncMock()\n+\n+        await adapter._record_dm_room(\"!new:example.org\", \"@alice:example.org\")\n+\n+        expected = {\n+            \"@bob:example.org\": [\"!old:example.org\"],\n+            \"@alice:example.org\": [\"!new:example.org\"],\n+        }\n+        adapter._client.set_account_data.assert_awaited_once_with(\"m.direct\", expected)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}