# NousResearch/hermes-agent · Matrix auto-thread feature and thread reply behavior

## Open now

- Nothing open on GitHub.

These members concern adding MATRIX_AUTO_THREAD and MATRIX_REQUIRE_MENTION configuration support, along with a reported issue where thread-scoped sessions do not correctly attach the m.thread relation when posting replies.

## TL;DR

- 4 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #5106. Verify it addresses your case.
- **Recommended:** read PR #5106 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-5106`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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.39`: the share of member 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.

## Closed / duplicate issues

- #5087: feat(matrix): add auto_thread support for group rooms
- #67352: \[Bug\]: Matrix auto_thread / session_scope=thread isolates sessions correctly but replies still post at room level (no m.thread relation)

## Prior & competing PRs

- **#5106: feat(gateway): add MATRIX_REQUIRE_MENTION and MATRIX_AUTO_THREAD support**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/config.py`, `gateway/platforms/matrix.py`, `hermes_cli/config.py`, `tests/gateway/test_matrix_mention.py`, `website/docs/reference/environment-variables.md`, `website/docs/user-guide/messaging/matrix.md`
  - fix hunks:
  - `gateway/config.py`

    ```diff
    @@ -575,6 +575,20 @@ def load_gateway_config() -> GatewayConfig:
                         if isinstance(frc, list):
                             frc = ",".join(str(v) for v in frc)
                         os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc)
    +
    +            # Matrix settings → env vars (env vars take precedence)
    +            matrix_cfg = yaml_cfg.get("matrix", {})
    +            if isinstance(matrix_cfg, dict):
    +                if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
    +                    os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
    +                frc = matrix_cfg.get("free_response_rooms")
    +                if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
    +                    if isinstance(frc, list):
    +                        frc = ",".join(str(v) for v in frc)
    +                    os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
    +                if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
    +                    os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
    +
         except Exception as e:
             logger.warning(
                 "Failed to process config.yaml — falling back to .env / gateway.json values. "
    ```
  - `gateway/platforms/matrix.py`

    ```diff
    @@ -5,13 +5,16 @@
     when installed with ``pip install "matrix-nio[e2e]"``.
     
     Environment variables:
    -    MATRIX_HOMESERVER       Homeserver URL (e.g. https://matrix.example.org)
    -    MATRIX_ACCESS_TOKEN     Access token (preferred auth method)
    -    MATRIX_USER_ID          Full user ID (@bot:server) — required for password login
    -    MATRIX_PASSWORD         Password (alternative to access token)
    -    MATRIX_ENCRYPTION       Set "true" to enable E2EE
    -    MATRIX_ALLOWED_USERS    Comma-separated Matrix user IDs (@user:server)
    -    MATRIX_HOME_ROOM        Room ID for cron/notification delivery
    +    MATRIX_HOMESERVER           Homeserver URL (e.g. https://matrix.example.org)
    +    MATRIX_ACCESS_TOKEN         Access token (preferred auth method)
    +    MATRIX_USER_ID              Full user ID (@bot:server) — required for password login
    +    MATRIX_PASSWORD             Password (alternative to access token)
    +    MATRIX_ENCRYPTION           Set "true" to enable E2EE
    +    MATRIX_ALLOWED_USERS        Comma-separated Matrix user IDs (@user:server)
    +    MATRIX_HOME_ROOM            Room ID for cron/notification delivery
    +    MATRIX_REQUIRE_MENTION      Require @mention in rooms (default: true)
    +    MATRIX_FREE_RESPONSE_ROOMS  Comma-separated room IDs exempt from mention requirement
    +    MATRIX_AUTO_THREAD          Auto-create threads for room messages (default: true)
     """
     
     from __future__ import annotations
    ```

    ```diff
    @@ -123,6 +126,10 @@ def __init__(self, config: PlatformConfig):
             # Each entry: (room, event, timestamp)
             self._pending_megolm: list = []
     
    +        # Thread participation tracking (for require_mention bypass)
    +        self._bot_participated_threads: set = self._load_participated_threads()
    +        self._MAX_TRACKED_THREADS = 500
    +
         def _is_duplicate_event(self, event_id) -> bool:
             """Return True if this event was already processed. Tracks the ID otherwise."""
             if not event_id:
    ```

    ```diff
    @@ -902,6 +909,30 @@ async def _on_room_message(self, room: Any, event: Any) -> None:
             if relates_to.get("rel_type") == "m.thread":
                 thread_id = relates_to.get("event_id")
     
    +        # Require-mention gating.
    +        if not is_dm:
    +            free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
    +            free_rooms = {r.strip() for r in free_rooms_raw.split(",") if r.strip()}
    +            require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
    +            is_free_room = room.room_id in free_rooms
    +            in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads)
    +
    +            formatted_body = source_content.get("formatted_body")
    +            if require_mention and not is_free_room and not in_bot_thread:
    +                if not self._is_bot_mentioned(body, formatted_body):
    +                    return
    +
    +        # Strip mention from body when present (including in DMs).
    +        if self._is_bot_mentioned(body, source_content.get("formatted_body")):
    +            body = self._strip_mention(body)
    +
    +        # Auto-thread: create a thread for non-DM, non-threaded messages.
    +        if not is_dm and not thread_id:
    +            auto_thread = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
    +            if auto_thread:
    +                thread_id = event.event_id
    +                self._track_thread(thread_id)
    +
             # Reply-to detection.
             reply_to = None
             in_reply_to = relates_to.get("m.in_reply_to", {})
    ```

    ```diff
    @@ -946,6 +977,9 @@ async def _on_room_message(self, room: Any, event: Any) -> None:
                 reply_to_message_id=reply_to,
             )
     
    +        if thread_id:
    +            self._track_thread(thread_id)
    +
             await self.handle_message(msg_event)
     
         async def _on_room_message_media(self, room: Any, event: Any) -> None:
    ```

    ```diff
    @@ -1031,6 +1065,30 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None:
             if relates_to.get("rel_type") == "m.thread":
                 thread_id = relates_to.get("event_id")
     
    +        # Require-mention gating (media messages).
    +        if not is_dm:
    +            free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
    +            free_rooms = {r.strip() for r in free_rooms_raw.split(",") if r.strip()}
    +            require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
    +            is_free_room = room.room_id in free_rooms
    +            in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads)
    +
    +            if require_mention and not is_free_room and not in_bot_thread:
    +                formatted_body = source_content.get("formatted_body")
    +                if not self._is_bot_mentioned(body, formatted_body):
    +                    return
    +
    +        # Strip mention from body when present (including in DMs).
    +        if self._is_bot_mentioned(body, source_content.get("formatted_body")):
    +            body = self._strip_mention(body)
    +
    +        # Auto-thread: create a thread for non-DM, non-threaded messages.
    +        if not is_dm and not thread_id:
    +            auto_thread = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
    +            if auto_thread:
    +                thread_id = event.event_id
    +                self._track_thread(thread_id)
    +
             # For voice messages, cache audio locally for transcription tools.
             # Use the authenticated nio client to download (Matrix requires auth for media).
             media_urls = [http_url] if http_url else None
    ```

    ```diff
    @@ -1079,6 +1137,9 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None:
                 media_types=media_types,
             )
     
    +        if thread_id:
    +            self._track_thread(thread_id)
    +
             await self.handle_message(msg_event)
     
         async def _on_invite(self, room: Any, event: Any) -> None:
    ```

    ```diff
    @@ -1166,6 +1227,82 @@ async def _refresh_dm_cache(self) -> None:
                 for rid in self._joined_rooms
             }
     
    +    # ------------------------------------------------------------------
    +    # Thread participation tracking
    +    # ------------------------------------------------------------------
    +
    +    @staticmethod
    +    def _thread_state_path() -> Path:
    +        """Path to the persisted thread participation set."""
    +        from hermes_cli.config import get_hermes_home
    +        return get_hermes_home() / "matrix_threads.json"
    +
    +    @classmethod
    +    def _load_participated_threads(cls) -> set:
    +        """Load persisted thread IDs from disk."""
    +        path = cls._thread_state_path()
    +        try:
    +            if path.exists():
    +                data = json.loads(path.read_text(encoding="utf-8"))
    +                if isinstance(data, list):
    +                    return set(data)
    +        except Exception as e:
    +            logger.debug("Could not load matrix thread state: %s", e)
    +        return set()
    +
    +    def _save_participated_threads(self) -> None:
    +        """Persist the current thread set to disk (best-effort)."""
    +        path = self._thread_state_path()
    +        try:
    +            thread_list = list(self._bot_participated_threads)
    +            if len(thread_list) > self._MAX_TRACKED_THREADS:
    +                thread_list = thread_list[-self._MAX_TRACKED_THREADS:]
    +                self._bot_participated_threads = set(thread_list)
    +            path.parent.mkdir(parents=True, exist_ok=True)
    +            path.write_text(json.dumps(thread_list), encoding="utf-8")
    +        except Exception as e:
    +            logger.debug("Could not save matrix thread state: %s", e)
    +
    +    def _track_thread(self, thread_id: str) -> None:
    +        """Add a thread to the participation set and persist."""
    +        if thread_id not in self._bot_participated_threads:
    +            self._bot_participated_threads.add(thread_id)
    +            self._save_participated_threads()
    +
    +    # ------------------------------------------------------------------
    +    # Mention detection helpers
    +    # ------------------------------------------------------------------
    +
    +    def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool:
    +        """Return True if the bot is mentioned in the message."""
    +        if not body and not formatted_body:
    +            return False
    +        # Check for full @user:server in body
    +        if self._user_id and self._user_id in body:
    +            return True
    +        # Check for localpart with word boundaries (case-insensitive)
    +        if self._user_id and ":" in self._user_id:
    +            localpart = self._user_id.split(":")[0].lstrip("@")
    +            if localpart and re.search(r'\b' + re.escape(localpart) + r'\b', body, re.IGNORECASE):
    +                return True
    +        # Check formatted_body for Matrix pill
    +        if formatted_body and self._user_id:
    +            if f"matrix.to/#/{self._user_id}" in formatted_body:
    +                return True
    +        return False
    +
    +    def _strip_mention(self, body: str) -> str:
    +        """Remove bot mention from message body."""
    +        # Remove full @user:server
    +        if self._user_id:
    +            body = body.replace(self._user_id, "")
    +        # If still contains localpart mention, remove it
    +        if self._user_id and ":" in self._user_id:
    +            localpart = self._user_id.split(":")[0].lstrip("@")
    +            if localpart:
    +                body = re.sub(r'\b' + re.escape(localpart) + r'\b', '', body, flags=re.IGNORECASE)
    +        return body.strip()
    +
         def _get_display_name(self, room: Any, user_id: str) -> str:
             """Get a user's display name in a room, falling back to user_id."""
             if room and hasattr(room, "users"):
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -42,6 +42,7 @@
         "WHATSAPP_MODE", "WHATSAPP_ENABLED",
         "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
         "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM",
    +    "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
     })
     import yaml
     
    ```

    ```diff
    @@ -1008,6 +1009,30 @@ def ensure_hermes_home():
             "password": False,
             "category": "messaging",
         },
    +    "MATRIX_REQUIRE_MENTION": {
    +        "description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.",
    +        "prompt": "Require @mention in rooms (true/false)",
    +        "url": None,
    +        "password": False,
    +        "category": "messaging",
    +        "advanced": True,
    +    },
    +    "MATRIX_FREE_RESPONSE_ROOMS": {
    +        "description": "Comma-separated Matrix room IDs where bot responds without @mention",
    +        "prompt": "Free-response room IDs (comma-separated)",
    +        "url": None,
    +        "password": False,
    +        "category": "messaging",
    +        "advanced": True,
    +    },
    +    "MATRIX_AUTO_THREAD": {
    +        "description": "Auto-create threads for messages in Matrix rooms (default: true)",
    +        "prompt": "Auto-create threads in rooms (true/false)",
    +        "url": None,
    +        "password": False,
    +        "category": "messaging",
    +        "advanced": True,
    +    },
         "GATEWAY_ALLOW_ALL_USERS": {
             "description": "Allow all users to interact with messaging bots (true/false). Default: false.",
             "prompt": "Allow all users (true/false)",
    ```
  - `tests/gateway/test_matrix_mention.py`

    ```diff
    @@ -0,0 +1,492 @@
    +"""Tests for Matrix require-mention gating and auto-thread features."""
    +
    +import json
    +import sys
    +import time
    +from types import SimpleNamespace
    +from unittest.mock import AsyncMock, MagicMock, patch
    +
    +import pytest
    +
    +from gateway.config import PlatformConfig
    +
    +
    +def _ensure_nio_mock():
    +    """Install a mock nio module when matrix-nio isn't available."""
    +    if "nio" in sys.modules and hasattr(sys.modules["nio"], "__file__"):
    +        return
    +    nio_mod = MagicMock()
    +    nio_mod.MegolmEvent = type("MegolmEvent", (), {})
    +    nio_mod.RoomMessageText = type("RoomMessageText", (), {})
    +    nio_mod.RoomMessageImage = type("RoomMessageImage", (), {})
    +    nio_mod.RoomMessageAudio = type("RoomMessageAudio", (), {})
    +    nio_mod.RoomMessageVideo = type("RoomMessageVideo", (), {})
    +    nio_mod.RoomMessageFile = type("RoomMessageFile", (), {})
    +    nio_mod.DownloadResponse = type("DownloadResponse", (), {})
    +    nio_mod.MemoryDownloadResponse = type("MemoryDownloadResponse", (), {})
    +    nio_mod.InviteMemberEvent = type("InviteMemberEvent", (), {})
    +    sys.modules.setdefault("nio", nio_mod)
    +
    +
    +_ensure_nio_mock()
    +
    +
    +def _make_adapter(tmp_path=None):
    +    """Create a MatrixAdapter with mocked config."""
    +    from gateway.platforms.matrix import MatrixAdapter
    +
    +    config = PlatformConfig(
    +        enabled=True,
    +        token="[redacted:secret-assignment]",
    +        extra={
    +            "homeserver": "https://matrix.example.org",
    +            "user_id": "@hermes:example.org",
    +        },
    +    )
    +    adapter = MatrixAdapter(config)
    +    adapter.handle_message = AsyncMock()
    +    adapter._startup_ts = time.time() - 10  # avoid startup grace filter
    +    return adapter
    +
    +
    +def _make_room(room_id="!room1:example.org", member_count=5, is_dm=False):
    +    """Create a fake Matrix room."""
    +    room = SimpleNamespace(
    +        room_id=room_id,
    +        member_count=member_count,
    +        users={},
    +    )
    +    return room
    +
    +
    +def _make_event(
    +    body,
    +    sender="@alice:example.org",
    +    event_id="$evt1",
    +    formatted_body=None,
    +    thread_id=None,
    +):
    +    """Create a fake RoomMessageText event."""
    +    content = {"body": body, "msgtype": "m.text"}
    +    if formatted_body:
    +        content["formatted_body"] = formatted_body
    +        content["format"] = "org.matrix.custom.html"
    +
    +    relates_to = {}
    +    if thread_id:
    +        relates_to["rel_type"] = "m.thread"
    +        relates_to["event_id"] = thread_id
    +    if relates_to:
    +        content["m.relates_to"] = relates_to
    +
    +    return SimpleNamespace(
    +        sender=sender,
    +        event_id=event_id,
    +        server_timestamp=int(time.time() * 1000),
    +        body=body,
    +        source={"content": content},
    +    )
    +
    +
    +# ---------------------------------------------------------------------------
    +# Mention detection helpers
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestIsBotMentioned:
    +    def setup_method(self):
    +        self.adapter = _make_adapter()
    +
    +    def test_full_user_id_in_body(self):
    +        assert self.adapter._is_bot_mentioned("hey @hermes:example.org help")
    +
    +    def test_localpart_in_body(self):
    +        assert self.adapter._is_bot_mentioned("hermes can you help?")
    +
    +    def test_localpart_case_insensitive(self):
    +        assert self.adapter._is_bot_mentioned("HERMES can you help?")
    +
    +    def test_matrix_pill_in_formatted_body(self):
    +        html = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
    +        assert self.adapter._is_bot_mentioned("Hermes help", html)
    +
    +    def test_no_mention(self):
    +        assert not self.adapter._is_bot_mentioned("hello everyone")
    +
    +    def test_empty_body(self):
    +        assert not self.adapter._is_bot_mentioned("")
    +
    +    def test_partial_localpart_no_match(self):
    +        # "hermesbot" should not match word-boundary check for "hermes"
    +        assert not self.adapter._is_bot_mentioned("hermesbot is here")
    +
    +
    +class TestStripMention:
    +    def setup_method(self):
    +        self.adapter = _make_adapter()
    +
    +    def test_strip_full_user_id(self):
    +        result = self.adapter._strip_mention("@hermes:example.org help me")
    +        assert result == "help me"
    +
    +    def test_strip_localpart(self):
    +        result = self.adapter._strip_mention("hermes help me")
    +        assert result == "help me"
    +
    +    def test_strip_returns_empty_for_mention_only(self):
    +        result = self.adapter._strip_mention("@hermes:example.org")
    +        assert result == ""
    +
    +
    +# ---------------------------------------------------------------------------
    +# Require-mention gating in _on_room_message
    +# ---------------------------------------------------------------------------
    +
    +
    [redacted:email]
    +async def test_require_mention_default_ignores_unmentioned(monkeypatch):
    +    """Default (require_mention=true): messages without mention are ignored."""
    +    monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +    monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
    +
    +    adapter = _make_adapter()
    +    room = _make_room()
    +    event = _make_event("hello everyone")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_not_awaited()
    +
    +
    [redacted:email]
    +async def test_require_mention_default_processes_mentioned(monkeypatch):
    +    """Default: messages with mention are processed, mention stripped."""
    +    monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room()
    +    event = _make_event("@hermes:example.org help me")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.text == "help me"
    +
    +
    [redacted:email]
    +async def test_require_mention_html_pill(monkeypatch):
    +    """Bot mentioned via HTML pill should be processed."""
    +    monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room()
    +    formatted = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
    +    event = _make_event("Hermes help", formatted_body=formatted)
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +
    +
    [redacted:email]
    +async def test_require_mention_dm_always_responds(monkeypatch):
    +    """DMs always respond regardless of mention setting."""
    +    monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    # member_count=2 triggers DM detection
    +    room = _make_room(member_count=2)
    +    event = _make_event("hello without mention")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +
    +
    [redacted:email]
    +async def test_dm_strips_mention(monkeypatch):
    +    """DMs strip mention from body, matching Discord behavior."""
    +    monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room(member_count=2)
    +    event = _make_event("@hermes:example.org help me")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.text == "help me"
    +
    +
    [redacted:email]
    +async def test_bare_mention_passes_empty_string(monkeypatch):
    +    """A message that is only a mention should pass through as empty, not be dropped."""
    +    monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room()
    +    event = _make_event("@hermes:example.org")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.text == ""
    +
    +
    [redacted:email]
    +async def test_require_mention_free_response_room(monkeypatch):
    +    """Free-response rooms bypass mention requirement."""
    +    monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +    monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", "!room1:example.org,!room2:example.org")
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room(room_id="!room1:example.org")
    +    event = _make_event("hello without mention")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +
    +
    [redacted:email]
    +async def test_require_mention_bot_participated_thread(monkeypatch):
    +    """Threads with prior bot participation bypass mention requirement."""
    +    monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    adapter._bot_participated_threads.add("$thread1")
    +
    +    room = _make_room()
    +    event = _make_event("hello without mention", thread_id="$thread1")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +
    +
    [redacted:email]
    +async def test_require_mention_disabled(monkeypatch):
    +    """MATRIX_REQUIRE_MENTION=false: all messages processed."""
    +    monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
    +    monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room()
    +    event = _make_event("hello without mention")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.text == "hello without mention"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Auto-thread in _on_room_message
    +# ---------------------------------------------------------------------------
    +
    +
    [redacted:email]
    +async def test_auto_thread_default_creates_thread(monkeypatch):
    +    """Default (auto_thread=true): sets thread_id to event.event_id."""
    +    monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
    +    monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
    +
    +    adapter = _make_adapter()
    +    room = _make_room()
    +    event = _make_event("hello", event_id="$msg1")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.source.thread_id == "$msg1"
    +
    +
    [redacted:email]
    +async def test_auto_thread_preserves_existing_thread(monkeypatch):
    +    """If message is already in a thread, thread_id is not overridden."""
    +    monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
    +    monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
    +
    +    adapter = _make_adapter()
    +    adapter._bot_participated_threads.add("$thread_root")
    +    room = _make_room()
    +    event = _make_event("reply in thread", thread_id="$thread_root")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.source.thread_id == "$thread_root"
    +
    +
    [redacted:email]
    +async def test_auto_thread_skips_dm(monkeypatch):
    +    """DMs should not get auto-threaded."""
    +    monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
    +    monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
    +
    +    adapter = _make_adapter()
    +    room = _make_room(member_count=2)
    +    event = _make_event("hello dm", event_id="$dm1")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.source.thread_id is None
    +
    +
    [redacted:email]
    +async def test_auto_thread_disabled(monkeypatch):
    +    """MATRIX_AUTO_THREAD=false: thread_id stays None."""
    +    monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room()
    +    event = _make_event("hello", event_id="$msg1")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.source.thread_id is None
    +
    +
    [redacted:email]
    +async def test_auto_thread_tracks_participation(monkeypatch):
    +    """Auto-created threads are tracked in _bot_participated_threads."""
    +    monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
    +    monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
    +
    +    adapter = _make_adapter()
    +    room = _make_room()
    +    event = _make_event("hello", event_id="$msg1")
    +
    +    with patch.object(adapter, "_save_participated_threads"):
    +        await adapter._on_room_message(room, event)
    +
    +    assert "$msg1" in adapter._bot_participated_threads
    +
    +
    +# ---------------------------------------------------------------------------
    +# Thread persistence
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestThreadPersistence:
    +    def test_empty_state_file(self, tmp_path, monkeypatch):
    +        """No state file → empty set."""
    +        monkeypatch.setattr(
    +            "gateway.platforms.matrix.MatrixAdapter._thread_state_path",
    +            staticmethod(lambda: tmp_path / "matrix_threads.json"),
    +        )
    +        adapter = _make_adapter()
    +        loaded = adapter._load_participated_threads()
    +        assert loaded == set()
    +
    +    def test_track_thread_persists(self, tmp_path, monkeypatch):
    +        """_track_thread writes to disk."""
    +        state_path = tmp_path / "matrix_threads.json"
    +        monkeypatch.setattr(
    +            "gateway.platforms.matrix.MatrixAdapter._thread_state_path",
    +            staticmethod(lambda: state_path),
    +        )
    +        adapter = _make_adapter()
    +        adapter._track_thread("$thread_abc")
    +
    +        data = json.loads(state_path.read_text())
    +        assert "$thread_abc" in data
    +
    +    def test_threads_survive_reload(self, tmp_path, monkeypatch):
    +        """Persisted threads are loaded by a new adapter instance."""
    +        state_path = tmp_path / "matrix_threads.json"
    +        state_path.write_text(json.dumps(["$t1", "$t2"]))
    +        monkeypatch.setattr(
    +            "gateway.platforms.matrix.MatrixAdapter._thread_state_path",
    +            staticmethod(lambda: state_path),
    +        )
    +        adapter = _make_adapter()
    +        assert "$t1" in adapter._bot_participated_threads
    +        assert "$t2" in adapter._bot_participated_threads
    +
    +    def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):
    +        """Thread set is trimmed to _MAX_TRACKED_THREADS."""
    +        state_path = tmp_path / "matrix_threads.json"
    +        monkeypatch.setattr(
    +            "gateway.platforms.matrix.MatrixAdapter._thread_state_path",
    +            staticmethod(lambda: state_path),
    +        )
    +        adapter = _make_adapter()
    +        adapter._MAX_TRACKED_THREADS = 5
    +
    +        for i in range(10):
    +            adapter._bot_participated_threads.add(f"$t{i}")
    +        adapter._save_participated_threads()
    +
    +        data = json.loads(state_path.read_text())
    +        assert len(data) == 5
    +
    +
    +# ---------------------------------------------------------------------------
    +# YAML config bridge
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestMatrixConfigBridge:
    +    def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path):
    +        """Matrix YAML config should bridge to env vars."""
    +        monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
    +        monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
    +        monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
    +
    +        yaml_content = {
    +            "matrix": {
    +                "require_mention": False,
    +                "free_response_rooms": ["!room1:example.org", "!room2:example.org"],
    +                "auto_thread": False,
    +            }
    +        }
    +
    +        import os
    +        import yaml
    +
    +        config_file = tmp_path / "config.yaml"
    +        config_file.write_text(yaml.dump(yaml_content))
    +
    +        # Simulate the bridge logic from gateway/config.py
    +        yaml_cfg = yaml.safe_load(config_file.read_text())
    +        matrix_cfg = yaml_cfg.get("matrix", {})
    +        if isinstance(matrix_cfg, dict):
    +            if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
    +                monkeypatch.setenv("MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower())
    +            frc = matrix_cfg.get("free_response_rooms")
    +            if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
    +                if isinstance(frc, list):
    +                    frc = ",".join(str(v) for v in frc)
    +                monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", str(frc))
    +            if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
    +                monkeypatch.setenv("MATRIX_AUTO_THREAD", str(matrix_cfg["auto_thread"]).lower())
    +
    +        assert os.getenv("MATRIX_REQUIRE_MENTION") == "false"
    +        assert os.getenv("MATRIX_FREE_RESPONSE_ROOMS") == "!room1:example.org,!room2:example.org"
    +        assert os.getenv("MATRIX_AUTO_THREAD") == "false"
    +
    +    def test_env_vars_take_precedence_over_yaml(self, monkeypatch):
    +        """Env vars should not be overwritten by YAML values."""
    +        monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
    +
    +        import os
    +        yaml_cfg = {"matrix": {"require_mention": False}}
    +        matrix_cfg = yaml_cfg.get("matrix", {})
    +        if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
    +            monkeypatch.setenv("MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower())
    +
    +        assert os.getenv("MATRIX_REQUIRE_MENTION") == "true"
    ```
  - `website/docs/reference/environment-variables.md`

    ```diff
    @@ -232,6 +232,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
     | `MATRIX_ALLOWED_USERS` | Comma-separated Matrix user IDs allowed to message the bot (e.g. `@alice:matrix.org`) |
     | `MATRIX_HOME_ROOM` | Room ID for proactive message delivery (e.g. `!abc123:matrix.org`) |
     | `MATRIX_ENCRYPTION` | Enable end-to-end encryption (`true`/`false`, default: `false`) |
    +| `MATRIX_REQUIRE_MENTION` | Require `@mention` in rooms (default: `true`). Set to `false` to respond to all messages. |
    +| `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` |
    +| `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) |
     | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) |
     | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) |
     | `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) |
    ```
  - `website/docs/user-guide/messaging/matrix.md`

    ```diff
    @@ -17,8 +17,9 @@ Before setup, here's the part most people want to know: how Hermes behaves once
     | Context | Behavior |
     |---------|----------|
     | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
    -| **Rooms** | Hermes responds to all messages in rooms it has joined. Room invites are auto-accepted. |
    -| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. |
    +| **Rooms** | By default, Hermes requires an `@mention` to respond. Set `MATRIX_REQUIRE_MENTION=false` or add room IDs to `MATRIX_FREE_RESPONSE_ROOMS` for free-response rooms. Room invites are auto-accepted. |
    +| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. Threads where the bot has already participated do not require a mention. |
    +| **Auto-threading** | By default, Hermes auto-creates a thread for each message it responds to in a room. This keeps conversations isolated. Set `MATRIX_AUTO_THREAD=false` to disable. |
     | **Shared rooms with multiple users** | By default, Hermes isolates session history per user inside the room. Two people talking in the same room do not share one transcript unless you explicitly disable that. |
     
     :::tip
    ```

    ```diff
    @@ -51,6 +52,30 @@ Shared sessions can be useful for a collaborative room, but they also mean:
     - one person's long tool-heavy task can bloat everyone else's context
     - one person's in-flight run can interrupt another person's follow-up in the same room
     
    +### Mention and Threading Configuration
    +
    +You can configure mention and auto-threading behavior via environment variables or `config.yaml`:
    +
    +```yaml
    +matrix:
    +  require_mention: true           # Require @mention in rooms (default: true)
    +  free_response_rooms:            # Rooms exempt from mention requirement
    +    - "!abc123:matrix.org"
    +  auto_thread: true               # Auto-create threads for responses (default: true)
    +```
    +
    +Or via environment variables:
    +
    +```bash
    +MATRIX_REQUIRE_MENTION=true
    +MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org
    +MATRIX_AUTO_THREAD=true
    +```
    +
    +:::note
    +If you are upgrading from a version that did not have `MATRIX_REQUIRE_MENTION`, the bot previously responded to all messages in rooms. To preserve that behavior, set `MATRIX_REQUIRE_MENTION=false`.
    +:::
    +
     This guide walks you through the full setup process — from creating your bot account to sending your first message.
     
     ## Step 1: Create a Bot Account
    ```
- **#5090: feat(gateway): add MATRIX_REQUIRE_MENTION and MATRIX_AUTO_THREAD support**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#5106`

## Fix status

- Merged to main in PR #5106 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-04`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
