Kaynağa Gözat

Render the room roster from a cached projection, presence patched live

The previous approach re-fetched the member directory whenever a
presence_changed event marked an announced room stale — with real-world
presence churn that meant one list_room_members call per inbound message
after any flip, and every room was invalidated regardless of who moved.

Rework it as a miniature projection of server state, modelled on how the
Chatto web frontend handles presence (per-user cache patched in place,
member lists never re-fetched): each announced room keeps member IDs
plus its users in the existing _user_cache and the last rendered line.
presence_changed patches the cached user; the next turn in the room
re-renders from cache and re-delivers only when the line actually
changed. Membership events (user_joined_room/user_left_room), /leave,
stale-room pruning and reconnects discard a room's projection so its
next turn refetches once - protocol v1 has no presence snapshot on
subscribe, so discarding is the bootstrap.

Also corrects the event router: projection_event belongs to realtime
protocol v2 on unreleased Chatto main and can never arrive over
chattolib v1; noted where to revisit it.

AGENTS.md gains two guidance entries born from this thread: check
existing state fields before adding new ones (_user_cache instead of a
second user store), and treat the Chatto web frontend as the
architecture reference for client-side behaviour.
Paul Klumpp 1 hafta önce
ebeveyn
işleme
8af1cef5c3
4 değiştirilmiş dosya ile 417 ekleme ve 98 silme
  1. 33 8
      AGENTS.md
  2. 3 3
      README.md
  3. 177 65
      adapter.py
  4. 204 22
      test_adapter.py

+ 33 - 8
AGENTS.md

@@ -21,6 +21,12 @@ is unclear on our side, the Chatto server source is the reference:
 git clone https://github.com/chattocorp/chatto.git
 ```
 
+The same repo's web frontend (`apps/frontend`) doubles as the architecture
+reference for client-side concerns — presence handling, caches, realtime
+consumption — because the bot is a Chatto client just like the browser. Read
+how the platform itself models a problem before inventing our own shape (its
+presence-overlay pattern is what shaped the roster projection below).
+
 The vendored copy is upstream `chattolib[realtime]` 0.4.20.post1 (realtime
 protocol v1); the matching server floor is Chatto v0.4.20. Refreshing `vendor/`
 via `vendor_chattolib.sh` can move that floor (see VENDORING.md) — after each
@@ -92,14 +98,19 @@ changes must keep them, or consciously renegotiate the docs.**
 - **Room roster is room-scoped and presence-live.** The first turn in a room
   carries the member roster in `channel_context` so the agent knows its
   audience; the roster is **not** thread-scoped and not delivered on every
-  turn. It is re-fetched only after a `presence_changed` event marks the room
-  stale, and that refresh lands on the *next* inbound turn in the room (the
-  agent only ever sees `channel_context` at dispatch, so there is nothing to
-  push between turns). The bot's own 60s presence refresh is filtered out so it
-  never churns announced rooms; a failed lookup leaves the room unannounced and
-  lets the next turn retry. This is the fix for the old "roster frozen at thread
-  start" flaw: presence moves (away/offline) within a long-lived room now catch
-  up on the next message.
+  turn. Per announced room the adapter keeps a miniature projection of server
+  state — member IDs plus their cached users (`_user_cache`, one cache, one
+  truth) and the last rendered line. `presence_changed` patches the cached
+  user in place; the next turn in that room re-renders from cache and
+  re-delivers only when the line actually changed, so presence churn costs no
+  API calls. `user_joined_room`/`user_left_room` discard a room's projection
+  (its next turn refetches once), as does a reconnect — protocol v1 sends no
+  presence snapshot on subscribe, so discarding is what forces fresh data
+  after downtime; refetching is lazy, quiet rooms stay free of lookups. The
+  bot's own presence events are filtered out. A failed lookup leaves the room
+  unannounced and lets the next turn retry. This is the fix for the old
+  "roster frozen at thread start" flaw: presence moves (away/offline) within
+  a long-lived room now catch up on the next message.
 - **Edits are corrections, not new traffic.** An inbound `message_edited`
   re-runs the admission gates against the new body: a message currently being
   processed is cancelled (🚫) and redispatched with the edited text; a queued
@@ -255,6 +266,20 @@ one job are one too many, because they drift silently until behaviour
 differs (an uncapped downloader next to a capped one shipped here for
 months).
 
+**State questions: check what exists before adding.** The sweep above catches
+dead state after the fact; the cheaper move is to not create parallel state
+in the first place. Before introducing a new instance field, look through the
+adapter's existing state for something that already carries the data —
+`_user_cache` holds every known user (the roster projection stores only room
+membership IDs and reads users from there), `_room_names`/`_room_kinds` carry
+room metadata filled by `_refresh_rooms`. Two caches of one truth drift
+silently until behaviour differs. And when the question is *how to model*
+state or behaviour at all, read how the Chatto web frontend (`apps/frontend`
+in the repo cloned under *What this plugin is*) solves it first — its
+presence-overlay pattern (per-user cache patched by `presence_changed`, never
+re-fetching member lists) is the reference implementation of exactly the
+roster design in the behavioural contract below.
+
 ## Configuration surface
 
 **One field, three coordinated names — declared once on `ChattoConfiguration`.**

+ 3 - 3
README.md

@@ -15,7 +15,7 @@ Before setup, here's the part most people want to know: how Hermes behaves once
 | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
 | **Rooms** | Opt-in per room: a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` gets answers only when Hermes is addressed (`@mention`, `@all`, `@here`); one listed in `CHATTO_OPTIONAL_MENTION_ROOMS` gets an answer for every message. A room on neither list stays silent — read-only. |
 | **Threads** | If you reply in a thread, Hermes keeps the thread context isolated from the parent room. The bot auto-follows threads it participates in. |
