Преглед на файлове

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 дни
родител
ревизия
86a17b237e
променени са 4 файла, в които са добавени 331 реда и са изтрити 70 реда
  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
   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
-  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
   (`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
@@ -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
   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
 relevant *Behavioural contract* entry) recording: what the wrong plausible
 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.
 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*
-   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
 
@@ -128,10 +143,13 @@ multiplexed gateway into `agent:main:` and names a session nobody is running.
 
 ### 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
   after a thread's first turn. Not per turn.
 - `[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)
 
         # 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)
         if cursor is _UNSEEDED:
             resumed = self._persisted_cursor(session_key, _UNSEEDED)
@@ -1546,6 +1546,26 @@ class ChattoAdapter(BasePlatformAdapter):
             thread_root_event_id=thread_root_event_id,
             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:
             logger.debug(
                 "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.
 
         ``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
         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
         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:
-            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:
             logger.debug(
                 "Chatto: get thread events failed for %s/%s",
@@ -1701,7 +1749,7 @@ class ChattoAdapter(BasePlatformAdapter):
             )
             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):
             # The thread root is fetched explicitly when the unanchored timeline
             # 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(
-    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.
 
-    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,
-    - ``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.
 
+    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
     anchored fetch with the thread's start, which is exactly the bug the anchor
     exists to remove.
     """
     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)
         head = ordered[:index] if index is not None else ordered
         selected = head[-limit:] if limit else head
@@ -3225,10 +3226,46 @@ def _thread_page(
     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):
     """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.
+    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(
         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,
             limit=kwargs.get("limit") or limit,
             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
 
+    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):
         """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
@@ -3963,21 +4114,14 @@ class TestThreadContext:
             actor_id="user-1",
             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(
             body="All green, deploying now",
             message_id="msg-tool",
             actor_id="bot-user-id",
             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.
         await self._dispatch(
@@ -3987,8 +4131,9 @@ class TestThreadContext:
             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(
             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(
         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 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(
             body="@hermes_bot anyone there?",
@@ -4266,6 +4410,10 @@ class TestThreadContext:
         )
         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.
         human = _make_message(
             body="@hermes_bot still there?",