Files
hermes-agent/tests/gateway/test_matrix_handoff_thread.py
teknium1 f41ad3bc0a fix(gateway,cron): key Matrix handoff/cron thread seeds the way Matrix replies are keyed
A Matrix `attach_to_session` cron delivery and a CLI→Matrix `/handoff` now land in their own
thread AND a human reply in that thread continues the seeded session (#112918).

The salvaged `MatrixAdapter.create_handoff_thread` (ea-s21, #108367) gives both seeders a
thread root. The second half of the bug is a key-shape mismatch: the handoff watcher and
`_seed_cron_thread_session` seeded `matrix🧵<room>:<root>` while the adapter keys every
in-thread reply on the ROOM's type (`matrix:group|dm:<room>:<root>`), so seed and reply never
met. Following the direction landed for Slack in 1f3f45e87b (#111896), the seeders now mirror
the adapter instead of the adapter moving onto a `thread` slot: rekeying inbound Matrix threads
would orphan every existing Matrix thread session and drop `is_group` in authz for in-thread
messages (`_GROUP_CHAT_TYPES` has no `thread`).

- gateway/run_startup.py: Matrix destinations key `dm`/`group` from the adapter's
  `get_chat_info` (`_handoff_home_is_dm`).
- cron/scheduler_delivery.py: `_THREAD_REPLY_CHAT_TYPE` — Slack and Matrix non-DM thread seeds
  use `group`. Slack channel cron threads had the same mismatch (adapter `build_source` keys
  `group`; the seed said `thread`); the two cron tests that pinned `thread` for Slack channels
  asserted the wrong shape and now assert the adapter's.
- Dropped from #108367: the `chat_type="thread"` inbound rekey (see above) and its tests;
  contributor tests trimmed to two invariants.
- Docs: Matrix listed among thread-capable handoff/cron platforms.

Live probe (in-process, real MatrixAdapter + real handoff destination + real cron seeder):
before  create_handoff_thread -> None; handoff key matrix🧵… ≠ inbound matrix:group:…;
        cron seed matrix🧵… ≠ inbound
after   create_handoff_thread -> '$seed'; handoff == inbound == cron seed (room and DM cases)

Co-authored-by: ea-s21 <190767603+ea-s21@users.noreply.github.com>
2026-09-16 17:18:44 -07:00

56 lines
2.1 KiB
Python

"""Tests for MatrixAdapter.create_handoff_thread.
Matrix has no channel-level "create thread" API, so the adapter seeds a root
message and returns its event_id as the thread handle (Slack-style — mirrors
``tests/gateway/test_slack_sdk_response.py::TestHandoffThread``). These tests
cover: the seed event id becomes the thread id (and the root is registered as a
participated thread), and ``None`` is returned when the seed send fails (#112918).
"""
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock
from gateway.config import PlatformConfig
from gateway.platforms.base import SendResult
def _make_adapter():
"""Create a MatrixAdapter with mocked config (no live client)."""
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
token="syt_test_token",
extra={
"homeserver": "https://matrix.example.org",
"user_id": "@hermes:example.org",
},
)
adapter = MatrixAdapter(config)
adapter._startup_ts = time.time() - 10
return adapter
class TestHandoffThread:
"""``create_handoff_thread`` anchors a session on the seed event's id."""
def test_seed_event_becomes_the_thread_id(self):
adapter = _make_adapter()
adapter._client = MagicMock() # truthy: a client is connected
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="$root_evt"))
thread_id = asyncio.run(adapter.create_handoff_thread("!room1:example.org", "Hermes — daily brief"))
assert thread_id == "$root_evt"
adapter.send.assert_awaited_once_with("!room1:example.org", "Hermes — daily brief")
# Root registered so inbound replies in this thread are recognised.
assert "$root_evt" in adapter._threads
def test_failed_seed_send_yields_no_thread(self):
adapter = _make_adapter()
adapter._client = MagicMock()
adapter.send = AsyncMock(return_value=SendResult(success=False, error="boom"))
assert asyncio.run(adapter.create_handoff_thread("!room1:example.org", "x")) is None