|
@@ -2098,6 +2098,42 @@ def _thread_event(message):
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class _FakeSessionStore:
|
|
|
|
|
+ """An in-memory session store with the real store's shape.
|
|
|
|
|
+
|
|
|
|
|
+ Modelled on ``gateway.session`` rather than mocked wholesale, because two of
|
|
|
|
|
+ its behaviours are what the seed-cursor bookkeeping is built on:
|
|
|
|
|
+ ``set_session_metadata`` answers False when no session exists under that key
|
|
|
|
|
+ yet (the refusal a thread's first turn runs into), and metadata lives on the
|
|
|
|
|
+ session, so a second adapter handed this same instance sees what the first
|
|
|
|
|
+ one wrote — which is what surviving a restart means here.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.sessions: dict[str, str] = {}
|
|
|
|
|
+ self.metadata: dict[tuple[str, str], object] = {}
|
|
|
|
|
+ self.writes = 0
|
|
|
|
|
+
|
|
|
|
|
+ def create_session(self, session_key: str, session_id: str = "sess-1") -> None:
|
|
|
|
|
+ """What the gateway does once it has actually run a turn."""
|
|
|
|
|
+ self.sessions[session_key] = session_id
|
|
|
|
|
+
|
|
|
|
|
+ def peek_session_id(self, session_key: str):
|
|
|
|
|
+ return self.sessions.get(session_key)
|
|
|
|
|
+
|
|
|
|
|
+ def get_session_metadata(self, session_key: str, key: str, default=None):
|
|
|
|
|
+ if session_key not in self.sessions:
|
|
|
|
|
+ return default
|
|
|
|
|
+ return self.metadata.get((session_key, key), default)
|
|
|
|
|
+
|
|
|
|
|
+ def set_session_metadata(self, session_key: str, key: str, value) -> bool:
|
|
|
|
|
+ if session_key not in self.sessions:
|
|
|
|
|
+ return False
|
|
|
|
|
+ self.writes += 1
|
|
|
|
|
+ self.metadata[(session_key, key)] = value
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class TestThreadContext:
|
|
class TestThreadContext:
|
|
|
"""A thread session starts empty, so the first turn into a Chatto thread
|
|
"""A thread session starts empty, so the first turn into a Chatto thread
|
|
|
seeds the messages that came before it.
|
|
seeds the messages that came before it.
|
|
@@ -2185,16 +2221,17 @@ class TestThreadContext:
|
|
|
second = adapter.handle_message.await_args.args[0]
|
|
second = adapter.handle_message.await_args.args[0]
|
|
|
assert "Nightly build finished" not in (second.channel_context or "")
|
|
assert "Nightly build finished" not in (second.channel_context or "")
|
|
|
|
|
|
|
|
- async def test_a_restart_rebuilds_the_partition_instead_of_reseeding(self):
|
|
|
|
|
|
|
+ async def test_a_restart_resumes_from_the_cursor_the_session_kept(self):
|
|
|
"""A restart drops the in-memory cursor while the session survives.
|
|
"""A restart drops the in-memory cursor while the session survives.
|
|
|
|
|
|
|
|
- It is rebuilt from the fetch already being done: the newest message that
|
|
|
|
|
- is not ours is the last one the session certainly holds, so everything
|
|
|
|
|
- after it — our own tool output — is delivered, and the human messages
|
|
|
|
|
- the session already has are not repeated.
|
|
|
|
|
|
|
+ The session's own copy is what outlives the process, so the thread goes
|
|
|
|
|
+ on delivering its tail from exactly where it stopped — our own tool
|
|
|
|
|
+ output arrives, and the human messages the session already holds do not.
|
|
|
"""
|
|
"""
|
|
|
|
|
+ store = _FakeSessionStore()
|
|
|
before = self._adapter()
|
|
before = self._adapter()
|
|
|
self._seed_channel(before)
|
|
self._seed_channel(before)
|
|
|
|
|
+ before.set_session_store(store)
|
|
|
cron = _make_message(
|
|
cron = _make_message(
|
|
|
body="Nightly build finished",
|
|
body="Nightly build finished",
|
|
|
message_id="cron-1",
|
|
message_id="cron-1",
|
|
@@ -2221,14 +2258,86 @@ class TestThreadContext:
|
|
|
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
|
|
|
)
|
|
)
|
|
|
|
|
+ # The gateway creates the session once it has run a turn, which is what
|
|
|
|
|
+ # lets the *next* turn's cursor land on it.
|
|
|
|
|
+ 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),
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ before, "@hermes_bot thanks", message_id="msg-2", thread_root="cron-1"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert store.writes == 1, (
|
|
|
|
|
+ "delivering our own output is the one turn whose cursor has to "
|
|
|
|
|
+ "outlive the process, so it is the one turn that must write — and "
|
|
|
|
|
+ "the pending cursor from turn one rides along on it rather than "
|
|
|
|
|
+ "costing a second routing-table rewrite"
|
|
|
|
|
+ )
|
|
|
|
|
+ later_tool_post = _make_message(
|
|
|
|
|
+ body="Rolled back",
|
|
|
|
|
+ message_id="msg-tool2",
|
|
|
|
|
+ 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),
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ before, "@hermes_bot next", message_id="msg-3", thread_root="cron-1"
|
|
|
|
|
+ )
|
|
|
|
|
+ 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),
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ before, quiet.body, message_id="msg-4", thread_root="cron-1"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert store.writes == 2, (
|
|
|
|
|
+ "a turn that delivered only the trigger wrote again — the cursor is "
|
|
|
|
|
+ "written when it cannot be recovered, not once per turn"
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- # A fresh adapter: the cursor is gone, as it is after any restart. The
|
|
|
|
|
- # session store still resolves the key — it is persistent.
|
|
|
|
|
|
|
+ # A fresh adapter: the in-memory cursor is gone, as it is after any
|
|
|
|
|
+ # restart. The store is the same one, which is the point.
|
|
|
after = self._adapter()
|
|
after = self._adapter()
|
|
|
self._seed_channel(after)
|
|
self._seed_channel(after)
|
|
|
|
|
+ after.set_session_store(store)
|
|
|
assert dict(after._seeded_threads) == {}
|
|
assert dict(after._seeded_threads) == {}
|
|
|
- after.set_session_store(
|
|
|
|
|
- MagicMock(peek_session_id=MagicMock(return_value="sess-7"))
|
|
|
|
|
|
|
+ after_me = _make_message(
|
|
|
|
|
+ body="Standing by",
|
|
|
|
|
+ message_id="msg-tool2",
|
|
|
|
|
+ actor_id="bot-user-id",
|
|
|
|
|
+ created_at=datetime(2026, 10, 11, 8, 9, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ 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(
|
|
after._chatto_client.get_thread_events = AsyncMock(
|
|
|
return_value=TimelinePage(
|
|
return_value=TimelinePage(
|
|
@@ -2236,22 +2345,29 @@ class TestThreadContext:
|
|
|
_thread_event(cron),
|
|
_thread_event(cron),
|
|
|
_thread_event(human),
|
|
_thread_event(human),
|
|
|
_thread_event(from_bot),
|
|
_thread_event(from_bot),
|
|
|
|
|
+ _thread_event(later_tool_post),
|
|
|
|
|
+ _thread_event(quiet),
|
|
|
|
|
+ _thread_event(after_me),
|
|
|
|
|
+ _thread_event(newest),
|
|
|
]
|
|
]
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
- after, "@hermes_bot after restart", message_id="msg-2", thread_root="cron-1"
|
|
|
|
|
|
|
+ after, newest.body, message_id="msg-5", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- resumed = after.handle_message.await_args.args[0]
|
|
|
|
|
- context = resumed.channel_context or ""
|
|
|
|
|
- assert "Deployed, all green" in context
|
|
|
|
|
- assert "and the deploy?" not in context, (
|
|
|
|
|
- "the human message the session already holds was delivered again"
|
|
|
|
|
|
|
+ context = self._dispatched_event(after).channel_context or ""
|
|
|
|
|
+ assert "Standing by" in context, (
|
|
|
|
|
+ "what landed after the persisted cursor was not delivered"
|
|
|
)
|
|
)
|
|
|
|
|
+ assert "Rolled back" not in context, (
|
|
|
|
|
+ "the resumed cursor is behind where it was written: the thread was "
|
|
|
|
|
+ "seeded from the top and repeated what the session already holds"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert "and the deploy?" not in context
|
|
|
# Sanity: the key the adapter asks about is the one the gateway used.
|
|
# Sanity: the key the adapter asks about is the one the gateway used.
|
|
|
- assert after._session_store.peek_session_id.call_args.args == (session_key,)
|
|
|
|
|
|
|
+ assert after._session_store.peek_session_id(session_key) == "sess-1"
|
|
|
|
|
|
|
|
async def test_a_thread_whose_session_is_unknown_still_seeds(self):
|
|
async def test_a_thread_whose_session_is_unknown_still_seeds(self):
|
|
|
"""No session bound to the key means the session is empty — the exact
|
|
"""No session bound to the key means the session is empty — the exact
|
|
@@ -2259,9 +2375,7 @@ class TestThreadContext:
|
|
|
reply the agent cannot read."""
|
|
reply the agent cannot read."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
self._seed_channel(adapter)
|
|
|
- adapter.set_session_store(
|
|
|
|
|
- MagicMock(peek_session_id=MagicMock(return_value=None))
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ adapter.set_session_store(_FakeSessionStore())
|
|
|
cron = _make_message(
|
|
cron = _make_message(
|
|
|
body="Nightly build finished",
|
|
body="Nightly build finished",
|
|
|
message_id="cron-1",
|
|
message_id="cron-1",
|
|
@@ -2583,29 +2697,99 @@ class TestThreadContext:
|
|
|
"left behind the message the session already holds"
|
|
"left behind the message the session already holds"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- async def test_a_lost_cursor_is_rebuilt_after_a_restart(
|
|
|
|
|
|
|
+ async def test_a_session_without_a_cursor_redelivers_only_the_bot_output(
|
|
|
self,
|
|
self,
|
|
|
):
|
|
):
|
|
|
- """A thread whose in-memory cursor is gone and whose session survived.
|
|
|
|
|
-
|
|
|
|
|
- The cursor is rebuilt from the fetch rather than trusted, so the thread
|
|
|
|
|
- keeps delivering its own output instead of going silent for good."""
|
|
|
|
|
|
|
+ """The blind case: a session that survived with no cursor on it.
|
|
|
|
|
+
|
|
|
|
|
+ A thread's first turn has no session to write a cursor to, so a thread
|
|
|
|
|
+ seeded with nothing (a reply opening a fresh thread) leaves one behind
|
|
|
|
|
+ only if the process restarts before the second turn. There is no way to
|
|
|
|
|
+ reconstruct where the session stops, so this re-delivers the bot's own
|
|
|
|
|
+ messages — the ones no session holds and no later turn can recover —
|
|
|
|
|
+ leaves the people's messages to their own turns, and writes the cursor so
|
|
|
|
|
+ the next process is not guessing either.
|
|
|
|
|
+ """
|
|
|
|
|
+ store = _FakeSessionStore()
|
|
|
before = self._adapter()
|
|
before = self._adapter()
|
|
|
self._seed_channel(before)
|
|
self._seed_channel(before)
|
|
|
|
|
+ before.set_session_store(store)
|
|
|
human = _make_message(
|
|
human = _make_message(
|
|
|
body="@hermes_bot status?",
|
|
body="@hermes_bot status?",
|
|
|
message_id="msg-1",
|
|
message_id="msg-1",
|
|
|
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),
|
|
|
)
|
|
)
|
|
|
|
|
+ before._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
+ return_value=TimelinePage(events=[_thread_event(human)])
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ before, human.body, message_id="msg-1", thread_root="msg-1"
|
|
|
|
|
+ )
|
|
|
|
|
+ session_key = before._session_key_for(
|
|
|
|
|
+ before.handle_message.await_args.args[0].source
|
|
|
|
|
+ )
|
|
|
|
|
+ store.create_session(session_key)
|
|
|
|
|
+ assert store.writes == 0, (
|
|
|
|
|
+ "a thread's first turn cannot write a cursor — there is no session "
|
|
|
|
|
+ "yet — so it must not pretend to"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
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),
|
|
|
)
|
|
)
|
|
|
- before.set_session_store(
|
|
|
|
|
- MagicMock(peek_session_id=MagicMock(return_value=None))
|
|
|
|
|
|
|
+ after = self._adapter()
|
|
|
|
|
+ self._seed_channel(after)
|
|
|
|
|
+ after.set_session_store(store)
|
|
|
|
|
+ after._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
+ return_value=TimelinePage(
|
|
|
|
|
+ events=[_thread_event(human), _thread_event(from_bot)]
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ after, "@hermes_bot any news?", message_id="msg-2", thread_root="msg-1"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ context = self._dispatched_event(after).channel_context or ""
|
|
|
|
|
+ assert "All green, deploying now" in context, (
|
|
|
|
|
+ "the thread went silent: with no cursor to go on, the bot's own "
|
|
|
|
|
+ "output is delivered once by hand rather than never"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert "status?" not in context, (
|
|
|
|
|
+ "the human message became a turn of its own and is in the session; "
|
|
|
|
|
+ "the blind pass delivers the bot's output only"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert store.writes == 1 and after._seeded_threads["msg-1"] == "msg-tool", (
|
|
|
|
|
+ "the blind pass is a once-per-thread event: it records the frontier "
|
|
|
|
|
+ "it just established, so the next restart resumes normally. Not the "
|
|
|
|
|
+ "trigger — that message is not in the fetched window, and a cursor "
|
|
|
|
|
+ "beyond the window is unreachable on the next turn"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_restart_keeps_the_bot_output_when_the_trigger_is_in_the_page(
|
|
|
|
|
+ self,
|
|
|
|
|
+ ):
|
|
|
|
|
+ """The shape a thread reply actually produces: the fetched window ends
|
|
|
|
|
+ with the turn's own trigger.
|
|
|
|
|
+
|
|
|
|
|
+ Rebuilding the partition as "the newest message that is not ours" lands
|
|
|
|
|
+ on that trigger — a person's message that became a turn just now — so the
|
|
|
|
|
+ tail comes out empty and every bot message before it is skipped. That is
|
|
|
|
|
+ the whole feature failing at exactly the moment it was added for, and it
|
|
|
|
|
+ is why the frontier is read off the session instead of guessed.
|
|
|
|
|
+ """
|
|
|
|
|
+ store = _FakeSessionStore()
|
|
|
|
|
+ before = self._adapter()
|
|
|
|
|
+ self._seed_channel(before)
|
|
|
|
|
+ before.set_session_store(store)
|
|
|
|
|
+ human = _make_message(
|
|
|
|
|
+ body="@hermes_bot status?",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ actor_id="user-1",
|
|
|
|
|
+ created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
|
)
|
|
)
|
|
|
before._chatto_client.get_thread_events = AsyncMock(
|
|
before._chatto_client.get_thread_events = AsyncMock(
|
|
|
return_value=TimelinePage(events=[_thread_event(human)])
|
|
return_value=TimelinePage(events=[_thread_event(human)])
|
|
@@ -2613,26 +2797,48 @@ class TestThreadContext:
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
before, human.body, message_id="msg-1", thread_root="msg-1"
|
|
before, human.body, message_id="msg-1", thread_root="msg-1"
|
|
|
)
|
|
)
|
|
|
|
|
+ store.create_session(
|
|
|
|
|
+ before._session_key_for(before.handle_message.await_args.args[0].source)
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- # Restart: a fresh adapter, the session survived, the cursor with it?
|
|
|
|
|
|
|
+ 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),
|
|
|
|
|
+ )
|
|
|
|
|
+ trigger = _make_message(
|
|
|
|
|
+ body="@hermes_bot any news?",
|
|
|
|
|
+ message_id="msg-2",
|
|
|
|
|
+ created_at=datetime(2026, 10, 11, 8, 2, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
after = self._adapter()
|
|
after = self._adapter()
|
|
|
self._seed_channel(after)
|
|
self._seed_channel(after)
|
|
|
- after.set_session_store(
|
|
|
|
|
- MagicMock(peek_session_id=MagicMock(return_value="sess-1"))
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ after.set_session_store(store)
|
|
|
after._chatto_client.get_thread_events = AsyncMock(
|
|
after._chatto_client.get_thread_events = AsyncMock(
|
|
|
return_value=TimelinePage(
|
|
return_value=TimelinePage(
|
|
|
- events=[_thread_event(human), _thread_event(from_bot)]
|
|
|
|
|
|
|
+ events=[
|
|
|
|
|
+ _thread_event(human),
|
|
|
|
|
+ _thread_event(from_bot),
|
|
|
|
|
+ _thread_event(trigger),
|
|
|
|
|
+ ]
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
|
|
+
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
- after, "@hermes_bot any news?", message_id="msg-2", thread_root="msg-1"
|
|
|
|
|
|
|
+ after, trigger.body, message_id="msg-2", thread_root="msg-1"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
context = self._dispatched_event(after).channel_context or ""
|
|
context = self._dispatched_event(after).channel_context or ""
|
|
|
assert "All green, deploying now" in context, (
|
|
assert "All green, deploying now" in context, (
|
|
|
- "the thread went silent after a restart: a lost cursor must be "
|
|
|
|
|
- "rebuilt from the fetch, never treated as a session to leave alone"
|
|
|
|
|
|
|
+ "the bot's own output in the thread was lost after a restart: the "
|
|
|
|
|
+ "thread went silent because the trigger is the newest message that "
|
|
|
|
|
+ "is not ours"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert "status?" not in context, (
|
|
|
|
|
+ "delivering our own output is how this thread's blind pass resolves "
|
|
|
|
|
+ "the cursor — re-seeding the whole thread would also put the output "
|
|
|
|
|
+ "in the context, while repeating a turn the session already holds"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
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):
|