-| **Room awareness** | The first turn in a room comes with the member roster attached (who is in the room, with presence), so Hermes knows his audience without asking. The roster refreshes on the next turn after a participant's presence changes, so going `away`/`offline` is reflected without the room going quiet first. DMs never carry one. |
+| **Room awareness** | The first turn in a room comes with the member roster attached (who is in the room, with presence), so Hermes knows his audience without asking. Presence changes flow into the cached roster live and are re-delivered on the next turn only when the rendered line actually changed — going `away`/`offline` is reflected without extra directory lookups. DMs never carry one. |
 | **Processing indicators** | Hermes adds a 👀 reaction when it starts processing a message, and replaces it with ✅ on success, ❌ on failure, or 🚫 when processing was cancelled. |
 | **Editing your messages** | An edit within 5 minutes of posting counts as a correction: while Hermes is still working on the original, he restarts with the edited text (🚫 → 👀); if he had ignored or not yet answered the message, the gates are re-checked against the new text — adding a forgotten `@mention` this way works. Edits to messages Hermes has already answered change nothing. Disable with `CHATTO_EDIT_DISPATCH=false`. |
 | **Typing indicators** | Hermes broadcasts persistent typing indicators while it's working, so users know the bot is active. |
@@ -44,7 +44,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 | room creation | yes |
 | room join/leave over DM | yes (`/join`, `/leave`) |
 | member directory | yes (cached) |
-| room member context | yes (roster on the first turn of each room, refreshed on presence change) |
+| room member context | yes (roster on the first turn of each room, presence kept live from cached members) |
 | presence broadcasting | yes (online, refreshed) |
 | custom status | yes |
 | read state management | yes |
@@ -210,7 +210,7 @@ When you reply to a message in Chatto (creating a thread), Hermes responds withi
 
 Hermes cannot see who else is in a room: unaddressed channel messages are dropped by the mention gates long before they could teach him a name. So the first turn in a room carries the member roster alongside the message text — one entry per member as `@login (Display Name, presence)`, e.g. `@alice (Alice, online), @bob (Bob, offline)`. The bot's own account and deleted users are left out; rooms larger than 100 members end with `… and N more`. Presence is the Chatto status (`online`, `away`, `do not disturb`, `offline`).
 
-The roster is room-scoped, not thread-scoped: the first turn in a room brings it, and a later turn in that same room brings none again — until a participant's presence changes. On a `presence_changed` event the room's roster is marked stale and re-fetched on the next inbound turn there, so the agent learns about someone going `away` or `offline` without the room going quiet first. The bot's own presence refresh never triggers this. If the member lookup fails, the turn proceeds without the roster; the room then counts as unannounced and the next turn in it (e.g. after adding a forgotten `@mention` by editing the root post) tries again.
+The roster is room-scoped, not thread-scoped: the first turn in a room brings it, and later turns bring nothing again until the rendered line actually changes. The adapter keeps a small projection per announced room — member IDs plus their cached users — and `presence_changed` events patch those users in place, so someone going `away` or `offline` shows up on the next turn in that room without any extra directory lookup; churn that ends without a visible change repeats nothing. The bot's own presence refresh never enters the roster. Membership changes (`user_joined_room`/`user_left_room`) and reconnects discard the projection, and the room's next turn refetches once — protocol v1 sends no presence snapshot on subscribe, so a discarded cache is what forces fresh data after downtime. If the initial lookup fails, the turn proceeds without the roster and the next turn tries again.
 
 ### Reactions
 

