|
|
@@ -1373,6 +1373,205 @@ class TestRoomPolicies:
|
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
|
|
+# -- Room roster on channel_context --
|
|
|
+
|
|
|
+
|
|
|
+def _roster_user(user_id, login, presence=PresenceStatus.UNSPECIFIED, deleted=False):
|
|
|
+ """A directory user as list_room_members() would return it."""
|
|
|
+ return User(
|
|
|
+ id=user_id,
|
|
|
+ login=login,
|
|
|
+ display_name=login.replace("_", " ").title(),
|
|
|
+ presence_status=presence,
|
|
|
+ deleted=deleted,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class TestRoomRoster:
|
|
|
+ """The dispatch that opens a channel thread carries the member roster in
|
|
|
+ channel_context — the agent's prompt is the only place it could learn who
|
|
|
+ else is listening. Once per thread: follow-ups and edited roots never
|
|
|
+ re-announce."""
|
|
|
+
|
|
|
+ def _adapter(self):
|
|
|
+ adapter = _make_adapter()
|
|
|
+ adapter.chatto_config.allow_all_users.value = True
|
|
|
+ adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
+ adapter._user_cache["user-1"] = _roster_user("user-1", "alice")
|
|
|
+ adapter.handle_message = AsyncMock()
|
|
|
+ return adapter
|
|
|
+
|
|
|
+ def _seed_channel(self, adapter, room_id="room-1"):
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = [room_id]
|
|
|
+ adapter._room_kinds[room_id] = RoomKind.CHANNEL
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _members(*users, total_count=None):
|
|
|
+ members = [DirectoryMember(user=user) for user in users]
|
|
|
+ page = Page(total_count=total_count or len(members))
|
|
|
+ return members, page
|
|
|
+
|
|
|
+ async def _dispatch(
|
|
|
+ self,
|
|
|
+ adapter,
|
|
|
+ body,
|
|
|
+ *,
|
|
|
+ room_id="room-1",
|
|
|
+ message_id="msg-1",
|
|
|
+ thread_root=None,
|
|
|
+ ):
|
|
|
+ payload = _make_posted_payload(room_id=room_id, message_event_id=message_id)
|
|
|
+ payload.thread_root_event_id = thread_root
|
|
|
+ payload.fetch_message = AsyncMock(
|
|
|
+ return_value=_make_message(
|
|
|
+ body=body, room_id=room_id, message_id=message_id
|
|
|
+ )
|
|
|
+ )
|
|
|
+ await adapter._dispatch_message_posted(payload)
|
|
|
+
|
|
|
+ async def _edit(self, adapter, body, *, message_id="msg-1"):
|
|
|
+ event, payload = _make_edited_event(
|
|
|
+ room_id="room-1", message_event_id=message_id
|
|
|
+ )
|
|
|
+ payload.fetch_message = AsyncMock(
|
|
|
+ return_value=_make_message(body=body, message_id=message_id)
|
|
|
+ )
|
|
|
+ await adapter._handle_realtime_event(event)
|
|
|
+
|
|
|
+ def _dispatched_event(self, adapter):
|
|
|
+ return adapter.handle_message.await_args.args[0]
|
|
|
+
|
|
|
+ async def test_new_thread_carries_the_roster(self):
|
|
|
+ adapter = self._adapter()
|
|
|
+ self._seed_channel(adapter)
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
+ return_value=self._members(
|
|
|
+ _roster_user("user-1", "alice", PresenceStatus.ONLINE),
|
|
|
+ _roster_user("user-2", "bob", PresenceStatus.OFFLINE),
|
|
|
+ _roster_user("bot-user-id", "hermes_bot"),
|
|
|
+ _roster_user("user-3", "carol", deleted=True),
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "@hermes_bot hi")
|
|
|
+
|
|
|
+ context = self._dispatched_event(adapter).channel_context
|
|
|
+ assert "@alice (Alice, online)" in context
|
|
|
+ assert "@bob (Bob, offline)" in context
|
|
|
+ # Our own account and deleted users are nobody the agent must greet.
|
|
|
+ assert "@hermes_bot" not in context
|
|
|
+ assert "carol" not in context
|
|
|
+
|
|
|
+ async def test_thread_follow_up_does_not_repeat_it(self):
|
|
|
+ adapter = self._adapter()
|
|
|
+ self._seed_channel(adapter)
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
+ return_value=self._members(_roster_user("user-1", "alice"))
|
|
|
+ )
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "@hermes_bot hi")
|
|
|
+ await self._dispatch(
|
|
|
+ adapter,
|
|
|
+ "@hermes_bot and one more thing",
|
|
|
+ message_id="msg-2",
|
|
|
+ thread_root="msg-1",
|
|
|
+ )
|
|
|
+
|
|
|
+ assert adapter._chatto_client.list_room_members.await_count == 1
|
|
|
+ assert self._dispatched_event(adapter).channel_context is None
|
|
|
+
|
|
|
+ async def test_unspecified_room_kind_still_gets_one(self):
|
|
|
+ """A server that never sets kind counts as a channel — roster too."""
|
|
|
+ adapter = self._adapter()
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = ["room-1"]
|
|
|
+ adapter._room_kinds["room-1"] = RoomKind.UNSPECIFIED
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
+ return_value=self._members(_roster_user("user-1", "alice"))
|
|
|
+ )
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "@hermes_bot hi")
|
|
|
+
|
|
|
+ assert "@alice" in self._dispatched_event(adapter).channel_context
|
|
|
+
|
|
|
+ async def test_dm_never_carries_a_roster(self):
|
|
|
+ adapter = self._adapter()
|
|
|
+ adapter._room_kinds["dm-1"] = RoomKind.DM
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock()
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "hi there", room_id="dm-1")
|
|
|
+
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
+ adapter._chatto_client.list_room_members.assert_not_awaited()
|
|
|
+ assert self._dispatched_event(adapter).channel_context is None
|
|
|
+
|
|
|
+ async def test_directory_failure_still_dispatches(self):
|
|
|
+ adapter = self._adapter()
|
|
|
+ self._seed_channel(adapter)
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
+ side_effect=RuntimeError("directory down")
|
|
|
+ )
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "@hermes_bot hi")
|
|
|
+
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
+ assert self._dispatched_event(adapter).channel_context is None
|
|
|
+
|
|
|
+ async def test_large_room_notes_the_unfetched_rest(self):
|
|
|
+ adapter = self._adapter()
|
|
|
+ self._seed_channel(adapter)
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
+ return_value=self._members(
|
|
|
+ _roster_user("user-1", "alice"),
|
|
|
+ _roster_user("user-2", "bob"),
|
|
|
+ total_count=150,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "@hermes_bot hi")
|
|
|
+
|
|
|
+ assert "… and 148 more" in self._dispatched_event(adapter).channel_context
|
|
|
+
|
|
|
+ async def test_late_mention_edit_announces_exactly_once(self):
|
|
|
+ """A forgotten mention added by edit opens a fresh turn — with the
|
|
|
+ roster; the already-answered lock then keeps further edits out."""
|
|
|
+ adapter = self._adapter()
|
|
|
+ self._seed_channel(adapter)
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
+ return_value=self._members(_roster_user("user-1", "alice"))
|
|
|
+ )
|
|
|
+
|
|
|
+ await self._edit(adapter, "no mention yet")
|
|
|
+ await self._edit(adapter, "@hermes_bot now it counts")
|
|
|
+ await self._edit(adapter, "@hermes_bot once more")
|
|
|
+
|
|
|
+ assert adapter.handle_message.await_count == 1
|
|
|
+ assert adapter._chatto_client.list_room_members.await_count == 1
|
|
|
+
|
|
|
+ async def test_announcement_guard_is_not_rearmed_by_empty_rosters(self):
|
|
|
+ """A failed lookup must not burn the thread's one announcement."""
|
|
|
+ adapter = self._adapter()
|
|
|
+ self._seed_channel(adapter)
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(return_value=([], Page()))
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "@hermes_bot hi")
|
|
|
+ assert self._dispatched_event(adapter).channel_context is None
|
|
|
+
|
|
|
+ adapter.handle_message.reset_mock()
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
+ return_value=self._members(_roster_user("user-1", "alice"))
|
|
|
+ )
|
|
|
+ payload = _make_posted_payload(room_id="room-1", message_event_id="msg-9")
|
|
|
+ payload.fetch_message = AsyncMock(
|
|
|
+ return_value=_make_message(
|
|
|
+ body="@hermes_bot retry", room_id="room-1", message_id="msg-9"
|
|
|
+ )
|
|
|
+ )
|
|
|
+ await adapter._dispatch_message_posted(payload)
|
|
|
+
|
|
|
+ assert adapter.handle_message.await_count == 1
|
|
|
+ assert "@alice" in self._dispatched_event(adapter).channel_context
|
|
|
+
|
|
|
+
|
|
|
# -- Inbound edits (edit-dispatch) --
|
|
|
|
|
|
|