Sfoglia il codice sorgente

Anchor thread seeding with GetThreadEventsAround, not after=

Seeded-thread turns dispatched without history against a real server:
the turn that follows a thread's turn failed with
"invalid_argument: invalid cursor format" and every later turn answered
blind. GetThreadEvents' after/before take opaque, server-sealed cursor
tokens handed out only at page boundaries — the adapter's seed cursor is
a session frontier, which has to land on a specific message (the
trigger), so it is an event ID and cannot be one of those tokens.

Anchor instead on GetThreadEventsAround, whose request takes an event ID
the server resolves; ask for 2*limit+1 because the window is centred on
the anchor and slice at target_index to keep the per-turn delivery
slice. A stale anchor (its message deleted) now fails open as the
existing session-without-cursor blind pass instead of failing every
turn forever.

The test fake had modelled after= as an event-ID cursor — mock and
adapter agreed on a contract the wire does not have — so the fake now
rejects after= like the server does and a fake get_thread_events_around
implements the server's window logic.
Paul Klumpp 3 giorni fa
parent
commit
86a17b237e
4 ha cambiato i file con 331 aggiunte e 70 eliminazioni
  1. 51 4
      AGENTS.md
  2. 35 17
      CONCEPTS.md
  3. 71 23
      adapter.py
  4. 174 26
      test_adapter.py

+ 51 - 4
AGENTS.md

@@ -264,10 +264,17 @@ changes must keep them, or consciously renegotiate the docs.**
   least recently touched thread. The cursor passes the trigger as well as the
   least recently touched thread. The cursor passes the trigger as well as the
   delivered block — the trigger is in the session, so a cursor left behind it
   delivered block — the trigger is in the session, so a cursor left behind it
   would hand the next turn the previous question a second time. The
   would hand the next turn the previous question a second time. The
-  fetch is **anchored at the cursor** (`after=`), so a cursor is a boundary the
-  server walks forward from rather than a position inside a window, and a thread
-  that outgrew `THREAD_CONTEXT_MESSAGE_LIMIT` keeps answering instead of going
-  quiet for good. The
+   fetch is **anchored at the cursor** (`GetThreadEventsAround`, event-ID
+   anchor), so the server builds the window around it and the page always
+   carries the messages after the cursor — a thread that outgrew
+   `THREAD_CONTEXT_MESSAGE_LIMIT` keeps answering instead of going quiet
+   for good. (The broader `GetThreadEvents` API cannot anchor on an event
+   ID: its `after`/`before` take opaque server-sealed cursor tokens, see
+   *Research notes* — that shape silently muted seeded threads, which the
+   around anchor replaced.) A failed anchored fetch fails open: the cursor
+   is dropped for the turn and the unanchored path runs — the blind pass
+   re-delivers own messages and writes a fresh cursor, so a deleted cursor
+   message degrades to one bounded duplicate, not to a thread gone silent.
   cursor is **stored**, in memory and on the session
   cursor is **stored**, in memory and on the session
   (`chatto.thread_seed_cursor`), because a session's frontier is not derivable
   (`chatto.thread_seed_cursor`), because a session's frontier is not derivable
   from the message list: a person who merely talked is in the session, the
   from the message list: a person who merely talked is in the session, the
@@ -975,6 +982,46 @@ back. Consequences the adapter code must honour end to end:
   bare emoji to this API is wrong by construction — verify against the
   bare emoji to this API is wrong by construction — verify against the
   server, not against what our callsites happen to pass.
   server, not against what our callsites happen to pass.
 
 
