|
@@ -2185,12 +2185,13 @@ 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_does_not_reseed_a_thread_whose_session_survived(self):
|
|
|
|
|
- """A session that carries no cursor is one this bookkeeping predates.
|
|
|
|
|
|
|
+ async def test_a_restart_rebuilds_the_partition_instead_of_reseeding(self):
|
|
|
|
|
+ """A restart drops the in-memory cursor while the session survives.
|
|
|
|
|
|
|
|
- Its history is in the session and the cursor is unknowable, so the block
|
|
|
|
|
- is not re-delivered: re-seeding would repeat context the session already
|
|
|
|
|
- holds, which is the one thing the cursor exists to prevent.
|
|
|
|
|
|
|
+ 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.
|
|
|
"""
|
|
"""
|
|
|
before = self._adapter()
|
|
before = self._adapter()
|
|
|
self._seed_channel(before)
|
|
self._seed_channel(before)
|
|
@@ -2200,6 +2201,17 @@ 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),
|
|
|
)
|
|
)
|
|
|
|
|
+ human = _make_message(
|
|
|
|
|
+ body="@hermes_bot and the deploy?",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ created_at=datetime(2026, 10, 11, 8, 5, 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, 6, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
before._chatto_client.get_thread_events = AsyncMock(
|
|
before._chatto_client.get_thread_events = AsyncMock(
|
|
|
return_value=TimelinePage(events=[_thread_event(cron)])
|
|
return_value=TimelinePage(events=[_thread_event(cron)])
|
|
|
)
|
|
)
|
|
@@ -2210,150 +2222,37 @@ class TestThreadContext:
|
|
|
before.handle_message.await_args.args[0].source
|
|
before.handle_message.await_args.args[0].source
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- # A fresh adapter: the deque is gone, as it is after any restart. The
|
|
|
|
|
|
|
+ # A fresh adapter: the cursor is gone, as it is after any restart. The
|
|
|
# session store still resolves the key — it is persistent.
|
|
# session store still resolves the key — it is persistent.
|
|
|
after = self._adapter()
|
|
after = self._adapter()
|
|
|
self._seed_channel(after)
|
|
self._seed_channel(after)
|
|
|
assert dict(after._seeded_threads) == {}
|
|
assert dict(after._seeded_threads) == {}
|
|
|
after.set_session_store(
|
|
after.set_session_store(
|
|
|
- MagicMock(
|
|
|
|
|
- peek_session_id=MagicMock(return_value="sess-7"),
|
|
|
|
|
- # A session that predates cursor bookkeeping carries none — the
|
|
|
|
|
- # case this asserts: history is in the session, cursor unknown.
|
|
|
|
|
- get_session_metadata=MagicMock(return_value=None),
|
|
|
|
|
- set_session_metadata=MagicMock(return_value=True),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ MagicMock(peek_session_id=MagicMock(return_value="sess-7"))
|
|
|
)
|
|
)
|
|
|
after._chatto_client.get_thread_events = AsyncMock(
|
|
after._chatto_client.get_thread_events = AsyncMock(
|
|
|
- return_value=TimelinePage(events=[_thread_event(cron)])
|
|
|
|
|
|
|
+ return_value=TimelinePage(
|
|
|
|
|
+ events=[
|
|
|
|
|
+ _thread_event(cron),
|
|
|
|
|
+ _thread_event(human),
|
|
|
|
|
+ _thread_event(from_bot),
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
await self._dispatch(
|
|
await self._dispatch(
|
|
|
after, "@hermes_bot after restart", message_id="msg-2", thread_root="cron-1"
|
|
after, "@hermes_bot after restart", message_id="msg-2", thread_root="cron-1"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- after._chatto_client.get_thread_events.assert_not_called()
|
|
|
|
|
- assert after._chatto_client.get_thread_events.await_count == 0
|
|
|
|
|
resumed = after.handle_message.await_args.args[0]
|
|
resumed = after.handle_message.await_args.args[0]
|
|
|
- assert "Nightly build finished" not in (resumed.channel_context or "")
|
|
|
|
|
|
|
+ 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"
|
|
|
|
|
+ )
|
|
|
# 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.call_args.args == (session_key,)
|
|
|
|
|
|
|
|
- async def test_a_persisted_cursor_survives_a_restart(self):
|
|
|
|
|
- """The cursor lives on the session, so a restart keeps it. Without that,
|
|
|
|
|
- a thread answered in before the restart falls back to delivering
|
|
|
|
|
- nothing at all — and a chatto_send_room message posted into it while the
|
|
|
|
|
- gateway was down stays invisible for the rest of the thread's life."""
|
|
|
|
|
- adapter = self._adapter()
|
|
|
|
|
- self._seed_channel(adapter)
|
|
|
|
|
- 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),
|
|
|
|
|
- )
|
|
|
|
|
- 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.set_session_store(
|
|
|
|
|
- MagicMock(
|
|
|
|
|
- peek_session_id=MagicMock(return_value="sess-7"),
|
|
|
|
|
- get_session_metadata=MagicMock(return_value="msg-1"),
|
|
|
|
|
- set_session_metadata=MagicMock(return_value=True),
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
- adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(
|
|
|
|
|
- events=[_thread_event(human), _thread_event(from_bot)]
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- await self._dispatch(
|
|
|
|
|
- adapter, "@hermes_bot any news?", message_id="msg-2", thread_root="msg-1"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- context = self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
- assert "All green, deploying now" in context, (
|
|
|
|
|
- "the persisted cursor was not honoured — a thread answered in "
|
|
|
|
|
- "before the restart delivers nothing again"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- async def test_the_cursor_is_written_to_the_session_only_when_it_moves(self):
|
|
|
|
|
- """A write per turn would mean a full session-index save per turn in a
|
|
|
|
|
- live thread. The cursor has to be persisted exactly when it advances."""
|
|
|
|
|
- adapter = self._adapter()
|
|
|
|
|
- self._seed_channel(adapter)
|
|
|
|
|
- store = MagicMock(
|
|
|
|
|
- peek_session_id=MagicMock(return_value=None),
|
|
|
|
|
- get_session_metadata=MagicMock(return_value=None),
|
|
|
|
|
- set_session_metadata=MagicMock(return_value=True),
|
|
|
|
|
- )
|
|
|
|
|
- adapter.set_session_store(store)
|
|
|
|
|
- human = _make_message(
|
|
|
|
|
- body="@hermes_bot hi",
|
|
|
|
|
- message_id="m-0",
|
|
|
|
|
- actor_id="user-1",
|
|
|
|
|
- created_at=datetime(2026, 10, 11, 8, 0, tzinfo=UTC),
|
|
|
|
|
- )
|
|
|
|
|
- from_bot = _make_message(
|
|
|
|
|
- body="All green",
|
|
|
|
|
- message_id="m-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=[
|
|
|
|
|
- TimelinePage(events=[_thread_event(human)]),
|
|
|
|
|
- TimelinePage(events=[_thread_event(human), _thread_event(from_bot)]),
|
|
|
|
|
- TimelinePage(events=[_thread_event(human), _thread_event(from_bot)]),
|
|
|
|
|
- ]
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- await self._dispatch(
|
|
|
|
|
- adapter, "@hermes_bot first", message_id="msg-1", thread_root="m-0"
|
|
|
|
|
- )
|
|
|
|
|
- assert store.set_session_metadata.call_count == 1
|
|
|
|
|
-
|
|
|
|
|
- # This turn delivers the tool message, so the cursor moves — a write.
|
|
|
|
|
- store.set_session_metadata.reset_mock()
|
|
|
|
|
- await self._dispatch(
|
|
|
|
|
- adapter, "@hermes_bot news?", message_id="msg-2", thread_root="m-0"
|
|
|
|
|
- )
|
|
|
|
|
- assert "All green" in (self._dispatched_event(adapter).channel_context or "")
|
|
|
|
|
- store.set_session_metadata.assert_called_once()
|
|
|
|
|
-
|
|
|
|
|
- # Nothing new this time: the cursor does not move, so nothing is written.
|
|
|
|
|
- store.set_session_metadata.reset_mock()
|
|
|
|
|
- await self._dispatch(
|
|
|
|
|
- adapter, "@hermes_bot more?", message_id="msg-3", thread_root="m-0"
|
|
|
|
|
- )
|
|
|
|
|
- store.set_session_metadata.assert_not_called()
|
|
|
|
|
-
|
|
|
|
|
- async def test_a_session_store_that_refuses_the_cursor_costs_nothing(self):
|
|
|
|
|
- """The store is best-effort bookkeeping. A False from it must not fail
|
|
|
|
|
- the turn — the in-memory cursor still carries the thread meanwhile."""
|
|
|
|
|
- adapter = self._adapter()
|
|
|
|
|
- self._seed_channel(adapter)
|
|
|
|
|
- adapter.set_session_store(
|
|
|
|
|
- MagicMock(
|
|
|
|
|
- peek_session_id=MagicMock(return_value="sess-7"),
|
|
|
|
|
- get_session_metadata=MagicMock(return_value=None),
|
|
|
|
|
- set_session_metadata=MagicMock(return_value=False),
|
|
|
|
|
- )
|
|
|
|
|
- )
|
|
|
|
|
- adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
|
|
- return_value=TimelinePage(events=[])
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- await self._dispatch(
|
|
|
|
|
- adapter, "@hermes_bot hi", message_id="msg-1", thread_root="m-0"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- assert self._dispatched_event(adapter) is not None
|
|
|
|
|
-
|
|
|
|
|
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
|
|
|
situation seeding exists for. Fails open: seed rather than answer a
|
|
situation seeding exists for. Fails open: seed rather than answer a
|
|
@@ -2634,6 +2533,108 @@ class TestThreadContext:
|
|
|
"advance, so every turn repeats it"
|
|
"advance, so every turn repeats it"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ async def test_the_previous_turns_trigger_is_not_re_delivered(self):
|
|
|
|
|
+ """The trigger is not seeded into its own context — but it IS in the
|
|
|
|
|
+ session, as the message that turn was built from. So the cursor has to
|
|
|
|
|
+ advance past it too, or the next turn hands the agent the previous
|
|
|
|
|
+ question a second time, as an [Earlier in this thread] line next to the
|
|
|
|
|
+ conversation that already contains it."""
|
|
|
|
|
+ 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, 5, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ second = _make_message(
|
|
|
|
|
+ body="@hermes_bot any news?",
|
|
|
|
|
+ message_id="msg-2",
|
|
|
|
|
+ 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),
|
|
|
|
|
+ ]
|
|
|
|
|
+ ),
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter, first.body, message_id="msg-1", thread_root="cron-1"
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter, second.body, message_id="msg-2", thread_root="cron-1"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ assert "and the deploy?" not in context, (
|
|
|
|
|
+ "turn 1's own trigger came back in turn 2's context: the cursor was "
|
|
|
|
|
+ "left behind the message the session already holds"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_lost_cursor_is_rebuilt_after_a_restart(
|
|
|
|
|
+ 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."""
|
|
|
|
|
+ before = self._adapter()
|
|
|
|
|
+ self._seed_channel(before)
|
|
|
|
|
+ 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),
|
|
|
|
|
+ )
|
|
|
|
|
+ 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),
|
|
|
|
|
+ )
|
|
|
|
|
+ before.set_session_store(
|
|
|
|
|
+ MagicMock(peek_session_id=MagicMock(return_value=None))
|
|
|
|
|
+ )
|
|
|
|
|
+ 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"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # Restart: a fresh adapter, the session survived, the cursor with it?
|
|
|
|
|
+ after = self._adapter()
|
|
|
|
|
+ self._seed_channel(after)
|
|
|
|
|
+ after.set_session_store(
|
|
|
|
|
+ MagicMock(peek_session_id=MagicMock(return_value="sess-1"))
|
|
|
|
|
+ )
|
|
|
|
|
+ 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 after a restart: a lost cursor must be "
|
|
|
|
|
+ "rebuilt from the fetch, never treated as a session to leave alone"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
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
|
|
|
the agent the user said it twice."""
|
|
the agent the user said it twice."""
|