|
@@ -1388,11 +1388,11 @@ def _roster_user(user_id, login, presence=PresenceStatus.UNSPECIFIED, deleted=Fa
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRoomRoster:
|
|
class TestRoomRoster:
|
|
|
- """The first turn in a room carries the member roster in channel_context —
|
|
|
|
|
- the agent's prompt is the only place it could learn who else is listening.
|
|
|
|
|
- The roster is a room-scoped mini projection: presence changes patch the
|
|
|
|
|
- cached users and re-render without any API call; membership events and
|
|
|
|
|
- reconnects discard a room so its next turn refetches once."""
|
|
|
|
|
|
|
+ """Every channel turn carries the member roster in channel_context —
|
|
|
|
|
+ threads hold isolated sessions, so each turn needs its own copy. The
|
|
|
|
|
+ roster is a room-scoped mini projection: fetched once, presence changes
|
|
|
|
|
+ patch the cached users in place; membership events and reconnects discard
|
|
|
|
|
+ a room so its next turn refetches once."""
|
|
|
|
|
|
|
|
def _adapter(self):
|
|
def _adapter(self):
|
|
|
adapter = _make_adapter()
|
|
adapter = _make_adapter()
|
|
@@ -1463,7 +1463,9 @@ class TestRoomRoster:
|
|
|
assert "@hermes_bot" not in context
|
|
assert "@hermes_bot" not in context
|
|
|
assert "carol" not in context
|
|
assert "carol" not in context
|
|
|
|
|
|
|
|
- async def test_thread_follow_up_does_not_repeat_it(self):
|
|
|
|
|
|
|
+ async def test_follow_up_carries_the_roster_too(self):
|
|
|
|
|
+ """Threads hold isolated sessions, so every channel turn needs its own
|
|
|
|
|
+ copy of the roster — repetition is the point, deduplication a bug."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
self._seed_channel(adapter)
|
|
|
adapter._chatto_client.list_room_members = AsyncMock(
|
|
adapter._chatto_client.list_room_members = AsyncMock(
|
|
@@ -1478,8 +1480,11 @@ class TestRoomRoster:
|
|
|
thread_root="msg-1",
|
|
thread_root="msg-1",
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ # Still one directory lookup: the projection spares the API, not the
|
|
|
|
|
+ # prompt.
|
|
|
assert adapter._chatto_client.list_room_members.await_count == 1
|
|
assert adapter._chatto_client.list_room_members.await_count == 1
|
|
|
- assert self._dispatched_event(adapter).channel_context is None
|
|
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context
|
|
|
|
|
+ assert context is not None and "@alice" in context
|
|
|
|
|
|
|
|
async def test_unspecified_room_kind_still_gets_one(self):
|
|
async def test_unspecified_room_kind_still_gets_one(self):
|
|
|
"""A server that never sets kind counts as a channel — roster too."""
|
|
"""A server that never sets kind counts as a channel — roster too."""
|
|
@@ -1572,9 +1577,9 @@ class TestRoomRoster:
|
|
|
assert adapter.handle_message.await_count == 1
|
|
assert adapter.handle_message.await_count == 1
|
|
|
assert "@alice" in self._dispatched_event(adapter).channel_context
|
|
assert "@alice" in self._dispatched_event(adapter).channel_context
|
|
|
|
|
|
|
|
- async def test_presence_change_redelivers_updated_roster_from_cache(self):
|
|
|
|
|
|
|
+ async def test_presence_change_shows_on_next_turn_from_cache(self):
|
|
|
"""Presence moves patch the cached user; the next turn in the room
|
|
"""Presence moves patch the cached user; the next turn in the room
|
|
|
- re-renders the line from cache — no second directory lookup."""
|
|
|
|
|
|
|
+ renders the updated line — no second directory lookup."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
self._seed_channel(adapter)
|
|
|
# alice starts UNSPECIFIED (no presence label in the rendered entry).
|
|
# alice starts UNSPECIFIED (no presence label in the rendered entry).
|
|
@@ -1599,19 +1604,9 @@ class TestRoomRoster:
|
|
|
context = self._dispatched_event(adapter).channel_context
|
|
context = self._dispatched_event(adapter).channel_context
|
|
|
assert context is not None and "@alice (Alice, offline)" in context
|
|
assert context is not None and "@alice (Alice, offline)" in context
|
|
|
|
|
|
|
|
- # Unchanged since, a further turn delivers nothing.
|
|
|
|
|
- await self._dispatch(
|
|
|
|
|
- adapter,
|
|
|
|
|
- "@hermes_bot third",
|
|
|
|
|
- message_id="msg-3",
|
|
|
|
|
- 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_presence_churn_to_the_same_status_delivers_once(self):
|
|
|
|
|
- """Rapid away/offline flapping that ends where it started costs
|
|
|
|
|
- dictionary writes only and never repeats the channel_context."""
|
|
|
|
|
|
|
+ async def test_presence_churn_costs_no_extra_lookups(self):
|
|
|
|
|
+ """Rapid away/offline flapping is absorbed by the cache: the roster
|
|
|
|
|
+ still rides on every turn, but the directory is never re-asked."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
self._seed_channel(adapter)
|
|
|
adapter._chatto_client.list_room_members = AsyncMock(
|
|
adapter._chatto_client.list_room_members = AsyncMock(
|
|
@@ -1624,8 +1619,6 @@ class TestRoomRoster:
|
|
|
await adapter._handle_realtime_event(
|
|
await adapter._handle_realtime_event(
|
|
|
_make_presence_event(user_id="user-1", status=PresenceStatus.AWAY)
|
|
_make_presence_event(user_id="user-1", status=PresenceStatus.AWAY)
|
|
|
)
|
|
)
|
|
|
- adapter.handle_message.reset_mock()
|
|
|
|
|
-
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
adapter,
|
|
adapter,
|
|
|
"@hermes_bot again",
|
|
"@hermes_bot again",
|
|
@@ -1633,20 +1626,12 @@ class TestRoomRoster:
|
|
|
thread_root="msg-1",
|
|
thread_root="msg-1",
|
|
|
)
|
|
)
|
|
|
assert adapter._chatto_client.list_room_members.await_count == 1
|
|
assert adapter._chatto_client.list_room_members.await_count == 1
|
|
|
- assert self._dispatched_event(adapter).channel_context is not None
|
|
|
|
|
- assert "(Alice, away)" in self._dispatched_event(adapter).channel_context
|
|
|
|
|
-
|
|
|
|
|
- await self._dispatch(
|
|
|
|
|
- adapter,
|
|
|
|
|
- "@hermes_bot third",
|
|
|
|
|
- message_id="msg-3",
|
|
|
|
|
- thread_root="msg-1",
|
|
|
|
|
- )
|
|
|
|
|
- assert self._dispatched_event(adapter).channel_context is None
|
|
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context
|
|
|
|
|
+ assert context is not None and "(Alice, away)" in context
|
|
|
|
|
|
|
|
- async def test_non_member_presence_change_does_not_redeliver(self):
|
|
|
|
|
- """A presence move by someone outside the announced roster renders the
|
|
|
|
|
- same line — nothing goes out."""
|
|
|
|
|
|
|
+ async def test_non_member_presence_change_does_not_alter_the_line(self):
|
|
|
|
|
+ """A presence move by someone outside the roster projection leaves the
|
|
|
|
|
+ rendered line untouched — but the turn still carries it."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
self._seed_channel(adapter)
|
|
|
adapter._chatto_client.list_room_members = AsyncMock(
|
|
adapter._chatto_client.list_room_members = AsyncMock(
|
|
@@ -1666,10 +1651,12 @@ class TestRoomRoster:
|
|
|
message_id="msg-2",
|
|
message_id="msg-2",
|
|
|
thread_root="msg-1",
|
|
thread_root="msg-1",
|
|
|
)
|
|
)
|
|
|
- assert self._dispatched_event(adapter).channel_context is None
|
|
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context
|
|
|
|
|
+ assert context is not None and "mallory" not in context
|
|
|
|
|
|
|
|
async def test_own_presence_change_is_ignored(self):
|
|
async def test_own_presence_change_is_ignored(self):
|
|
|
- """Our 60s presence refresh must neither patch nor redeliver."""
|
|
|
|
|
|
|
+ """Our 60s presence refresh patches nothing — the agent must not see
|
|
|
|
|
+ the bot in its own audience."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
self._seed_channel(adapter)
|
|
|
adapter._chatto_client.list_room_members = AsyncMock(
|
|
adapter._chatto_client.list_room_members = AsyncMock(
|
|
@@ -1685,13 +1672,6 @@ class TestRoomRoster:
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
assert bot.presence_status == PresenceStatus.ONLINE
|
|
assert bot.presence_status == PresenceStatus.ONLINE
|
|
|
- await self._dispatch(
|
|
|
|
|
- adapter,
|
|
|
|
|
- "@hermes_bot again",
|
|
|
|
|
- message_id="msg-2",
|
|
|
|
|
- thread_root="msg-1",
|
|
|
|
|
- )
|
|
|
|
|
- assert self._dispatched_event(adapter).channel_context is None
|
|
|
|
|
|
|
|
|
|
async def test_presence_change_before_any_announcement_is_noop(self):
|
|
async def test_presence_change_before_any_announcement_is_noop(self):
|
|
|
"""No room announced yet — a presence event must not raise or fetch,
|
|
"""No room announced yet — a presence event must not raise or fetch,
|
|
@@ -1705,7 +1685,7 @@ class TestRoomRoster:
|
|
|
assert adapter._chatto_client.list_room_members.await_count == 0
|
|
assert adapter._chatto_client.list_room_members.await_count == 0
|
|
|
assert "user-42" not in adapter._user_cache
|
|
assert "user-42" not in adapter._user_cache
|
|
|
|
|
|
|
|
- async def test_membership_event_refetches_and_redelivers_once(self):
|
|
|
|
|
|
|
+ async def test_membership_event_refetches_once(self):
|
|
|
"""A join/leave makes the cached roster wrong: it is discarded and the
|
|
"""A join/leave makes the cached roster wrong: it is discarded and the
|
|
|
next turn refetches once, delivering the updated line."""
|
|
next turn refetches once, delivering the updated line."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
@@ -1746,8 +1726,10 @@ class TestRoomRoster:
|
|
|
message_id="msg-3",
|
|
message_id="msg-3",
|
|
|
thread_root="msg-1",
|
|
thread_root="msg-1",
|
|
|
)
|
|
)
|
|
|
|
|
+ # Still one refetch only — but the roster keeps riding on every turn.
|
|
|
assert adapter._chatto_client.list_room_members.await_count == 2
|
|
assert adapter._chatto_client.list_room_members.await_count == 2
|
|
|
- assert self._dispatched_event(adapter).channel_context is None
|
|
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context
|
|
|
|
|
+ assert context is not None and "@bob" in context
|
|
|
|
|
|
|
|
async def test_reconnect_discards_cached_rosters_for_a_fresh_snapshot(self):
|
|
async def test_reconnect_discards_cached_rosters_for_a_fresh_snapshot(self):
|
|
|
"""Protocol v1 sends no presence snapshot on subscribe, so every
|
|
"""Protocol v1 sends no presence snapshot on subscribe, so every
|
|
@@ -1766,7 +1748,6 @@ class TestRoomRoster:
|
|
|
await adapter._refresh_rooms()
|
|
await adapter._refresh_rooms()
|
|
|
assert not adapter._roster_announced
|
|
assert not adapter._roster_announced
|
|
|
assert not adapter._rosters
|
|
assert not adapter._rosters
|
|
|
- assert not adapter._roster_text
|
|
|
|
|
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
adapter,
|
|
adapter,
|
|
@@ -1796,7 +1777,6 @@ class TestRoomRoster:
|
|
|
|
|
|
|
|
assert "gone-1" not in adapter._roster_announced
|
|
assert "gone-1" not in adapter._roster_announced
|
|
|
assert "gone-1" not in adapter._rosters
|
|
assert "gone-1" not in adapter._rosters
|
|
|
- assert "gone-1" not in adapter._roster_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- Inbound edits (edit-dispatch) --
|
|
# -- Inbound edits (edit-dispatch) --
|
|
@@ -2430,7 +2410,6 @@ class TestDmRoomCommands:
|
|
|
adapter._joined_room_ids = ["room-7"]
|
|
adapter._joined_room_ids = ["room-7"]
|
|
|
adapter._roster_announced.append("room-7")
|
|
adapter._roster_announced.append("room-7")
|
|
|
adapter._rosters["room-7"] = MagicMock()
|
|
adapter._rosters["room-7"] = MagicMock()
|
|
|
- adapter._roster_text["room-7"] = "@alice"
|
|
|
|
|
state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
|
|
state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
|
|
|
adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
|
|
|
|
@@ -2441,7 +2420,6 @@ class TestDmRoomCommands:
|
|
|
# Leaving the audience means keeping no roster projection for it.
|
|
# Leaving the audience means keeping no roster projection for it.
|
|
|
assert "room-7" not in adapter._roster_announced
|
|
assert "room-7" not in adapter._roster_announced
|
|
|
assert "room-7" not in adapter._rosters
|
|
assert "room-7" not in adapter._rosters
|
|
|
- assert "room-7" not in adapter._roster_text
|
|
|
|
|
assert "Left 'Deploy' (room-7)" in self._reply(adapter)
|
|
assert "Left 'Deploy' (room-7)" in self._reply(adapter)
|
|
|
|
|
|
|
|
async def test_leave_refuses_direct_messages(self):
|
|
async def test_leave_refuses_direct_messages(self):
|