+### Thread timelines are anchored with GetThreadEventsAround, not after=
+
+The report (and log) was `chattolib`'s thread-history fetch failing with
+`invalid_argument: invalid cursor format` on every seeded turn after the first.
+The recovery cost three sources read together:
+
+- **The server rejects event IDs as timeline cursors.** The wire shape comes
+  from the Chatto server repo: `GetThreadEventsRequest.after`/`before` are
+  parsed by `parseRoomTimelineCursor` (`cli/internal/connectapi/room_timeline.go`)
+  as an opaque, server-*sealed* token — a versioned sequence number sealed with
+  `SealPublicCursor` and scoped per viewer/room/thread-root, handed out only as
+  a page's `start_cursor`/`end_cursor`. Anything a client invents, including an
+  event ID, fails with exactly `invalid cursor format`. And the adapter's seed
+  cursor *is* an event ID, because the session frontier cannot be a page-boundary
+  token (see CONCEPTS.md — it must advance past the trigger specifically).
+- **The old test mock hid it.** `test_adapter.py`'s `_thread_page` faked the
+  server as accepting `after=<event id>` — the mock and the adapter agreed on a
+  contract the wire does not have, the same failure mode the *Typed access*
+  section documents one level up. The fake now raises the server's error if
+  handed `after=`, so a regression shows as a loud failure, not a silent
+  no-context dispatch.
+- **The wrong fix looks right.** Storing the page's opaque `end_cursor` as the
+  seed instead only *looks* like a swap: a cursor token exists only for a page
+  boundary, so the frontier could never land on "the trigger message" or "the
+  last message we chose to deliver" — sessions would skip or re-hand blocks
+  between the cut point and the token. The correct anchor is
+  `GetThreadEventsAround`, whose request takes an event ID (`event_id`) that the
+  server resolves server-side (`threadTimelineTargetIndex`); chattolib exposes
+  it as `get_thread_events_around`, returning `(page, target_index)` where the
+  target index is the anchor's position + 1 (0 when the anchor is the root).
+  The window is centred on the anchor — `(limit-1)/2` entries before it — so
+  the request asks for `2*THREAD_CONTEXT_MESSAGE_LIMIT + 1` and the adapter
+  slices `events[target_index : target_index + limit]` to keep the per-turn
+  delivery slice aligned with the old anchored-after shape.
+
+Diagnosis source of truth: `cli/internal/connectapi/room_timeline.go`
+(cursor format), `cli/internal/core/threads.go` (`GetThreadReplyEvents` vs
+`GetThreadReplyEventsAround` — the root is never in a reply page), and
+`room_timeline_read_model.go` for `targetIndex` semantics.
+
 So when a diagnosis required that kind of work, add a note here (or extend the
 So when a diagnosis required that kind of work, add a note here (or extend the
 relevant *Behavioural contract* entry) recording: what the wrong plausible
 relevant *Behavioural contract* entry) recording: what the wrong plausible
 reading was, which cross-repo sources had to be read together, and what makes
 reading was, which cross-repo sources had to be read together, and what makes

+ 35 - 17
CONCEPTS.md

@@ -48,19 +48,34 @@ facts about it drive everything else:
    already contains it.
    already contains it.
 2. **The fetch is anchored at the cursor**, which is what makes a cursor usable
 2. **The fetch is anchored at the cursor**, which is what makes a cursor usable
    rather than merely informative. An unanchored page returns the *newest*
    rather than merely informative. An unanchored page returns the *newest*
