/ tests / gateway / test_matrix_mention.py
test_matrix_mention.py
  1  """Tests for Matrix require-mention gating and auto-thread features."""
  2  
  3  import json
  4  import sys
  5  import time
  6  from types import SimpleNamespace
  7  from unittest.mock import AsyncMock, MagicMock, patch
  8  
  9  import pytest
 10  
 11  from gateway.config import PlatformConfig
 12  
 13  # The matrix adapter module is importable without mautrix installed
 14  # (module-level imports use try/except with stubs).  No need for
 15  # module-level mock installation — tests that call adapter methods
 16  # needing real mautrix APIs mock them individually.
 17  
 18  
 19  def _make_adapter(tmp_path=None):
 20      """Create a MatrixAdapter with mocked config."""
 21      from gateway.platforms.matrix import MatrixAdapter
 22  
 23      config = PlatformConfig(
 24          enabled=True,
 25          token="syt_test_token",
 26          extra={
 27              "homeserver": "https://matrix.example.org",
 28              "user_id": "@hermes:example.org",
 29          },
 30      )
 31      adapter = MatrixAdapter(config)
 32      adapter._text_batch_delay_seconds = 0  # disable batching for tests
 33      adapter.handle_message = AsyncMock()
 34      adapter._startup_ts = time.time() - 10  # avoid startup grace filter
 35      return adapter
 36  
 37  
 38  def _set_dm(adapter, room_id="!room1:example.org", is_dm=True):
 39      """Mark a room as DM (or not) in the adapter's cache."""
 40      adapter._dm_rooms[room_id] = is_dm
 41  
 42  
 43  def _make_event(
 44      body,
 45      sender="@alice:example.org",
 46      event_id="$evt1",
 47      room_id="!room1:example.org",
 48      formatted_body=None,
 49      thread_id=None,
 50      mention_user_ids=None,
 51  ):
 52      """Create a fake room message event.
 53  
 54      The mautrix adapter reads ``event.room_id``, ``event.sender``,
 55      ``event.event_id``, ``event.timestamp``, and ``event.content``
 56      (a dict with ``msgtype``, ``body``, etc.).
 57      """
 58      content = {"body": body, "msgtype": "m.text"}
 59      if formatted_body:
 60          content["formatted_body"] = formatted_body
 61          content["format"] = "org.matrix.custom.html"
 62  
 63      if mention_user_ids is not None:
 64          content["m.mentions"] = {"user_ids": mention_user_ids}
 65  
 66      relates_to = {}
 67      if thread_id:
 68          relates_to["rel_type"] = "m.thread"
 69          relates_to["event_id"] = thread_id
 70      if relates_to:
 71          content["m.relates_to"] = relates_to
 72  
 73      return SimpleNamespace(
 74          sender=sender,
 75          event_id=event_id,
 76          room_id=room_id,
 77          timestamp=int(time.time() * 1000),
 78          content=content,
 79      )
 80  
 81  
 82  # ---------------------------------------------------------------------------
 83  # Mention detection helpers
 84  # ---------------------------------------------------------------------------
 85  
 86  
 87  class TestIsBotMentioned:
 88      def setup_method(self):
 89          self.adapter = _make_adapter()
 90  
 91      def test_full_user_id_in_body(self):
 92          assert self.adapter._is_bot_mentioned("hey @hermes:example.org help")
 93  
 94      def test_localpart_in_body(self):
 95          assert self.adapter._is_bot_mentioned("hermes can you help?")
 96  
 97      def test_localpart_case_insensitive(self):
 98          assert self.adapter._is_bot_mentioned("HERMES can you help?")
 99  