+ 177 - 65
adapter.py

@@ -31,6 +31,7 @@ import os
 import re
 import tempfile
 from collections import deque
+from dataclasses import dataclass, field
 from datetime import UTC, datetime
 from difflib import SequenceMatcher
 from enum import StrEnum
@@ -81,9 +82,9 @@ try:
         MessagePostedPayload,
         PresenceChangedPayload,
         ReactionPayload,
+        RoomEventPayload,
     )
     from chattolib.types import (
-        DirectoryMember,
         Message,
         MessageAttachment,
         PresenceStatus,
@@ -274,6 +275,21 @@ _PRESENCE_LABELS = {
 }
 
 
+@dataclass
+class _RoomRoster:
+    """One announced room's roster projection.
+
+    A miniature of the server's room membership: which user IDs belong to
+    the room (the users themselves live in the shared ``_user_cache``) and
+    how many directory entries were beyond the fetch limit, rendered as
+    ``… and N more``. Presence changes patch the cached users in place;
+    only a membership change or reconnect discards this and refetches.
+    """
+
+    member_ids: set[str] = field(default_factory=set)
+    unfetched: int = 0
+
+
 def hermes_adapter_factory(config: PlatformConfig):
     """Construct a ChattoAdapter from a PlatformConfig."""
     return ChattoAdapter(config)
@@ -323,12 +339,17 @@ class ChattoAdapter(BasePlatformAdapter):
         self._dispatched_ids: deque[str] = deque(maxlen=ChattoConstants.SEEN_CAP)
         # Rooms whose member roster has already been announced to the agent via
         # channel_context — the roster is room-scoped, so the first turn in a
-        # room carries it and only a presence change re-delivers it.
+        # room carries it. Together with _rosters/_roster_text this is our
+        # miniature projection of server state: presence changes patch the
+        # cached users in place and the next turn re-renders from cache;
+        # membership events and reconnects discard a room so its next turn
+        # refetches once.
         self._roster_announced: deque[str] = deque(maxlen=ChattoConstants.SEEN_CAP)
-        # Rooms whose announced roster is stale after a presence_changed event
-        # and must be re-fetched on the next turn there. Lazy: the refresh
-        # happens when an inbound message actually arrives, not on the event.
-        self._roster_dirty: set[str] = set()
+        # Announced rooms' membership projection (see _RoomRoster).
+        self._rosters: dict[str, _RoomRoster] = {}
+        # Announced rooms' last delivered roster text — the dedup key that
+        # keeps presence churn from repeating an unchanged channel_context.
+        self._roster_text: dict[str, str] = {}
         # session_key -> message ID currently being processed there. Written
         # by on_processing_start, cleared by on_processing_complete; an edit
         # landing on the recorded ID is a mid-run correction.
@@ -884,6 +905,8 @@ class ChattoAdapter(BasePlatformAdapter):
             return f"Chatto refused to leave {label}."
         if room_obj.id in self._joined_room_ids:
             self._joined_room_ids.remove(room_obj.id)
+        # We are no longer part of this audience; keep no roster for it.
+        self._evict_roster(room_obj.id)
         return f"Left {label}."
 
     def _room_join_hint(self, room_id: str) -> str:
@@ -1071,13 +1094,17 @@ class ChattoAdapter(BasePlatformAdapter):
             return True
         return self._room_policy(room_id) != RoomPolicy.SILENT
 
-    async def _room_roster_context(self, client: ChattoClient, room_id: str) -> str:
-        """The channel's member roster as one context line, "" on any failure.
+    async def _room_roster_context(
+        self, client: ChattoClient, room_id: str
+    ) -> tuple[_RoomRoster, str] | None:
+        """Fetch the room's member roster as a fresh projection + rendered line.
 
         The agent only ever sees its prompt: without this block it cannot know
         who else is in a channel, because unaddressed messages are dropped by
-        mention gating long before they could teach it a name. Best-effort by
-        design — a directory hiccup must never cost the turn itself.
+        mention gating long before they could teach it a name. Fetched users go
+        into _user_cache so presence patching and mention resolution share one
+        store. Returns ``None`` on failure or when nothing renderable remains —
+        best-effort by design, a directory hiccup must never cost the turn.
         """
         try:
             members, page = await client.list_room_members(
@@ -1087,17 +1114,25 @@ class ChattoAdapter(BasePlatformAdapter):
             logger.debug(
                 "Chatto: could not list members of room %s", room_id, exc_info=True
             )
-            return ""
+            return None
         logger.debug(
             "Chatto: roster for room %s: %d members fetched, total_count=%d",
             room_id,
             len(members),
             page.total_count,
         )
+
+        member_ids: set[str] = set()
+        users: list[User] = []
+        for member in members:
+            user = member.user
+            if user is None or user.deleted:
+                continue
+            member_ids.add(user.id)
+            self._user_cache[user.id] = user
+            users.append(user)
         entries = [
-            entry
-            for entry in (self._roster_entry(member) for member in members)
-            if entry
+            entry for entry in (self._roster_entry(user) for user in users) if entry
         ]
         if not entries:
             logger.debug(
@@ -1106,20 +1141,22 @@ class ChattoAdapter(BasePlatformAdapter):
                 room_id,
                 len(members),
             )
-            return ""
+            return None
 
         unfetched = max(0, page.total_count - len(members))
         more = f" … and {unfetched} more" if unfetched else ""
-        return ", ".join(entries) + more
+        return (
+            _RoomRoster(member_ids=member_ids, unfetched=unfetched),
+            ", ".join(entries) + more,
+        )
 
-    def _roster_entry(self, member: DirectoryMember) -> str:
+    def _roster_entry(self, user: User) -> str:
         """One roster entry, e.g. ``@bob (Bob Example, online)``.
 
         Skips deleted users and our own account (the agent knows itself);
         display name and presence are optional parts of the parenthetical.
         """
-        user = member.user
-        if user is None or user.deleted:
+        if user.deleted:
             return ""
         if self.me is not None and user.id == self.me.id:
             return ""
@@ -1136,31 +1173,81 @@ class ChattoAdapter(BasePlatformAdapter):
             entry += f" ({', '.join(details)})"
         return entry
 
-    async def _roster_for_room(self, client: ChattoClient, *, room_id: str) -> str:
-        """Roster context for this room — once, then only on presence change.
+    def _roster_line(self, room_id: str) -> str:
+        """Render the room's roster line from its cached projection."""
+        roster = self._rosters.get(room_id)
+        if roster is None:
+            return ""
+        users = [self._user_cache.get(member_id) for member_id in roster.member_ids]
+        entries = [
+            entry
+            for entry in (
+                self._roster_entry(user) for user in users if user is not None
+            )
+            if entry
+        ]
+        more = f" … and {roster.unfetched} more" if roster.unfetched else ""
+        return ", ".join(entries) + more
+
+    def _announce_roster(self, room_id: str, roster: _RoomRoster, text: str) -> None:
+        """Record a freshly fetched roster as the room's announced projection.
 
-        The roster is room-scoped, so the first turn in a room carries it via
-        channel_context. After that the room stays announced and further turns
-        bring nothing until a presence_changed event marks it stale
-        (``_roster_dirty``), which makes the next turn re-fetch and re-deliver
-        it. Bounded like _dispatched_ids; a room counts as announced only once
-        a roster was actually fetched: a failed lookup leaves it unmarked, so
-        the next turn through this gate can still bring the roster.
+        Bounded like _dispatched_ids: appending past the cap drops the oldest
+        room's projection alongside its deque entry.
         """
-        if room_id in self._roster_announced and room_id not in self._roster_dirty:
-            logger.debug(
-                "Chatto: roster for room %s already announced and fresh",
-                room_id,
-            )
-            return ""
-        roster = await self._room_roster_context(client, room_id)
-        if not roster:
-            self._roster_dirty.discard(room_id)
-            return ""
         if room_id not in self._roster_announced:
+            oldest = (
+                self._roster_announced.popleft()
+                if len(self._roster_announced) == self._roster_announced.maxlen
+                else None
+            )
             self._roster_announced.append(room_id)
-        self._roster_dirty.discard(room_id)
-        return roster
+            if oldest is not None:
+                self._rosters.pop(oldest, None)
+                self._roster_text.pop(oldest, None)
+        self._rosters[room_id] = roster
+        self._roster_text[room_id] = text
+
+    def _evict_roster(self, room_id: str) -> None:
+        """Drop all roster state for a room.
+
+        Called on membership changes (user_joined/left_room), when we leave a
+        room ourselves, and on reconnect — protocol v1 has no presence snapshot
+        on subscribe, so a discarded cache is what forces one honest refetch.
+        """
+        try:
+            self._roster_announced.remove(room_id)
+        except ValueError:
+            pass
+        self._rosters.pop(room_id, None)
+        self._roster_text.pop(room_id, None)
+
+    async def _roster_for_room(self, client: ChattoClient, *, room_id: str) -> str:
+        """Roster context for this room, kept fresh through the projection.
+
+        The first turn in a room fetches and announces the roster; afterwards
+        the cached members are re-rendered and re-delivered only when the line
+        actually changed — presence patches land in _user_cache without any
+        API call, so churn like rapid away/offline flips costs dictionary
+        writes only. Membership events or a reconnect evict the room, making
+        its next turn refetch once. An unannounced room retries on its next
+        turn until a lookup succeeds.
+        """
+        if room_id in self._roster_announced:
+            line = self._roster_line(room_id)
+            if line == self._roster_text.get(room_id):
+                logger.debug("Chatto: roster for room %s unchanged", room_id)
+                return ""
+            logger.debug("Chatto: roster for room %s changed - redelivering", room_id)
+            self._roster_text[room_id] = line
+            return line
+
+        fetched = await self._room_roster_context(client, room_id)
+        if fetched is None:
+            return ""
+        roster, text = fetched
+        self._announce_roster(room_id, roster, text)
+        return text
 
     async def _dispatch_message_posted(self, payload: MessagePostedPayload) -> None:
         # Respond-room gate first: read-only memberships must not cost a
@@ -1455,10 +1542,10 @@ class ChattoAdapter(BasePlatformAdapter):
         )
 
         # The first turn in a room is where the agent learns who is in the