-   messages in the thread; an anchored one (`after=<cursor>`) returns the
-   *oldest* messages past the cursor, walking forward. So the window and the
-   thing being delivered are the same window, and a thread that outgrew
-   `THREAD_CONTEXT_MESSAGE_LIMIT` keeps answering — where an unanchored page put
-   the cursor outside the window, left no tail to find inside it, and muted the
-   thread for good.
-
-   Two consequences ride along. The root is not part of an anchored page (the
-   server sets `IncludeRoot` false whenever `after` is set), and it is not needed
-   either — everything up to the cursor is already in the session — so the extra
-   root fetch is skipped: one RPC fewer per turn. And a backlog bigger than the
-   window drains over turns instead of arriving at once: each turn takes the next
-   slice and stops there, so nothing is skipped and nothing repeats.
+   messages in the thread; an anchored one returns the *oldest* messages past
+   the cursor, walking forward. So the window and the thing being delivered are
+   the same window, and a thread that outgrew `THREAD_CONTEXT_MESSAGE_LIMIT`
+   keeps answering — where an unanchored page put the cursor outside the window,
+   left no tail to find inside it, and muted the thread for good.
+   The anchor is `GetThreadEventsAround`, not `GetThreadEvents` with `after=`:
+   on the wire `after`/`before` take opaque cursor tokens the server seals and
+   only ever hands back at a page boundary, so an event ID — which is what the
+   cursor is — would be rejected with `invalid cursor format` (see the
+   corresponding Research note in AGENTS.md). Around takes an event ID
+   directly; because its window is centred on the anchor, the request asks for
+   nearly twice the delivery limit so the half after the cursor still holds a
+   full slice.
+
+   Two consequences ride along, on any anchored fetch. The root is not part of
+   an anchored page (the server's reply pages never list the root), and it is
+   not needed — everything up to the cursor is already in the session — so the
+   extra root fetch is skipped: one RPC fewer per turn. And a backlog bigger
+   than the window drains over turns instead of arriving at once: each turn
+   takes the next slice and stops there, so nothing is skipped and nothing
+   repeats.
+
+   A stale anchor fails open. If the cursor's message has since been deleted,
+   the anchored lookup errors — and the turn drops the cursor, refetches
+   unanchored, and runs the blind pass (re-delivering the bot's own messages,
+   writing a fresh frontier) instead of re-reading a cursor the server now
+   refuses every turn forever. The cost is one bounded duplicate; the
+   alternative was a thread gone permanently silent.
 
 
 ### Why the frontier is stored, not derived
 ### Why the frontier is stored, not derived
 
 
@@ -128,10 +143,13 @@ multiplexed gateway into `agent:main:` and names a session nobody is running.
 
 
 ### What this costs
 ### What this costs
 
 
-- One extra RPC per turn that delivers something: `get_thread_events`, capped at
-  20 messages. Quiet threads skip it — the cursor means there is nothing to
-  fetch. It used to be two: the unanchored page omits the thread root, so the
-  root was fetched separately on top.
+- One extra RPC per turn that delivers something: `get_thread_events` on the
+  unanchored path, `get_thread_events_around` on the anchored one (page of up
+  to 41, slice after the cursor capped at 20). Quiet threads skip it — the
+  cursor means there is nothing to fetch. It used to be two: the unanchored
+  page omits the thread root, so the root was fetched separately on top.
+  A stale cursor adds one unanchored retry for that turn only — the fresh
+  cursor it writes puts the next turn back on the anchored path.
 - One session-metadata write per turn that delivered own output, plus one retry
 - One session-metadata write per turn that delivered own output, plus one retry
   after a thread's first turn. Not per turn.
   after a thread's first turn. Not per turn.
 - `[Earlier in this thread]` is reference text, not trusted input: only the
 - `[Earlier in this thread]` is reference text, not trusted input: only the

+ 71 - 23
adapter.py

@@ -1525,10 +1525,10 @@ class ChattoAdapter(BasePlatformAdapter):
         session_key = self._session_key_for(source)
         session_key = self._session_key_for(source)
 
 
         # The cursor is resolved *before* the fetch, because it anchors it. That
         # The cursor is resolved *before* the fetch, because it anchors it. That
-        # is what makes a long thread work: the server returns the oldest messages
-        # past the anchor rather than the newest ones in the thread, so a thread
-        # that grew past the fetch limit still answers with what the session is
-        # missing instead of leaving the cursor outside the window.
+        # is what makes a long thread work: an anchored fetch returns the oldest
+        # messages past the anchor rather than the newest ones in the thread, so
+        # a thread that grew past the fetch limit still answers with what the
+        # session is missing instead of leaving the cursor outside the window.
         cursor = self._seeded_threads.get(thread_root_event_id, _UNSEEDED)
         cursor = self._seeded_threads.get(thread_root_event_id, _UNSEEDED)
         if cursor is _UNSEEDED:
         if cursor is _UNSEEDED:
             resumed = self._persisted_cursor(session_key, _UNSEEDED)
             resumed = self._persisted_cursor(session_key, _UNSEEDED)
@@ -1546,6 +1546,26 @@ class ChattoAdapter(BasePlatformAdapter):
             thread_root_event_id=thread_root_event_id,
             thread_root_event_id=thread_root_event_id,
             after=anchored,
             after=anchored,
         )
         )