100      def test_matrix_pill_in_formatted_body(self):
101          html = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
102          assert self.adapter._is_bot_mentioned("Hermes help", html)
103  
104      def test_no_mention(self):
105          assert not self.adapter._is_bot_mentioned("hello everyone")
106  
107      def test_empty_body(self):
108          assert not self.adapter._is_bot_mentioned("")
109  
110      def test_partial_localpart_no_match(self):
111          # "hermesbot" should not match word-boundary check for "hermes"
112          assert not self.adapter._is_bot_mentioned("hermesbot is here")
113  
114      # m.mentions.user_ids — MSC3952 / Matrix v1.7 authoritative mentions
115      # Ported from openclaw/openclaw#64796
116  
117      def test_m_mentions_user_ids_authoritative(self):
118          """m.mentions.user_ids alone is sufficient — no body text needed."""
119          assert self.adapter._is_bot_mentioned(
120              "please reply",  # no @hermes anywhere in body
121              mention_user_ids=["@hermes:example.org"],
122          )
123  
124      def test_m_mentions_user_ids_with_body_mention(self):
125          """Both m.mentions and body mention — should still be True."""
126          assert self.adapter._is_bot_mentioned(
127              "hey @hermes:example.org help",
128              mention_user_ids=["@hermes:example.org"],
129          )
130  
131      def test_m_mentions_user_ids_other_user_only(self):
132          """m.mentions with a different user — bot is NOT mentioned."""
133          assert not self.adapter._is_bot_mentioned(
134              "hello",
135              mention_user_ids=["@alice:example.org"],
136          )
137  
138      def test_m_mentions_user_ids_empty_list(self):
139          """Empty user_ids list — falls through to text detection."""
140          assert not self.adapter._is_bot_mentioned(
141              "hello everyone",
142              mention_user_ids=[],
143          )
144  
145      def test_m_mentions_user_ids_none(self):
146          """None mention_user_ids — falls through to text detection."""
147          assert not self.adapter._is_bot_mentioned(
148              "hello everyone",
149              mention_user_ids=None,
150          )
151  
152  
153  class TestStripMention:
154      def setup_method(self):
155          self.adapter = _make_adapter()
156  
157      def test_strip_full_user_id(self):
158          result = self.adapter._strip_mention("@hermes:example.org help me")
159          assert result == "help me"
160  
161      def test_localpart_preserved(self):
162          """Bare localpart (no @) is preserved — avoids false positives in paths."""
163          result = self.adapter._strip_mention("hermes help me")
164          assert result == "hermes help me"
165  
166      def test_localpart_in_path_preserved(self):
167          """Localpart inside a file path must not be damaged."""
168          result = self.adapter._strip_mention("read /home/hermes/config.yaml")
169          assert result == "read /home/hermes/config.yaml"
170  
171      def test_strip_localpart_when_explicit_at_mention(self):
172          result = self.adapter._strip_mention("@hermes help me")
173          assert result == "help me"
174  
175      def test_does_not_strip_bare_localpart_word(self):
176          # Regression: plain words like "Hermes Agent" should not be mutated.
177          result = self.adapter._strip_mention("Hermes Agent")
178          assert result == "Hermes Agent"
179  
180      def test_strip_returns_empty_for_mention_only(self):
181          result = self.adapter._strip_mention("@hermes:example.org")
182          assert result == ""
183  
184  
185  # ---------------------------------------------------------------------------
186  # Outbound mention payloads
187  # ---------------------------------------------------------------------------
188  
189  
190  class TestOutboundMentions:
191      def setup_method(self):
192          self.adapter = _make_adapter()
193          self.mock_client = MagicMock()
194          self.mock_client.send_message_event = AsyncMock(return_value="$evt1")
195          self.adapter._client = self.mock_client
196  
197      @staticmethod
198      def _sent_content(mock_client):
199          call_args = mock_client.send_message_event.call_args
200          return call_args.args[2] if len(call_args.args) > 2 else call_args.kwargs["content"]
201  
202      @pytest.mark.asyncio
203      async def test_send_adds_matrix_mentions_and_formatted_body(self):
204          result = await self.adapter.send(
205              "!room1:example.org",
206              "Hello @alice:example.org, please check this.",
207          )
208  
209          assert result.success is True
210          content = self._sent_content(self.mock_client)
211          assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
212          assert content["formatted_body"] == (
213              'Hello <a href="https://matrix.to/#/@alice:example.org">'
214              "@alice:example.org</a>, please check this."
215          )
216  
217      @pytest.mark.asyncio
218      async def test_send_dedupes_mentions_and_ignores_code_spans(self):
219          await self.adapter.send(
220              "!room1:example.org",
221              "Ping @alice:example.org and @alice:example.org, not `@code:example.org`.",
222          )
223  
224          content = self._sent_content(self.mock_client)
225          assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
226          assert "@code:example.org</a>" not in content["formatted_body"]
227  
228      @pytest.mark.asyncio
229      async def test_edit_message_preserves_mentions(self):
230          result = await self.adapter.edit_message(
231              "!room1:example.org",
232              "$original",
233              "Updated for @alice:example.org",
234          )
235  
236          assert result.success is True
237          content = self._sent_content(self.mock_client)
238          assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
239          assert content["m.new_content"]["m.mentions"] == {"user_ids": ["@alice:example.org"]}
240          assert content["m.new_content"]["formatted_body"] == (
241              'Updated for <a href="https://matrix.to/#/@alice:example.org">'
242              "@alice:example.org</a>"
243          )
244          assert content["formatted_body"] == (
245              '* Updated for <a href="https://matrix.to/#/@alice:example.org">'
246              "@alice:example.org</a>"
247          )
248  
249      @pytest.mark.asyncio
250      async def test_send_simple_notice_adds_mentions(self):
251          result = await self.adapter._send_simple_message(
252              "!room1:example.org",
253              "Heads up @alice:example.org",
254              msgtype="m.notice",
255          )
256  
257          assert result.success is True
258          content = self._sent_content(self.mock_client)
259          assert content["msgtype"] == "m.notice"
260          assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
261  
262  
263  # ---------------------------------------------------------------------------
264  # Require-mention gating in _on_room_message
265  # ---------------------------------------------------------------------------
266  
267  
268  @pytest.mark.asyncio
269  async def test_require_mention_default_ignores_unmentioned(monkeypatch):
270      """Default (require_mention=true): messages without mention are ignored."""
271      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
272      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
273      monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
274  
275      adapter = _make_adapter()
276      event = _make_event("hello everyone")
277  
278      await adapter._on_room_message(event)
279      adapter.handle_message.assert_not_awaited()
280  
281  
282  @pytest.mark.asyncio
283  async def test_require_mention_default_processes_mentioned(monkeypatch):
284      """Default: messages with mention are processed, mention stripped."""
285      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
286      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
287      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
288  
289      adapter = _make_adapter()
290      event = _make_event("@hermes:example.org help me")
291  
292      await adapter._on_room_message(event)
293      adapter.handle_message.assert_awaited_once()
294      msg = adapter.handle_message.await_args.args[0]
295      assert msg.text == "help me"
296  
297  
298  @pytest.mark.asyncio
299  async def test_require_mention_html_pill(monkeypatch):
300      """Bot mentioned via HTML pill should be processed."""
301      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
302      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
303      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
304  
305      adapter = _make_adapter()
306      formatted = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
307      event = _make_event("Hermes help", formatted_body=formatted)
308  
309      await adapter._on_room_message(event)
310      adapter.handle_message.assert_awaited_once()
311  
312  
313  @pytest.mark.asyncio
314  async def test_require_mention_m_mentions_user_ids(monkeypatch):
315      """m.mentions.user_ids is authoritative per MSC3952 — no body mention needed.
316  
317      Ported from openclaw/openclaw#64796.
318      """
319      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
320      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
321      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
322  
323      adapter = _make_adapter()
324      # Body has NO mention, but m.mentions.user_ids includes the bot.
325      event = _make_event(
326          "please reply",
327          mention_user_ids=["@hermes:example.org"],
328      )
329  
330      await adapter._on_room_message(event)
331      adapter.handle_message.assert_awaited_once()
332  
333  
334  @pytest.mark.asyncio
335  async def test_require_mention_m_mentions_other_user_ignored(monkeypatch):
336      """m.mentions.user_ids mentioning another user should NOT activate the bot."""
337      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
338      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
339      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
340  
341      adapter = _make_adapter()
342      event = _make_event(
343          "hey alice check this",
344          mention_user_ids=["@alice:example.org"],
345      )
346  
347      await adapter._on_room_message(event)
348      adapter.handle_message.assert_not_awaited()
349  
350  
351  @pytest.mark.asyncio
352  async def test_require_mention_dm_always_responds(monkeypatch):
353      """DMs always respond regardless of mention setting."""
354      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
355      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
356      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
357  
358      adapter = _make_adapter()
359      # Mark the room as a DM via the adapter's cache.
360      _set_dm(adapter)
361      event = _make_event("hello without mention")
362  
363      await adapter._on_room_message(event)
364      adapter.handle_message.assert_awaited_once()
365  
366  
367  @pytest.mark.asyncio
368  async def test_dm_strips_full_mxid(monkeypatch):
369      """DMs strip the full MXID from body when require_mention is on (default)."""
370      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
371      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
372      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
373  
374      adapter = _make_adapter()
375      _set_dm(adapter)
376      event = _make_event("@hermes:example.org help me")
377  
378      await adapter._on_room_message(event)
379      adapter.handle_message.assert_awaited_once()
380      msg = adapter.handle_message.await_args.args[0]
381      assert msg.text == "help me"
382  
383  
384  @pytest.mark.asyncio
385  async def test_dm_preserves_localpart_in_body(monkeypatch):
386      """DMs no longer strip bare localpart — only the full MXID is removed."""
387      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
388      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
389      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
390  
391      adapter = _make_adapter()
392      _set_dm(adapter)
393      event = _make_event("hermes help me")
394  
395      await adapter._on_room_message(event)
396      adapter.handle_message.assert_awaited_once()
397      msg = adapter.handle_message.await_args.args[0]
398      assert msg.text == "hermes help me"
399  
400  
401  @pytest.mark.asyncio
402  async def test_bare_mention_passes_empty_string(monkeypatch):
403      """A message that is only a mention should pass through as empty, not be dropped."""
404      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
405      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
406      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
407  
408      adapter = _make_adapter()
409      event = _make_event("@hermes:example.org")
410  
411      await adapter._on_room_message(event)
412      adapter.handle_message.assert_awaited_once()
413      msg = adapter.handle_message.await_args.args[0]
414      assert msg.text == ""
415  
416  
417  @pytest.mark.asyncio
418  async def test_require_mention_free_response_room(monkeypatch):
419      """Free-response rooms bypass mention requirement."""
420      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
421      monkeypatch.setenv(
422          "MATRIX_FREE_RESPONSE_ROOMS", "!room1:example.org,!room2:example.org"
423      )
424      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
425  
426      adapter = _make_adapter()
427      event = _make_event("hello without mention", room_id="!room1:example.org")
428  
429      await adapter._on_room_message(event)
430      adapter.handle_message.assert_awaited_once()
431  
432  
433  @pytest.mark.asyncio
434  async def test_require_mention_bot_participated_thread(monkeypatch):
435      """Threads with prior bot participation bypass mention requirement."""
436      monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
437      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
438      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
439  
440      adapter = _make_adapter()
441      adapter._threads.mark("$thread1")
442  
443      event = _make_event("hello without mention", thread_id="$thread1")
444  
445      await adapter._on_room_message(event)
446      adapter.handle_message.assert_awaited_once()
447  
448  
449  @pytest.mark.asyncio
450  async def test_require_mention_disabled(monkeypatch):
451      """MATRIX_REQUIRE_MENTION=false: all messages processed."""
452      monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
453      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
454      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
455  
456      adapter = _make_adapter()
457      event = _make_event("hello without mention")
458  
459      await adapter._on_room_message(event)
460      adapter.handle_message.assert_awaited_once()
461      msg = adapter.handle_message.await_args.args[0]
462      assert msg.text == "hello without mention"
463  
464  
465  @pytest.mark.asyncio
466  async def test_require_mention_disabled_skips_stripping(monkeypatch):
467      """MATRIX_REQUIRE_MENTION=false: mention text is NOT stripped from body."""
468      monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
469      monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
470      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
471  
472      adapter = _make_adapter()
473      event = _make_event("@hermes:example.org help me")
474  
475      await adapter._on_room_message(event)
476      adapter.handle_message.assert_awaited_once()
477      msg = adapter.handle_message.await_args.args[0]
478      assert msg.text == "@hermes:example.org help me"
479  
480  
481  # ---------------------------------------------------------------------------
482  # Auto-thread in _on_room_message
483  # ---------------------------------------------------------------------------
484  
485  
486  @pytest.mark.asyncio
487  async def test_auto_thread_default_creates_thread(monkeypatch):
488      """Default (auto_thread=true): sets thread_id to event.event_id."""
489      monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
490      monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
491  
492      adapter = _make_adapter()
493      event = _make_event("hello", event_id="$msg1")
494  
495      await adapter._on_room_message(event)
496      adapter.handle_message.assert_awaited_once()
497      msg = adapter.handle_message.await_args.args[0]
498      assert msg.source.thread_id == "$msg1"
499  
500  
501  @pytest.mark.asyncio
502  async def test_auto_thread_preserves_existing_thread(monkeypatch):
503      """If message is already in a thread, thread_id is not overridden."""
504      monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
505      monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
506  
507      adapter = _make_adapter()
508      adapter._threads.mark("$thread_root")
509      event = _make_event("reply in thread", thread_id="$thread_root")
510  
511      await adapter._on_room_message(event)
512      adapter.handle_message.assert_awaited_once()
513      msg = adapter.handle_message.await_args.args[0]
514      assert msg.source.thread_id == "$thread_root"
515  
516  
517  @pytest.mark.asyncio
518  async def test_auto_thread_skips_dm(monkeypatch):
519      """DMs should not get auto-threaded."""
520      monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
521      monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
522  
523      adapter = _make_adapter()
524      _set_dm(adapter)
525      event = _make_event("hello dm", event_id="$dm1")
526  
527      await adapter._on_room_message(event)
528      adapter.handle_message.assert_awaited_once()
529      msg = adapter.handle_message.await_args.args[0]
530      assert msg.source.thread_id is None
531  
532  
533  @pytest.mark.asyncio
534  async def test_auto_thread_disabled(monkeypatch):
535      """MATRIX_AUTO_THREAD=false: thread_id stays None."""
536      monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
537      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
538  
539      adapter = _make_adapter()
540      event = _make_event("hello", event_id="$msg1")
541  
542      await adapter._on_room_message(event)
543      adapter.handle_message.assert_awaited_once()
544      msg = adapter.handle_message.await_args.args[0]
545      assert msg.source.thread_id is None
546  
547  
548  @pytest.mark.asyncio
549  async def test_auto_thread_tracks_participation(monkeypatch):
550      """Auto-created threads are tracked in _threads."""
551      monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
552      monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
553  
554      adapter = _make_adapter()
555      event = _make_event("hello", event_id="$msg1")
556  
557      with patch.object(adapter._threads, "_save"):
558          await adapter._on_room_message(event)
559  
560      assert "$msg1" in adapter._threads
561  
562  
563  # ---------------------------------------------------------------------------
564  # Thread persistence
565  # ---------------------------------------------------------------------------
566  
567  
568  class TestThreadPersistence:
569      def test_empty_state_file(self, tmp_path, monkeypatch):
570          """No state file → empty set."""
571          from gateway.platforms.helpers import ThreadParticipationTracker
572  
573          monkeypatch.setattr(
574              ThreadParticipationTracker,
575              "_state_path",
576              lambda self: tmp_path / "matrix_threads.json",
577          )
578          adapter = _make_adapter()
579          assert "$nonexistent" not in adapter._threads
580  
581      def test_track_thread_persists(self, tmp_path, monkeypatch):
582          """mark() writes to disk."""
583          from gateway.platforms.helpers import ThreadParticipationTracker
584  
585          state_path = tmp_path / "matrix_threads.json"
586          monkeypatch.setattr(
587              ThreadParticipationTracker,
588              "_state_path",
589              lambda self: state_path,
590          )
591          adapter = _make_adapter()
592          adapter._threads.mark("$thread_abc")
593  
594          data = json.loads(state_path.read_text())
595          assert "$thread_abc" in data
596  
597      def test_threads_survive_reload(self, tmp_path, monkeypatch):
598          """Persisted threads are loaded by a new adapter instance."""
599          from gateway.platforms.helpers import ThreadParticipationTracker
600  
601          state_path = tmp_path / "matrix_threads.json"
602          state_path.write_text(json.dumps(["$t1", "$t2"]))
603          monkeypatch.setattr(
604              ThreadParticipationTracker,
605              "_state_path",
606              lambda self: state_path,
607          )
608          adapter = _make_adapter()
609          assert "$t1" in adapter._threads
610          assert "$t2" in adapter._threads
611  
612      def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):
613          """Thread set is trimmed to max_tracked."""
614          from gateway.platforms.helpers import ThreadParticipationTracker
615  
616          state_path = tmp_path / "matrix_threads.json"
617          monkeypatch.setattr(
618              ThreadParticipationTracker,
619              "_state_path",
620              lambda self: state_path,
621          )
622          adapter = _make_adapter()
623          adapter._threads._max_tracked = 5
624  
625          for i in range(10):
626              adapter._threads.mark(f"$t{i}")
627  
628          data = json.loads(state_path.read_text())
629          assert len(data) == 5
630  
631  
632  # ---------------------------------------------------------------------------
633  # DM mention-thread feature
634  # ---------------------------------------------------------------------------
635  
636  
637  @pytest.mark.asyncio
638  async def test_dm_mention_thread_disabled_by_default(monkeypatch):
639      """Default (dm_mention_threads=false): DM with mention should NOT create a thread."""
640      monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
641      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
642  
643      adapter = _make_adapter()
644      _set_dm(adapter)
645      event = _make_event("@hermes:example.org help me", event_id="$dm1")
646  
647      await adapter._on_room_message(event)
648      adapter.handle_message.assert_awaited_once()
649      msg = adapter.handle_message.await_args.args[0]
650      assert msg.source.thread_id is None
651  
652  
653  @pytest.mark.asyncio
654  async def test_dm_mention_thread_creates_thread(monkeypatch):
655      """MATRIX_DM_MENTION_THREADS=true: DM with @mention creates a thread."""
656      monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
657      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
658  
659      adapter = _make_adapter()
660      _set_dm(adapter)
661      event = _make_event("@hermes:example.org help me", event_id="$dm1")
662  
663      with patch.object(adapter._threads, "_save"):
664          await adapter._on_room_message(event)
665  
666      adapter.handle_message.assert_awaited_once()
667      msg = adapter.handle_message.await_args.args[0]
668      assert msg.source.thread_id == "$dm1"
669      assert msg.text == "help me"
670  
671  
672  @pytest.mark.asyncio
673  async def test_dm_mention_thread_no_mention_no_thread(monkeypatch):
674      """MATRIX_DM_MENTION_THREADS=true: DM without mention does NOT create a thread."""
675      monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
676      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
677  
678      adapter = _make_adapter()
679      _set_dm(adapter)
680      event = _make_event("hello without mention", event_id="$dm1")
681  
682      await adapter._on_room_message(event)
683      adapter.handle_message.assert_awaited_once()
684      msg = adapter.handle_message.await_args.args[0]
685      assert msg.source.thread_id is None
686  
687  
688  @pytest.mark.asyncio
689  async def test_dm_mention_thread_preserves_existing_thread(monkeypatch):
690      """MATRIX_DM_MENTION_THREADS=true: DM already in a thread keeps that thread_id."""
691      monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
692      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
693  
694      adapter = _make_adapter()
695      _set_dm(adapter)
696      adapter._threads.mark("$existing_thread")
697      event = _make_event("@hermes:example.org help me", thread_id="$existing_thread")
698  
699      await adapter._on_room_message(event)
700      adapter.handle_message.assert_awaited_once()
701      msg = adapter.handle_message.await_args.args[0]
702      assert msg.source.thread_id == "$existing_thread"
703  
704  
705  @pytest.mark.asyncio
706  async def test_dm_mention_thread_tracks_participation(monkeypatch):
707      """DM mention-thread tracks the thread in _threads."""
708      monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
709      monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
710  
711      adapter = _make_adapter()
712      _set_dm(adapter)
713      event = _make_event("@hermes:example.org help", event_id="$dm1")
714  
715      with patch.object(adapter._threads, "_save"):
716          await adapter._on_room_message(event)
717  
718      assert "$dm1" in adapter._threads
719  
720  
721  # ---------------------------------------------------------------------------
722  # YAML config bridge
723  # ---------------------------------------------------------------------------
724  
725  
726  class TestMatrixConfigBridge:
727      def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path):
728          """Matrix YAML config should bridge to env vars."""
729          monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
730          monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
731          monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
732  
733          yaml_content = {
734              "matrix": {
735                  "require_mention": False,
736                  "free_response_rooms": ["!room1:example.org", "!room2:example.org"],
737                  "auto_thread": False,
738              }
739          }
740  
741          import os
742  
743          import yaml
744  
745          config_file = tmp_path / "config.yaml"
746          config_file.write_text(yaml.dump(yaml_content))
747  
748          # Simulate the bridge logic from gateway/config.py
749          yaml_cfg = yaml.safe_load(config_file.read_text())
750          matrix_cfg = yaml_cfg.get("matrix", {})
751          if isinstance(matrix_cfg, dict):
752              if "require_mention" in matrix_cfg and not os.getenv(
753                  "MATRIX_REQUIRE_MENTION"
754              ):
755                  monkeypatch.setenv(
756                      "MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()
757                  )
758              frc = matrix_cfg.get("free_response_rooms")
759              if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
760                  if isinstance(frc, list):
761                      frc = ",".join(str(v) for v in frc)
762                  monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", str(frc))
763              if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
764                  monkeypatch.setenv(
765                      "MATRIX_AUTO_THREAD", str(matrix_cfg["auto_thread"]).lower()
766                  )
767  
768          assert os.getenv("MATRIX_REQUIRE_MENTION") == "false"
769          assert (
770              os.getenv("MATRIX_FREE_RESPONSE_ROOMS")
771              == "!room1:example.org,!room2:example.org"
772          )
773          assert os.getenv("MATRIX_AUTO_THREAD") == "false"
774  
775      def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):
776          """Matrix YAML dm_mention_threads should bridge to env var."""
777          monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
778  
779          import os
780  
781          import yaml
782  
783          yaml_content = {"matrix": {"dm_mention_threads": True}}
784          config_file = tmp_path / "config.yaml"
785          config_file.write_text(yaml.dump(yaml_content))
786  
787          yaml_cfg = yaml.safe_load(config_file.read_text())
788          matrix_cfg = yaml_cfg.get("matrix", {})
789          if isinstance(matrix_cfg, dict):
790              if "dm_mention_threads" in matrix_cfg and not os.getenv(
791                  "MATRIX_DM_MENTION_THREADS"
792              ):
793                  monkeypatch.setenv(
794                      "MATRIX_DM_MENTION_THREADS",
795                      str(matrix_cfg["dm_mention_threads"]).lower(),
796                  )
797  
798          assert os.getenv("MATRIX_DM_MENTION_THREADS") == "true"
799  
800      def test_env_vars_take_precedence_over_yaml(self, monkeypatch):
801          """Env vars should not be overwritten by YAML values."""
802          monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
803  
804          import os
805  
806          yaml_cfg = {"matrix": {"require_mention": False}}
807          matrix_cfg = yaml_cfg.get("matrix", {})
808          if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
809              monkeypatch.setenv(
810                  "MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()
811              )
812  
813          assert os.getenv("MATRIX_REQUIRE_MENTION") == "true"