-        # channel — later turns bring the roster back only after a presence
-        # change (see _roster_for_room). The gateway prepends channel_context
-        # above the message text, so the roster never mingles with what the user
-        # actually wrote.
+        # channel — later turns re-deliver it only when the cached projection
+        # renders a different line (see _roster_for_room). The gateway prepends
+        # channel_context above the message text, so the roster never mingles
+        # with what the user actually wrote.
         roster = ""
         if room_kind != RoomKind.DM:
             logger.debug(
@@ -1469,7 +1556,7 @@ class ChattoAdapter(BasePlatformAdapter):
             if not roster:
                 logger.debug(
                     "Chatto: dispatching channel message without roster "
-                    "(lookup failed, empty, or already announced and fresh)"
+                    "(lookup failed, empty, or unchanged)"
                 )
 
         source = self.build_source(
@@ -1599,34 +1686,48 @@ class ChattoAdapter(BasePlatformAdapter):
                 )
 
         elif (presence := event.get("presence_changed")) is not None:
-            # A participant's presence changed — any room whose roster the agent
-            # has already seen is now stale and must be re-delivered on the next
-            # turn there. Our own 60s presence refresh must not churn every room,
-            # so ignore self events; rooms we never announced need no refresh.
-            user_id = cast(PresenceChangedPayload, presence).user_id
-            if self.me is not None and user_id == self.me.id:
-                logger.debug("Chatto: ignoring own presence change for roster refresh")
-            elif self._roster_announced:
-                self._roster_dirty.update(self._roster_announced)
+            # A participant's presence changed — patch the cached user in place
+            # and let the next turn in any room they are announced for re-render
+            # its roster line. No flags, no API calls: churn like rapid
+            # away/offline flips costs dictionary writes only. Our own 60s
+            # presence refresh must not feed the agent a self-roster, so self
+            # events are ignored; users we never cached enter with the next
+            # roster fetch carrying current status anyway.
+            presence_payload = cast(PresenceChangedPayload, presence)
+            if self.me is not None and presence_payload.user_id == self.me.id:
+                logger.debug("Chatto: ignoring own presence change")
+            elif (user := self._user_cache.get(presence_payload.user_id)) is not None:
+                user.presence_status = presence_payload.status
                 logger.debug(
-                    "Chatto: marked %d announced room(s) stale after presence "
-                    "change of user %s",
-                    len(self._roster_dirty),
-                    user_id,
+                    "Chatto: patched presence of %s to %s",
+                    presence_payload.user_id,
+                    presence_payload.status.name,
                 )
 
+        elif event.kind in ("user_joined_room", "user_left_room"):
+            # Membership moved — the room's roster projection is now wrong, so
+            # discard it; the next turn there refetches once and re-delivers.
+            room_event = cast(RoomEventPayload, event.get(event.kind))
+            if room_event is not None and room_event.room_id in self._roster_announced:
+                logger.debug(
+                    "Chatto: %s invalidated the roster of room %s",
+                    event.kind,
+                    room_event.room_id,
+                )
+                self._evict_roster(room_event.room_id)
+
         # confirmed:
+        # NOTE: "projection_event" (and "caught_up") belong to realtime protocol
+        # v2 on unreleased Chatto main — chattolib speaks v1 and can never
+        # deliver them here. Revisit when vendored chattolib gains v2 typing.
         elif event.kind in (
             "mention_notification",
-            "projection_event",
             "notification_dismissed",
             "room_marked_as_read",
             "user_typing",
             "notification_created",
             "new_direct_message_notification",
             "message_retracted",
-            "user_joined_room",
-            "user_left_room",
             "thread_created",
             "thread_follow_changed",
             "room_updated",
@@ -1803,12 +1904,22 @@ class ChattoAdapter(BasePlatformAdapter):
         )
 
     async def _refresh_rooms(self) -> None:
-        """Refresh room list via ConnectRPC, join and seed any newly discovered rooms."""
+        """Refresh room list via ConnectRPC, join and seed any newly discovered rooms.
+
+        Runs once per (re)connect, so this is also the bootstrap point for the
+        roster projection: protocol v1 sends no presence snapshot on subscribe,
+        meaning cached presence could only heal on the next random change.
+        Discarding it makes each announced room's first turn refetch fresh
+        data — lazily, so quiet rooms stay free of API calls.
+        """
         client = await self._get_chatto_client()
         if client is None:
             logger.warning("Chatto WS: _refresh_rooms aborted - no client available")
             return
 
+        for announced in list(self._roster_announced):
+            self._evict_roster(announced)
+
         try:
             rooms_list = await client.list_rooms()
             member_ids: set[str] = set()
@@ -1843,6 +1954,7 @@ class ChattoAdapter(BasePlatformAdapter):
             ]
             for rid in stale_room_ids:
                 self._joined_room_ids.remove(rid)