+        if history is None and anchored:
+            # The anchored fetch failed, most plausibly because the cursor
+            # message has gone away (deleted, retracted). Fail open as a session
+            # without a cursor — the blind pass below re-delivers own messages
+            # and writes a fresh cursor — rather than re-reading a cursor that
+            # will fail every turn from now on.
+            logger.debug(
+                "Chatto: anchored thread history for %s failed at cursor %s; "
+                "refetching unanchored",
+                thread_root_event_id,
+                anchored,
+            )
+            self._seeded_threads.pop(thread_root_event_id, None)
+            anchored = ""
+            history = await self._thread_history(
+                client,
+                room_id=room_id,
+                thread_root_event_id=thread_root_event_id,
+                after="",
+            )
         if history is None:
         if history is None:
             logger.debug(
             logger.debug(
                 "Chatto: dispatching into thread %s without history (lookup failed)",
                 "Chatto: dispatching into thread %s without history (lookup failed)",
@@ -1667,31 +1687,59 @@ class ChattoAdapter(BasePlatformAdapter):
         """Fetch a thread's messages oldest-first, or ``None`` if the lookup failed.
         """Fetch a thread's messages oldest-first, or ``None`` if the lookup failed.
 
 
         ``after`` anchors the page at a cursor instead of at the thread's start,
         ``after`` anchors the page at a cursor instead of at the thread's start,
-        and that is the whole point: the server then returns the *oldest* messages
-        past that cursor rather than the newest ones in the thread. A thread that
-        has grown past the fetch limit therefore still answers with exactly what
-        the session is missing, where an unanchored page would have left the
-        cursor outside the window and the turn with nothing to deliver.
-
-        Two things follow from the anchor, both from the server's own handler: the
-        root is not part of an anchored page (``IncludeRoot`` is false whenever
-        ``after`` is set), and it is not needed either — a cursor means
-        everything up to and including it is already in the session. So the extra
-        root fetch is skipped, which is one RPC fewer per turn than the unanchored
-        path.
+        and that is the whole point: the anchored fetch returns the *oldest*
+        messages past that cursor rather than the newest ones in the thread. A
+        thread that has grown past the fetch limit therefore still answers with
+        exactly what the session is missing, where an unanchored page would have
+        left the cursor outside the window and the turn with nothing to deliver.
+
+        The anchor is a ``GetThreadEventsAround`` call, not ``GetThreadEvents``
+        with ``after=``: the server's ``after``/``before`` take opaque cursor
+        tokens it seals and hands out with a page — an event ID, which is what
+        our cursor is, is rejected with ``invalid cursor format``. Around takes
+        an event ID directly; the request asks for nearly twice the delivery
+        limit because the window is centred on the anchor, so the half after it
+        holds up to ``THREAD_CONTEXT_MESSAGE_LIMIT`` messages.
+
+        Two things follow from the anchor, both from the server's own handler,
+        either way: the root is not part of an anchored page, and it is not
+        needed — a cursor means everything up to and including it is already in
+        the session. So the extra root fetch is skipped, which is one RPC fewer
+        per turn than the unanchored path.
 
 
         When the gap is larger than the limit the page holds only its oldest
         When the gap is larger than the limit the page holds only its oldest
         slice and the cursor stops at the last message delivered; the next turn
         slice and the cursor stops at the last message delivered; the next turn
         continues from there. A backlog drains over turns rather than in one, and
         continues from there. A backlog drains over turns rather than in one, and
         nothing is skipped either way.
         nothing is skipped either way.
+
+        A failed lookup returns ``None``; the caller treats a failure at an
+        anchored cursor as a stale cursor and falls back to the unanchored path.
         """
         """
+        window_limit = 2 * ChattoConstants.THREAD_CONTEXT_MESSAGE_LIMIT + 1
         try:
         try:
-            page = await client.get_thread_events(
-                room_id,
-                thread_root_event_id,
-                limit=ChattoConstants.THREAD_CONTEXT_MESSAGE_LIMIT,
-                after=after or None,
-            )
+            if after:
+                page, target_index = await client.get_thread_events_around(
+                    room_id,
+                    thread_root_event_id,
+                    after,
+                    limit=window_limit,
+                )
+                # target_index is the anchor's position + 1 (0 when the anchor
+                # is the root), so everything from there on is strictly past
+                # the cursor. The root-as-anchor case can return more than the
+                # delivery limit holds, so slice like the anchored-after shape
+                # does: no more than THREAD_CONTEXT_MESSAGE_LIMIT per turn.
+                events = page.events[
+                    target_index : target_index
+                    + ChattoConstants.THREAD_CONTEXT_MESSAGE_LIMIT
+                ]
+            else:
+                page = await client.get_thread_events(
+                    room_id,
+                    thread_root_event_id,
+                    limit=ChattoConstants.THREAD_CONTEXT_MESSAGE_LIMIT,
+                )
+                events = page.events
         except Exception:
         except Exception:
             logger.debug(
             logger.debug(
                 "Chatto: get thread events failed for %s/%s",
                 "Chatto: get thread events failed for %s/%s",
@@ -1701,7 +1749,7 @@ class ChattoAdapter(BasePlatformAdapter):
             )
             )
             return None
             return None
 
 
-        messages = [ev.message for ev in page.events if ev.message is not None]
+        messages = [ev.message for ev in events if ev.message is not None]
         if not after and not any(m.id == thread_root_event_id for m in messages):
         if not after and not any(m.id == thread_root_event_id for m in messages):
             # The thread root is fetched explicitly when the unanchored timeline
             # The thread root is fetched explicitly when the unanchored timeline
             # omits it: a cron message is a plain room message until someone
             # omits it: a cron message is a plain room message until someone

+ 174 - 26
test_adapter.py

@@ -3192,27 +3192,28 @@ class TestSessionKey:
 
 
 
 
 def _thread_page(
 def _thread_page(
-    messages, thread_root_event_id, *, limit=None, before=None, after=None
+    messages, thread_root_event_id, *, limit=None, before=None
 ):
 ):
     """``get_thread_events`` modelled on the server's own handler, not a fixture.
     """``get_thread_events`` modelled on the server's own handler, not a fixture.
 
 
-    Three shapes, and the adapter's cursor logic depends on all three:
+    Two shapes, and the adapter's cursor logic depends on both:
 
 
     - unanchored: the root plus the **newest** ``limit`` replies,
     - unanchored: the root plus the **newest** ``limit`` replies,
-    - ``after=cursor``: the **oldest** ``limit`` replies past the cursor, exclusive,
-      and without the root — the server sets ``IncludeRoot`` false for an anchor,
     - ``before``: the newest ``limit`` replies ahead of it.
     - ``before``: the newest ``limit`` replies ahead of it.
 
 
+    There is deliberately no ``after`` shape: on the wire that parameter takes
+    opaque cursor tokens the server seals and hands out with a page — an event
+    ID is rejected with ``invalid cursor format``. The adapter's own seed cursor
+    is an event ID, so `_serve_thread` also installs `get_thread_events_around`
+    for it; a test that wanted the old event-ID ``after`` back would now fail
+    loudly here instead of passing against a mock that never told the truth.
+
     A fixed ``TimelinePage`` fixture cannot stand in for this: it would answer an
     A fixed ``TimelinePage`` fixture cannot stand in for this: it would answer an
     anchored fetch with the thread's start, which is exactly the bug the anchor
     anchored fetch with the thread's start, which is exactly the bug the anchor
     exists to remove.
     exists to remove.
     """
     """
     ordered = list(messages)
     ordered = list(messages)
-    if after is not None:
-        index = next((i for i, m in enumerate(ordered) if m.id == after), None)
-        ahead = ordered[index + 1 :] if index is not None else []
-        selected = ahead[:limit] if limit else ahead
-    elif before is not None:
+    if before is not None:
         index = next((i for i, m in enumerate(ordered) if m.id == before), None)
         index = next((i for i, m in enumerate(ordered) if m.id == before), None)
         head = ordered[:index] if index is not None else ordered
         head = ordered[:index] if index is not None else ordered
         selected = head[-limit:] if limit else head
         selected = head[-limit:] if limit else head
@@ -3225,10 +3226,46 @@ def _thread_page(
     return TimelinePage(events=[_thread_event(m) for m in selected])
     return TimelinePage(events=[_thread_event(m) for m in selected])
 
 
 
 
+def _thread_page_around(messages, thread_root_event_id, event_id, *, limit=None):
+    """``get_thread_events_around`` modelled on the server's own handler.
+
+    The server (``GetThreadReplyEventsAround``) lists the thread's replies
+    excluding the root, builds a chronological window centred on the anchor —
+    ``(limit-1)/2`` entries before it, the rest after — and reports the anchor's
+    index + 1, or 0 when the anchor is the root itself. The adapter slices the
+    page at that index to get exactly the messages past its cursor.
+    """
+    replies = [m for m in messages if m.id != thread_root_event_id]
+    limit = limit or len(replies) or 1
+    if event_id == thread_root_event_id:
+        anchor_index, target_index = -1, 0
+    else:
+        anchor_index = next(
+            (i for i, m in enumerate(replies) if m.id == event_id), None
+        )
+        if anchor_index is None:
+            raise ChattoError("message not found")
+        target_index = anchor_index + 1
+    start = max(anchor_index - (limit - 1) // 2, 0)
+    end = start + limit
+    if end > len(replies):
+        end = len(replies)
+        start = max(end - limit, 0)
+    window = replies[start:end]
+    relative_target = target_index - start if anchor_index >= 0 else 0
+    return (
+        TimelinePage(events=[_thread_event(m) for m in window]),
+        relative_target,
+    )
+
+
 def _serve_thread(adapter, messages, limit=None):
 def _serve_thread(adapter, messages, limit=None):
     """Point the adapter's thread fetch at a message list the anchor can walk.
     """Point the adapter's thread fetch at a message list the anchor can walk.
 
 
     ``messages`` is the thread's whole history, oldest first, root included.
     ``messages`` is the thread's whole history, oldest first, root included.
+    Both shapes the server serves a thread read with are installed: the
+    unanchored ``get_thread_events`` and the event-ID-anchored
+    ``get_thread_events_around`` the seed cursor walks.
     """
     """
     adapter._chatto_client.get_thread_events = AsyncMock(
     adapter._chatto_client.get_thread_events = AsyncMock(
         side_effect=lambda room_id, thread_root_event_id, **kwargs: _thread_page(
         side_effect=lambda room_id, thread_root_event_id, **kwargs: _thread_page(
@@ -3236,7 +3273,16 @@ def _serve_thread(adapter, messages, limit=None):
             thread_root_event_id,
             thread_root_event_id,
             limit=kwargs.get("limit") or limit,
             limit=kwargs.get("limit") or limit,
             before=kwargs.get("before"),
             before=kwargs.get("before"),
-            after=kwargs.get("after"),
+        )
+    )
+    adapter._chatto_client.get_thread_events_around = AsyncMock(
+        side_effect=lambda room_id, thread_root_event_id, event_id, **kwargs: (
+            _thread_page_around(
+                messages,
+                thread_root_event_id,
+                event_id,
+                limit=kwargs.get("limit") or limit,
+            )
         )
         )
     )
     )
 
 
@@ -3912,6 +3958,111 @@ class TestThreadContext:
 
 
         assert "Nightly build" in self._dispatched_event(adapter).channel_context
         assert "Nightly build" in self._dispatched_event(adapter).channel_context
 
 
+    async def test_a_stale_cursor_falls_back_to_the_blind_pass(self):
+        """A killable cursor: its message is deleted server-side, so the fetch
+        anchored on it fails every turn.
+
+        The old anchored-read contract would answer this with a permanent
+        contextless dispatch. Instead the turn fails open — it refetches
+        unanchored, re-delivers the bot's own messages like the blind pass a
+        session without a cursor gets, and records a fresh frontier so the dead
+        cursor is never read again.
+        """
+        store = _FakeSessionStore()
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter.set_session_store(store)
+        source = MagicMock()
+        source.chat_type = "channel"
+        source.chat_id = "room-1"
+        source.thread_id = "cron-1"
+        source.prospective_thread_id = None
+        source.platform = Platform(ChattoConstants.PLATFORM_NAME)
+        source.user_id = "user-1"
+        source.profile = None
+        store.create_session(adapter._session_key_for(source))
+
+        cron = _make_message(
+            body="Nightly build finished",
+            message_id="cron-1",
+            actor_id="bot-user-id",
+            created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
+        )
+        from_bot = _make_message(
+            body="Deployed, all green",
+            message_id="msg-tool",
+            actor_id="bot-user-id",
+            created_at=datetime(2026, 10, 11, 8, 2, tzinfo=UTC),
+        )
+        history = [cron, from_bot]
+        _serve_thread(adapter, history)
+        # A cursor whose message the server no longer knows: the around fetch
+        # rejects it, the unanchored fetch does not care.
+        adapter._seeded_threads["cron-1"] = "msg-deleted"
+
+        block = await adapter._thread_context_for(
+            adapter._chatto_client,
+            room_id="room-1",
+            thread_root_event_id="cron-1",
+            current_message_id="msg-2",
+            source=source,
+        )
+
+        assert "Deployed, all green" in block, (
+            "the failed anchor muted the thread instead of falling back to the "
+            "unanchored fetch"
+        )
+        assert "status?" not in block, (
+            "the human messages became turns of their own and are in the "
+            "session; the fallback delivers the bot's output only"
+        )
+
+        # The fresh frontier is written over the dead one, so the next process
+        # does not re-read what the server just refused.
+        assert adapter._seeded_threads["cron-1"] == "msg-tool"
+        assert store.writes == 1
+
+    async def test_a_seeded_turn_never_sends_an_event_id_as_an_after_cursor(self):
+        """The wire does not accept one: GetThreadEvents' after/before take
+        opaque cursor tokens the server seals, and an event ID is rejected with
+        'invalid cursor format'. The old mock agreed with the adapter about a
+        contract the server does not have — so the fake now rejects it, and this
+        test pins that the seeded second turn walks the around API instead."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        cron = _make_message(
+            body="Nightly build finished",
+            message_id="cron-1",
+            actor_id="bot-user-id",
+            created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
+        )
+        first = _make_message(
+            body="@hermes_bot and the deploy?",
+            message_id="msg-1",
+            created_at=datetime(2026, 10, 11, 8, 1, tzinfo=UTC),
+        )
+        history = [cron, first]
+        _serve_thread(adapter, history)
+
+        await self._dispatch(
+            adapter, first.body, message_id="msg-1", thread_root="cron-1"
+        )
+        adapter._chatto_client.get_thread_events.reset_mock()
+        await self._dispatch(
+            adapter, "@hermes_bot second", message_id="msg-2", thread_root="cron-1"
+        )
+
+        adapter._chatto_client.get_thread_events.assert_not_called()
+        call = adapter._chatto_client.get_thread_events_around.await_args
+        assert call is not None and call.args[2] == "msg-1", (
+            "the seeded turn did not anchor its fetch at the persisted cursor"
+        )
+        window_limit = 2 * ChattoConstants.THREAD_CONTEXT_MESSAGE_LIMIT + 1
+        assert call.kwargs.get("limit") == window_limit, (
+            "the around window is centred on the anchor, so it has to ask for "
+            "almost twice the delivery limit to still deliver a full slice"
+        )
+
     async def test_the_root_is_fetched_when_the_timeline_omits_it(self):
     async def test_the_root_is_fetched_when_the_timeline_omits_it(self):
         """A cron message is a plain room message until someone replies, so
         """A cron message is a plain room message until someone replies, so
         whether the server lists it back as the thread's first event is not a
         whether the server lists it back as the thread's first event is not a
@@ -3963,21 +4114,14 @@ class TestThreadContext:
             actor_id="user-1",
             actor_id="user-1",
             created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
             created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
         )
         )
-        # The tool's message, posted into the thread after that first turn.
         from_bot = _make_message(
         from_bot = _make_message(
             body="All green, deploying now",
             body="All green, deploying now",
             message_id="msg-tool",
             message_id="msg-tool",
             actor_id="bot-user-id",
             actor_id="bot-user-id",
             created_at=datetime(2026, 10, 11, 8, 1, tzinfo=UTC),
             created_at=datetime(2026, 10, 11, 8, 1, tzinfo=UTC),
         )
         )
-        adapter._chatto_client.get_thread_events = AsyncMock(
-            side_effect=[
-                # The first turn's history predates the tool post; a later
-                # lookup, if one happened, would see it.
-                TimelinePage(events=[_thread_event(human)]),
-                TimelinePage(events=[_thread_event(human), _thread_event(from_bot)]),
-            ]
-        )
+        history = [human]
+        _serve_thread(adapter, history)
 
 
         # First turn seeds the thread — it must NOT contain the later message.
         # First turn seeds the thread — it must NOT contain the later message.
         await self._dispatch(
         await self._dispatch(
@@ -3987,8 +4131,9 @@ class TestThreadContext:
             self._dispatched_event(adapter).channel_context or ""
             self._dispatched_event(adapter).channel_context or ""
         )
         )
 
 
-        # Second turn: the thread is already answered in, so seeding is skipped
-        # — and the tool's message is nowhere in the session.
+        # The tool's message, posted into the thread after that first turn; the
+        # next lookup sees it because the fetch is anchored at the cursor.
+        history.append(from_bot)
         await self._dispatch(
         await self._dispatch(
             adapter, "@hermes_bot any news?", message_id="msg-2", thread_root="msg-1"
             adapter, "@hermes_bot any news?", message_id="msg-2", thread_root="msg-1"
         )
         )
@@ -4229,14 +4374,13 @@ class TestThreadContext:
     async def test_an_empty_fetch_records_the_trigger_and_the_next_turn_recovers(
     async def test_an_empty_fetch_records_the_trigger_and_the_next_turn_recovers(
         self,
         self,
     ):
     ):
-        """A fetch that names nothing still leaves an exact cursor behind.
+        """        A fetch that names nothing still leaves an exact cursor behind.
 
 
         The trigger is the turn's own message, so the session holds it whatever
         The trigger is the turn's own message, so the session holds it whatever
         the thread fetch returned — recording it is exact, not a guess. That is
         the thread fetch returned — recording it is exact, not a guess. That is
-        only safe because the next fetch is *anchored at* the cursor: a cursor
-        names a boundary rather than a position inside the window, so one the
-        window did not contain is still usable, and the next turn resumes from
-        there instead of re-seeding the thread.
+        only safe because the next fetch is *anchored at* the cursor: the server
+        builds the window around it, so the cursor is always inside the page the
+        next turn walks forward from instead of re-seeding the thread.
         """
         """
         trigger = _make_message(
         trigger = _make_message(
             body="@hermes_bot anyone there?",
             body="@hermes_bot anyone there?",
@@ -4266,6 +4410,10 @@ class TestThreadContext:
         )
         )
         assert adapter._seeded_threads.get("t1") == "msg-1"
         assert adapter._seeded_threads.get("t1") == "msg-1"
 
 
+        # The trigger exists server-side even though the first window did not
+        # name it — the next fetch anchors on it and finds it.
+        history.append(trigger)
+
         # And the next turn picks up from that cursor instead of re-seeding.
         # And the next turn picks up from that cursor instead of re-seeding.
         human = _make_message(
         human = _make_message(
             body="@hermes_bot still there?",
             body="@hermes_bot still there?",