|
@@ -2164,6 +2164,56 @@ class TestSessionKey:
|
|
|
assert adapter._session_key_for(source) == adapter._source_session_key(source)
|
|
assert adapter._session_key_for(source) == adapter._source_session_key(source)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _thread_page(
|
|
|
|
|
+ messages, thread_root_event_id, *, limit=None, before=None, after=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:
|
|
|
|
|
+
|
|
|
|
|
+ - 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.
|
|
|
|
|
+
|
|
|
|
|
+ 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:
|
|
|
|
|
+ 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
|
|
|
|
|
+ else:
|
|
|
|
|
+ root = next((m for m in ordered if m.id == thread_root_event_id), None)
|
|
|
|
|
+ newest = ordered[-limit:] if limit else ordered
|
|
|
|
|
+ selected = ([root] if root is not None else []) + [
|
|
|
|
|
+ m for m in newest if m.id != thread_root_event_id
|
|
|
|
|
+ ]
|
|
|
|
|
+ return TimelinePage(events=[_thread_event(m) for m in selected])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+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.
|
|
|
|
|
+ """
|
|
|
|
|
+ adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
+ side_effect=lambda room_id, thread_root_event_id, **kwargs: _thread_page(
|
|
|
|
|
+ messages,
|
|
|
|
|
+ thread_root_event_id,
|
|
|
|
|
+ limit=kwargs.get("limit") or limit,
|
|
|
|
|
+ before=kwargs.get("before"),
|
|
|
|
|
+ after=kwargs.get("after"),
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class _FakeSessionStore:
|
|
class _FakeSessionStore:
|
|
|
"""An in-memory session store with the real store's shape.
|
|
"""An in-memory session store with the real store's shape.
|
|
|
|
|
|
|
@@ -2262,9 +2312,10 @@ class TestThreadContext:
|
|
|
|
|
|
|
|
async def test_second_turn_in_the_thread_repeats_no_context(self):
|
|
async def test_second_turn_in_the_thread_repeats_no_context(self):
|
|
|
"""The session already holds what the first turn seeded — repeating it
|
|
"""The session already holds what the first turn seeded — repeating it
|
|
|
- would duplicate context the agent has. Seeding a later turn refetches to
|
|
|
|
|
- deliver whatever landed since the cursor, so the invariant to hold is the
|
|
|
|
|
- absence of duplication, not the absence of the lookup."""
|
|
|
|
|
|
|
+ would duplicate context the agent has. Seeding a later turn refetches,
|
|
|
|
|
+ anchored at the cursor, to deliver whatever landed since; so the
|
|
|
|
|
+ invariant to hold is the absence of duplication, not the absence of the
|
|
|
|
|
+ lookup."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
self._seed_channel(adapter)
|
|
|
cron = _make_message(
|
|
cron = _make_message(
|
|
@@ -2273,12 +2324,16 @@ class TestThreadContext:
|
|
|
actor_id="bot-user-id",
|
|
actor_id="bot-user-id",
|
|
|
created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
|
)
|
|
)
|
|
|
- adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(events=[_thread_event(cron)])
|
|
|
|
|
|
|
+ first = _make_message(
|
|
|
|
|
+ body="@hermes_bot first",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ created_at=datetime(2026, 10, 11, 8, 1, tzinfo=UTC),
|
|
|
)
|
|
)
|
|
|
|
|
+ history = [cron, first]
|
|
|
|
|
+ _serve_thread(adapter, history)
|
|
|
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
- adapter, "@hermes_bot first", message_id="msg-1", thread_root="cron-1"
|
|
|
|
|
|
|
+ adapter, first.body, message_id="msg-1", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
adapter, "@hermes_bot second", message_id="msg-2", thread_root="cron-1"
|
|
adapter, "@hermes_bot second", message_id="msg-2", thread_root="cron-1"
|
|
@@ -2294,6 +2349,10 @@ class TestThreadContext:
|
|
|
on delivering its tail from exactly where it stopped — our own tool
|
|
on delivering its tail from exactly where it stopped — our own tool
|
|
|
output arrives, and the human messages the session already holds do not.
|
|
output arrives, and the human messages the session already holds do not.
|
|
|
"""
|
|
"""
|
|
|
|
|
+
|
|
|
|
|
+ def at(minute: int):
|
|
|
|
|
+ return datetime(2026, 10, 11, 8, minute, tzinfo=UTC)
|
|
|
|
|
+
|
|
|
store = _FakeSessionStore()
|
|
store = _FakeSessionStore()
|
|
|
before = self._adapter()
|
|
before = self._adapter()
|
|
|
self._seed_channel(before)
|
|
self._seed_channel(before)
|
|
@@ -2302,24 +2361,16 @@ class TestThreadContext:
|
|
|
body="Nightly build finished",
|
|
body="Nightly build finished",
|
|
|
message_id="cron-1",
|
|
message_id="cron-1",
|
|
|
actor_id="bot-user-id",
|
|
actor_id="bot-user-id",
|
|
|
- created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
|
|
|
- )
|
|
|
|
|
- human = _make_message(
|
|
|
|
|
- body="@hermes_bot and the deploy?",
|
|
|
|
|
- message_id="msg-1",
|
|
|
|
|
- created_at=datetime(2026, 10, 11, 8, 5, tzinfo=UTC),
|
|
|
|
|
|
|
+ created_at=at(0),
|
|
|
)
|
|
)
|
|
|
- from_bot = _make_message(
|
|
|
|
|
- body="Deployed, all green",
|
|
|
|
|
- message_id="msg-tool",
|
|
|
|
|
- actor_id="bot-user-id",
|
|
|
|
|
- created_at=datetime(2026, 10, 11, 8, 6, tzinfo=UTC),
|
|
|
|
|
- )
|
|
|
|
|
- before._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(events=[_thread_event(cron)])
|
|
|
|
|
|
|
+ first = _make_message(
|
|
|
|
|
+ body="@hermes_bot and the deploy?", message_id="msg-1", created_at=at(1)
|
|
|
)
|
|
)
|
|
|
|
|
+ history = [cron, first]
|
|
|
|
|
+ _serve_thread(before, history)
|
|
|
|
|
+
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
- before, "@hermes_bot first", message_id="msg-1", thread_root="cron-1"
|
|
|
|
|
|
|
+ before, first.body, message_id="msg-1", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
|
session_key = before._session_key_for(
|
|
session_key = before._session_key_for(
|
|
|
before.handle_message.await_args.args[0].source
|
|
before.handle_message.await_args.args[0].source
|
|
@@ -2327,15 +2378,14 @@ class TestThreadContext:
|
|
|
# The gateway creates the session once it has run a turn, which is what
|
|
# The gateway creates the session once it has run a turn, which is what
|
|
|
# lets the *next* turn's cursor land on it.
|
|
# lets the *next* turn's cursor land on it.
|
|
|
store.create_session(session_key)
|
|
store.create_session(session_key)
|
|
|
- before._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(
|
|
|
|
|
- events=[
|
|
|
|
|
- _thread_event(cron),
|
|
|
|
|
- _thread_event(human),
|
|
|
|
|
- _thread_event(from_bot),
|
|
|
|
|
- ]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+
|
|
|
|
|
+ from_bot = _make_message(
|
|
|
|
|
+ body="Deployed, all green",
|
|
|
|
|
+ message_id="msg-tool",
|
|
|
|
|
+ actor_id="bot-user-id",
|
|
|
|
|
+ created_at=at(2),
|
|
|
)
|
|
)
|
|
|
|
|
+ history.append(from_bot)
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
before, "@hermes_bot thanks", message_id="msg-2", thread_root="cron-1"
|
|
before, "@hermes_bot thanks", message_id="msg-2", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
@@ -2345,41 +2395,22 @@ class TestThreadContext:
|
|
|
"the pending cursor from turn one rides along on it rather than "
|
|
"the pending cursor from turn one rides along on it rather than "
|
|
|
"costing a second routing-table rewrite"
|
|
"costing a second routing-table rewrite"
|
|
|
)
|
|
)
|
|
|
- later_tool_post = _make_message(
|
|
|
|
|
|
|
+
|
|
|
|
|
+ rolled_back = _make_message(
|
|
|
body="Rolled back",
|
|
body="Rolled back",
|
|
|
message_id="msg-tool2",
|
|
message_id="msg-tool2",
|
|
|
actor_id="bot-user-id",
|
|
actor_id="bot-user-id",
|
|
|
- created_at=datetime(2026, 10, 11, 8, 7, tzinfo=UTC),
|
|
|
|
|
- )
|
|
|
|
|
- before._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(
|
|
|
|
|
- events=[
|
|
|
|
|
- _thread_event(cron),
|
|
|
|
|
- _thread_event(human),
|
|
|
|
|
- _thread_event(from_bot),
|
|
|
|
|
- _thread_event(later_tool_post),
|
|
|
|
|
- ]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ created_at=at(3),
|
|
|
)
|
|
)
|
|
|
|
|
+ history.append(rolled_back)
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
before, "@hermes_bot next", message_id="msg-3", thread_root="cron-1"
|
|
before, "@hermes_bot next", message_id="msg-3", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
|
|
|
+
|
|
|
quiet = _make_message(
|
|
quiet = _make_message(
|
|
|
- body="@hermes_bot and now?",
|
|
|
|
|
- message_id="msg-4",
|
|
|
|
|
- created_at=datetime(2026, 10, 11, 8, 8, tzinfo=UTC),
|
|
|
|
|
- )
|
|
|
|
|
- before._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(
|
|
|
|
|
- events=[
|
|
|
|
|
- _thread_event(cron),
|
|
|
|
|
- _thread_event(human),
|
|
|
|
|
- _thread_event(from_bot),
|
|
|
|
|
- _thread_event(later_tool_post),
|
|
|
|
|
- _thread_event(quiet),
|
|
|
|
|
- ]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ body="@hermes_bot and now?", message_id="msg-4", created_at=at(4)
|
|
|
)
|
|
)
|
|
|
|
|
+ history.append(quiet)
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
before, quiet.body, message_id="msg-4", thread_root="cron-1"
|
|
before, quiet.body, message_id="msg-4", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
@@ -2396,28 +2427,15 @@ class TestThreadContext:
|
|
|
assert dict(after._seeded_threads) == {}
|
|
assert dict(after._seeded_threads) == {}
|
|
|
after_me = _make_message(
|
|
after_me = _make_message(
|
|
|
body="Standing by",
|
|
body="Standing by",
|
|
|
- message_id="msg-tool2",
|
|
|
|
|
|
|
+ message_id="msg-tool3",
|
|
|
actor_id="bot-user-id",
|
|
actor_id="bot-user-id",
|
|
|
- created_at=datetime(2026, 10, 11, 8, 9, tzinfo=UTC),
|
|
|
|
|
|
|
+ created_at=at(5),
|
|
|
)
|
|
)
|
|
|
newest = _make_message(
|
|
newest = _make_message(
|
|
|
- body="@hermes_bot status please",
|
|
|
|
|
- message_id="msg-5",
|
|
|
|
|
- created_at=datetime(2026, 10, 11, 8, 10, tzinfo=UTC),
|
|
|
|
|
- )
|
|
|
|
|
- after._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(
|
|
|
|
|
- events=[
|
|
|
|
|
- _thread_event(cron),
|
|
|
|
|
- _thread_event(human),
|
|
|
|
|
- _thread_event(from_bot),
|
|
|
|
|
- _thread_event(later_tool_post),
|
|
|
|
|
- _thread_event(quiet),
|
|
|
|
|
- _thread_event(after_me),
|
|
|
|
|
- _thread_event(newest),
|
|
|
|
|
- ]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ body="@hermes_bot status please", message_id="msg-5", created_at=at(6)
|
|
|
)
|
|
)
|
|
|
|
|
+ history.extend([after_me, newest])
|
|
|
|
|
+ _serve_thread(after, history)
|
|
|
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
after, newest.body, message_id="msg-5", thread_root="cron-1"
|
|
after, newest.body, message_id="msg-5", thread_root="cron-1"
|
|
@@ -2471,12 +2489,16 @@ class TestThreadContext:
|
|
|
actor_id="bot-user-id",
|
|
actor_id="bot-user-id",
|
|
|
created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
|
)
|
|
)
|
|
|
- adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(events=[_thread_event(cron)])
|
|
|
|
|
|
|
+ first = _make_message(
|
|
|
|
|
+ body="@hermes_bot first",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ created_at=datetime(2026, 10, 11, 8, 1, tzinfo=UTC),
|
|
|
)
|
|
)
|
|
|
|
|
+ history = [cron, first]
|
|
|
|
|
+ _serve_thread(adapter, history)
|
|
|
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
- adapter, "@hermes_bot first", message_id="msg-1", thread_root="cron-1"
|
|
|
|
|
|
|
+ adapter, first.body, message_id="msg-1", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
adapter, "@hermes_bot second", message_id="msg-2", thread_root="cron-1"
|
|
adapter, "@hermes_bot second", message_id="msg-2", thread_root="cron-1"
|
|
@@ -2684,18 +2706,13 @@ class TestThreadContext:
|
|
|
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),
|
|
|
)
|
|
)
|
|
|
- history = TimelinePage(events=[_thread_event(human), _thread_event(from_bot)])
|
|
|
|
|
- adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- side_effect=[
|
|
|
|
|
- TimelinePage(events=[_thread_event(human)]),
|
|
|
|
|
- history,
|
|
|
|
|
- history,
|
|
|
|
|
- ]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ history = [human]
|
|
|
|
|
+ _serve_thread(adapter, history)
|
|
|
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
adapter, human.body, message_id="msg-1", thread_root="msg-1"
|
|
adapter, human.body, message_id="msg-1", thread_root="msg-1"
|
|
|
)
|
|
)
|
|
|
|
|
+ history.append(from_bot)
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
adapter, "@hermes_bot and now?", message_id="msg-2", thread_root="msg-1"
|
|
adapter, "@hermes_bot and now?", message_id="msg-2", thread_root="msg-1"
|
|
|
)
|
|
)
|
|
@@ -2737,22 +2754,13 @@ class TestThreadContext:
|
|
|
message_id="msg-2",
|
|
message_id="msg-2",
|
|
|
created_at=datetime(2026, 10, 11, 8, 6, tzinfo=UTC),
|
|
created_at=datetime(2026, 10, 11, 8, 6, tzinfo=UTC),
|
|
|
)
|
|
)
|
|
|
- adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- side_effect=[
|
|
|
|
|
- TimelinePage(events=[_thread_event(cron), _thread_event(first)]),
|
|
|
|
|
- TimelinePage(
|
|
|
|
|
- events=[
|
|
|
|
|
- _thread_event(cron),
|
|
|
|
|
- _thread_event(first),
|
|
|
|
|
- _thread_event(second),
|
|
|
|
|
- ]
|
|
|
|
|
- ),
|
|
|
|
|
- ]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ history = [cron, first]
|
|
|
|
|
+ _serve_thread(adapter, history)
|
|
|
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
adapter, first.body, message_id="msg-1", thread_root="cron-1"
|
|
adapter, first.body, message_id="msg-1", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
|
|
|
+ history.append(second)
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
adapter, second.body, message_id="msg-2", thread_root="cron-1"
|
|
adapter, second.body, message_id="msg-2", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
@@ -2907,36 +2915,36 @@ class TestThreadContext:
|
|
|
"in the context, while repeating a turn the session already holds"
|
|
"in the context, while repeating a turn the session already holds"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- async def test_an_empty_fetch_records_no_cursor_rather_than_an_unfindable_one(
|
|
|
|
|
|
|
+ async def test_an_empty_fetch_records_the_trigger_and_the_next_turn_recovers(
|
|
|
self,
|
|
self,
|
|
|
):
|
|
):
|
|
|
- """An empty window names no position.
|
|
|
|
|
-
|
|
|
|
|
- The trigger is not a usable stand-in for one: recording it leaves a cursor
|
|
|
|
|
- the next fetch cannot find, which then reads as a thread that outgrew its
|
|
|
|
|
- cursor and mutes it for good. Leaving the thread unrecorded means the next
|
|
|
|
|
- turn tries again from the top.
|
|
|
|
|
|
|
+ """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.
|
|
|
"""
|
|
"""
|
|
|
- store = _FakeSessionStore()
|
|
|
|
|
- adapter = self._adapter()
|
|
|
|
|
- self._seed_channel(adapter)
|
|
|
|
|
- adapter.set_session_store(store)
|
|
|
|
|
- # The thread fetch is empty and the root cannot be fetched either, so
|
|
|
|
|
- # the window names no message at all. get_message answers per event: the
|
|
|
|
|
- # trigger resolves (the turn dispatches normally), the root does not.
|
|
|
|
|
trigger = _make_message(
|
|
trigger = _make_message(
|
|
|
body="@hermes_bot anyone there?",
|
|
body="@hermes_bot anyone there?",
|
|
|
message_id="msg-1",
|
|
message_id="msg-1",
|
|
|
created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
|
)
|
|
)
|
|
|
- adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(events=[])
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ adapter.set_session_store(_FakeSessionStore())
|
|
|
|
|
+ # The thread fetch is empty and the root cannot be fetched either, so the
|
|
|
|
|
+ # window names no message at all. get_message answers per event: the
|
|
|
|
|
+ # trigger resolves (the turn dispatches normally), the root does not.
|
|
|
adapter._chatto_client.get_message = AsyncMock(
|
|
adapter._chatto_client.get_message = AsyncMock(
|
|
|
side_effect=lambda room_id, event_id: (
|
|
side_effect=lambda room_id, event_id: (
|
|
|
trigger if event_id == "msg-1" else None
|
|
trigger if event_id == "msg-1" else None
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
|
|
+ history: list = []
|
|
|
|
|
+ _serve_thread(adapter, history)
|
|
|
payload = _make_posted_payload(room_id="room-1", message_event_id="msg-1")
|
|
payload = _make_posted_payload(room_id="room-1", message_event_id="msg-1")
|
|
|
payload.thread_root_event_id = "t1"
|
|
payload.thread_root_event_id = "t1"
|
|
|
|
|
|
|
@@ -2945,22 +2953,109 @@ class TestThreadContext:
|
|
|
assert adapter.handle_message.await_count == 1, (
|
|
assert adapter.handle_message.await_count == 1, (
|
|
|
"the turn did not dispatch at all, so this proves nothing about the cursor"
|
|
"the turn did not dispatch at all, so this proves nothing about the cursor"
|
|
|
)
|
|
)
|
|
|
- assert "t1" not in adapter._seeded_threads, (
|
|
|
|
|
- "a cursor the fetched window cannot name was recorded; the next turn "
|
|
|
|
|
- "would look for it, fail to find it, and take the thread silent"
|
|
|
|
|
- )
|
|
|
|
|
- # And the turn after it recovers instead of staying silent.
|
|
|
|
|
|
|
+ assert adapter._seeded_threads.get("t1") == "msg-1"
|
|
|
|
|
+
|
|
|
|
|
+ # 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?",
|
|
|
message_id="msg-2",
|
|
message_id="msg-2",
|
|
|
actor_id="user-1",
|
|
actor_id="user-1",
|
|
|
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(
|
|
|
|
|
- return_value=TimelinePage(events=[_thread_event(human)])
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ history.append(human)
|
|
|
await self._dispatch(adapter, human.body, message_id="msg-2", thread_root="t1")
|
|
await self._dispatch(adapter, human.body, message_id="msg-2", thread_root="t1")
|
|
|
assert adapter._seeded_threads["t1"] == "msg-2"
|
|
assert adapter._seeded_threads["t1"] == "msg-2"
|
|
|
|
|
+ assert "still there?" not in (
|
|
|
|
|
+ self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ ), (
|
|
|
|
|
+ "the turn's own trigger was delivered into its own context, so the "
|
|
|
|
|
+ "cursor did not pass it"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_thread_longer_than_the_window_still_delivers_the_tool_output(self):
|
|
|
|
|
+ """The case the fetch anchor exists for.
|
|
|
|
|
+
|
|
|
|
|
+ The window is capped at THREAD_CONTEXT_MESSAGE_LIMIT, and an unanchored
|
|
|
|
|
+ page returns the *newest* messages in the thread — so once the cursor is
|
|
|
|
|
+ older than the window it is not in the page, there is no tail to find
|
|
|
|
|
+ inside it, and every later turn answers without ever seeing what the bot
|
|
|
|
|
+ said. That is the thread going permanently silent, not degrading.
|
|
|
|
|
+
|
|
|
|
|
+ Anchored, the server returns the oldest messages *past* the cursor, so the
|
|
|
|
|
+ window and the thing being delivered are the same window. A backlog larger
|
|
|
|
|
+ than the window then drains over turns instead of being lost: each turn
|
|
|
|
|
+ takes the next slice, and nothing is repeated.
|
|
|
|
|
+ """
|
|
|
|
|
+ limit = ChattoConstants.THREAD_CONTEXT_MESSAGE_LIMIT
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ store = _FakeSessionStore()
|
|
|
|
|
+ adapter.set_session_store(store)
|
|
|
|
|
+
|
|
|
|
|
+ def at(minute: int):
|
|
|
|
|
+ return datetime(2026, 10, 11, 8, minute, tzinfo=UTC)
|
|
|
|
|
+
|
|
|
|
|
+ root = _make_message(
|
|
|
|
|
+ body="@hermes_bot deploy thread", message_id="msg-root", created_at=at(0)
|
|
|
|
|
+ )
|
|
|
|
|
+ history = [root]
|
|
|
|
|
+ _serve_thread(adapter, history)
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter, root.body, message_id="msg-root", thread_root="msg-root"
|
|
|
|
|
+ )
|
|
|
|
|
+ store.create_session(
|
|
|
|
|
+ adapter._session_key_for(adapter.handle_message.await_args.args[0].source)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # Grow the thread well past the window with chatter the session already
|
|
|
|
|
+ # holds, then put the tool output beyond the window's reach.
|
|
|
|
|
+ for index in range(limit + 5):
|
|
|
|
|
+ history.append(
|
|
|
|
|
+ _make_message(
|
|
|
|
|
+ body=f"chatter {index}",
|
|
|
|
|
+ message_id=f"chatter-{index}",
|
|
|
|
|
+ created_at=at(index + 1),
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ tool_post = _make_message(
|
|
|
|
|
+ body="Rolled the deploy back",
|
|
|
|
|
+ message_id="msg-tool",
|
|
|
|
|
+ actor_id="bot-user-id",
|
|
|
|
|
+ created_at=at(limit + 6),
|
|
|
|
|
+ )
|
|
|
|
|
+ ask = _make_message(
|
|
|
|
|
+ body="@hermes_bot status?", message_id="msg-ask", created_at=at(limit + 7)
|
|
|
|
|
+ )
|
|
|
|
|
+ history.extend([tool_post, ask])
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter, ask.body, message_id="msg-ask", thread_root="msg-root"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ first = self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ assert "chatter 0" in first, (
|
|
|
|
|
+ "the anchored fetch did not come back with the oldest slice after the "
|
|
|
|
|
+ "cursor, which is the whole point of anchoring"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert adapter._seeded_threads["msg-root"] == "chatter-19", (
|
|
|
|
|
+ "the cursor did not stop at the last message actually delivered, so "
|
|
|
|
|
+ "the next turn would skip the backlog"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ nxt = _make_message(
|
|
|
|
|
+ body="@hermes_bot and now?", message_id="msg-next", created_at=at(limit + 8)
|
|
|
|
|
+ )
|
|
|
|
|
+ history.append(nxt)
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter, nxt.body, message_id="msg-next", thread_root="msg-root"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ second = self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ assert "Rolled the deploy back" in second, (
|
|
|
|
|
+ "the backlog never drained: the tool output the session was missing "
|
|
|
|
|
+ "is still invisible after the thread grew past the window"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert "chatter 0" not in second, "the delivered backlog was repeated"
|
|
|
|
|
|
|
|
async def test_the_replying_message_is_not_seeded_into_its_own_context(self):
|
|
async def test_the_replying_message_is_not_seeded_into_its_own_context(self):
|
|
|
"""It is already the message text; repeating it in the block would tell
|
|
"""It is already the message text; repeating it in the block would tell
|