+                self._evict_roster(rid)
             if stale_room_ids:
                 logger.info(
                     "Chatto WS: no longer a member of %d room(s): %s",
@@ -3135,10 +3247,10 @@ def hermes_env_enablement_fn() -> dict:
     know that it is needed for plugin register().
     """
     seed: dict[str, Any] = {}
-    for field in ChattoConfiguration.fields():
-        env_value = os.getenv(field.env_name)
+    for config_field in ChattoConfiguration.fields():
+        env_value = os.getenv(config_field.env_name)
         if env_value:
-            seed[field.config_key] = env_value.strip()
+            seed[config_field.config_key] = env_value.strip()
 
     logger.debug(
         "seed: %s", {k: v for k, v in seed.items() if k not in ("token", "password")}

+ 204 - 22
test_adapter.py

@@ -1388,10 +1388,11 @@ def _roster_user(user_id, login, presence=PresenceStatus.UNSPECIFIED, deleted=Fa
 
 
 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."""
+    """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."""
 
     def _adapter(self):
         adapter = _make_adapter()
@@ -1571,11 +1572,12 @@ class TestRoomRoster:
         assert adapter.handle_message.await_count == 1
         assert "@alice" in self._dispatched_event(adapter).channel_context
 
-    async def test_presence_change_refreshes_roster_on_next_turn(self):
-        """A roster announced for the room stays put until a participant's
-        presence moves — then the next turn in that room re-fetches it once."""
+    async def test_presence_change_redelivers_updated_roster_from_cache(self):
+        """Presence moves patch the cached user; the next turn in the room
+        re-renders the line from cache — no second directory lookup."""
         adapter = self._adapter()
         self._seed_channel(adapter)
+        # alice starts UNSPECIFIED (no presence label in the rendered entry).
         adapter._chatto_client.list_room_members = AsyncMock(
             return_value=self._members(_roster_user("user-1", "alice"))
         )
@@ -1583,7 +1585,9 @@ class TestRoomRoster:
         await self._dispatch(adapter, "@hermes_bot hi")
         assert adapter._chatto_client.list_room_members.await_count == 1
 
-        await adapter._handle_realtime_event(_make_presence_event(user_id="user-1"))
+        await adapter._handle_realtime_event(
+            _make_presence_event(user_id="user-1", status=PresenceStatus.OFFLINE)
+        )
 
         await self._dispatch(
             adapter,
@@ -1591,21 +1595,58 @@ class TestRoomRoster:
             message_id="msg-2",
             thread_root="msg-1",
         )
-        assert adapter._chatto_client.list_room_members.await_count == 2
+        assert adapter._chatto_client.list_room_members.await_count == 1
         context = self._dispatched_event(adapter).channel_context
-        assert context is not None and "@alice" in context
+        assert context is not None and "@alice (Alice, offline)" in context
 
-        # The refresh cleared the stale flag, so a further turn does not refetch.
+        # 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 == 2
+        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."""
+        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")
+
+        for _ in range(5):
+            await adapter._handle_realtime_event(
+                _make_presence_event(user_id="user-1", status=PresenceStatus.AWAY)
+            )
+        adapter.handle_message.reset_mock()
+
+        await self._dispatch(
+            adapter,
+            "@hermes_bot again",
+            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 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
 
-    async def test_own_presence_change_does_not_mark_dirty(self):
-        """Our 60s presence refresh must not churn every announced room."""
+    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."""
         adapter = self._adapter()
         self._seed_channel(adapter)
         adapter._chatto_client.list_room_members = AsyncMock(
@@ -1613,8 +1654,10 @@ class TestRoomRoster:
         )
 
         await self._dispatch(adapter, "@hermes_bot hi")
+        adapter._user_cache["user-9"] = _roster_user("user-9", "mallory")
+
         await adapter._handle_realtime_event(
-            _make_presence_event(user_id="bot-user-id")
+            _make_presence_event(user_id="user-9", status=PresenceStatus.OFFLINE)
         )
 
         await self._dispatch(
@@ -1623,19 +1666,137 @@ class TestRoomRoster:
             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_own_presence_change_is_ignored(self):
+        """Our 60s presence refresh must neither patch nor redeliver."""
+        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")
+        bot = _roster_user("bot-user-id", "hermes_bot", PresenceStatus.ONLINE)
+        adapter._user_cache["bot-user-id"] = bot
+
+        await adapter._handle_realtime_event(
+            _make_presence_event(user_id="bot-user-id", status=PresenceStatus.OFFLINE)
+        )
+
+        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):
-        """No room announced yet — a presence event must not raise or fetch."""
+        """No room announced yet — a presence event must not raise or fetch,
+        and an unknown user stays unknown until a roster fetch names them."""
         adapter = self._adapter()
         self._seed_channel(adapter)
         adapter._chatto_client.list_room_members = AsyncMock()
 
-        await adapter._handle_realtime_event(_make_presence_event(user_id="user-1"))
+        await adapter._handle_realtime_event(_make_presence_event(user_id="user-42"))
 
         assert adapter._chatto_client.list_room_members.await_count == 0
-        assert adapter._roster_dirty == set()
+        assert "user-42" not in adapter._user_cache
+
+    async def test_membership_event_refetches_and_redelivers_once(self):
+        """A join/leave makes the cached roster wrong: it is discarded and the
+        next turn refetches once, delivering the updated line."""
+        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"),
+            )
+        )
+
+        await self._dispatch(adapter, "@hermes_bot hi")
+        assert "alice" in self._dispatched_event(adapter).channel_context
+
+        await adapter._handle_realtime_event(
+            _make_room_event("user_left_room", room_id="room-1")
+        )
+        assert "room-1" not in adapter._roster_announced
+        assert "room-1" not in adapter._rosters
+
+        # The server directory now reflects who actually remained.
+        adapter._chatto_client.list_room_members.return_value = self._members(
+            _roster_user("user-2", "bob")
+        )
+        await self._dispatch(
+            adapter,
+            "@hermes_bot again",
+            message_id="msg-2",
+            thread_root="msg-1",
+        )
+        assert adapter._chatto_client.list_room_members.await_count == 2
+        context = self._dispatched_event(adapter).channel_context
+        assert context is not None and "@bob" in context and "alice" not in context
+
+        await self._dispatch(
+            adapter,
+            "@hermes_bot third",
+            message_id="msg-3",
+            thread_root="msg-1",
+        )
+        assert adapter._chatto_client.list_room_members.await_count == 2
+        assert self._dispatched_event(adapter).channel_context is None
+
+    async def test_reconnect_discards_cached_rosters_for_a_fresh_snapshot(self):
+        """Protocol v1 sends no presence snapshot on subscribe, so every
+        (re)connect discards the projections; rooms refetch lazily on their
+        next turn instead of paying a burst right away."""
+        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")
+        assert adapter._roster_announced
+
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[])
+        await adapter._refresh_rooms()
+        assert not adapter._roster_announced
+        assert not adapter._rosters
+        assert not adapter._roster_text
+
+        await self._dispatch(
+            adapter,
+            "@hermes_bot back online?",
+            message_id="msg-2",
+            thread_root="msg-1",
+        )
+        assert adapter._chatto_client.list_room_members.await_count == 2
+        assert "@alice" in self._dispatched_event(adapter).channel_context
+
+    async def test_stale_room_eviction_drops_its_roster(self):
+        """Rooms we no longer belong to lose their projection along with their
+        joined-list entry."""
+        adapter = self._adapter()
+        self._seed_channel(adapter, room_id="gone-1")
+        adapter._joined_room_ids = ["gone-1"]
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=self._members(_roster_user("user-1", "alice"))
+        )
+
+        await self._dispatch(adapter, "@hermes_bot hi", room_id="gone-1")
+        assert "gone-1" in adapter._rosters
+
+        kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
+        adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
+        await adapter._refresh_rooms()
+
+        assert "gone-1" not in adapter._roster_announced
+        assert "gone-1" not in adapter._rosters
+        assert "gone-1" not in adapter._roster_text
 
 
 # -- Inbound edits (edit-dispatch) --
@@ -1657,21 +1818,35 @@ def _make_edited_event(room_id="room-1", message_event_id="msg-1", actor_id="hum
     return event, payload
 
 
-def _make_presence_event(user_id="user-1", actor_id="user-1"):
+def _make_presence_event(
+    user_id="user-1", actor_id=None, status=PresenceStatus.OFFLINE
+):
     """A presence_changed envelope — actor is the user whose status moved."""
     event = MagicMock()
     event.id = f"evt-presence-{user_id}"
     event.kind = "presence_changed"
-    event.actor_id = actor_id
+    event.actor_id = actor_id if actor_id is not None else user_id
     payload = MagicMock()
     payload.user_id = user_id
-    payload.status = MagicMock()
+    payload.status = status
     event.get = MagicMock(
         side_effect=lambda kind: payload if kind == "presence_changed" else None
     )
     return event
 
 
+def _make_room_event(kind, room_id="room-1", actor_id="user-1"):
+    """A user_joined_room / user_left_room envelope."""
+    event = MagicMock()
+    event.id = f"evt-{kind}-{room_id}"
+    event.kind = kind
+    event.actor_id = actor_id
+    payload = MagicMock()
+    payload.room_id = room_id
+    event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
+    return event
+
+
 class TestEditDispatch:
     """message_edited events become corrections, late mentions or nothing."""
 
@@ -2253,6 +2428,9 @@ class TestDmRoomCommands:
     async def test_leave_drops_the_room_from_the_joined_list(self):
         adapter = self._adapter()
         adapter._joined_room_ids = ["room-7"]
+        adapter._roster_announced.append("room-7")
+        adapter._rosters["room-7"] = MagicMock()
+        adapter._roster_text["room-7"] = "@alice"
         state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
 
@@ -2260,6 +2438,10 @@ class TestDmRoomCommands:
 
         adapter._chatto_client.leave_room.assert_awaited_once_with("room-7")
         assert adapter._joined_room_ids == []
+        # Leaving the audience means keeping no roster projection for it.
+        assert "room-7" not in adapter._roster_announced
+        assert "room-7" not in adapter._rosters
+        assert "room-7" not in adapter._roster_text
         assert "Left 'Deploy' (room-7)" in self._reply(adapter)
 
     async def test_leave_refuses_direct_messages(self):