|
|
@@ -2158,9 +2158,11 @@ class TestThreadContext:
|
|
|
context = self._dispatched_event(adapter).channel_context
|
|
|
assert "Nightly build finished" in context
|
|
|
|
|
|
- async def test_second_turn_in_the_thread_seeds_only_once(self):
|
|
|
+ async def test_second_turn_in_the_thread_repeats_no_context(self):
|
|
|
"""The session already holds what the first turn seeded — repeating it
|
|
|
- would duplicate context the agent has."""
|
|
|
+ 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."""
|
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
|
cron = _make_message(
|
|
|
@@ -2180,7 +2182,6 @@ class TestThreadContext:
|
|
|
adapter, "@hermes_bot second", message_id="msg-2", thread_root="cron-1"
|
|
|
)
|
|
|
|
|
|
- assert adapter._chatto_client.get_thread_events.await_count == 1
|
|
|
second = adapter.handle_message.await_args.args[0]
|
|
|
assert "Nightly build finished" not in (second.channel_context or "")
|
|
|
|
|
|
@@ -2213,7 +2214,7 @@ class TestThreadContext:
|
|
|
# session store still resolves the key — it is persistent.
|
|
|
after = self._adapter()
|
|
|
self._seed_channel(after)
|
|
|
- assert list(after._seeded_threads) == []
|
|
|
+ assert dict(after._seeded_threads) == {}
|
|
|
after.set_session_store(
|
|
|
MagicMock(peek_session_id=MagicMock(return_value="sess-7"))
|
|
|
)
|
|
|
@@ -2259,9 +2260,9 @@ class TestThreadContext:
|
|
|
"Nightly build finished" in self._dispatched_event(adapter).channel_context
|
|
|
)
|
|
|
|
|
|
- async def test_without_a_session_store_the_deque_still_guards_repeat_seeding(self):
|
|
|
+ async def test_without_a_session_store_the_cursor_still_guards_duplication(self):
|
|
|
"""No store wired (tests, or a gateway path that has not registered it
|
|
|
- yet) must not disable seeding's once-per-thread behaviour."""
|
|
|
+ yet) must not disable the record of what has already been delivered."""
|
|
|
adapter = self._adapter()
|
|
|
self._seed_channel(adapter)
|
|
|
cron = _make_message(
|
|
|
@@ -2281,7 +2282,8 @@ class TestThreadContext:
|
|
|
adapter, "@hermes_bot second", message_id="msg-2", thread_root="cron-1"
|
|
|
)
|
|
|
|
|
|
- assert adapter._chatto_client.get_thread_events.await_count == 1
|
|
|
+ second = adapter.handle_message.await_args.args[0]
|
|
|
+ assert "Nightly build finished" not in (second.channel_context or "")
|
|
|
|
|
|
async def test_the_bot_knows_the_seeded_cron_message_is_its_own(self):
|
|
|
"""The cron message is the bot's output that it never saw arrive —
|
|
|
@@ -2366,7 +2368,7 @@ class TestThreadContext:
|
|
|
adapter, "@hermes_bot first", message_id="msg-1", thread_root="cron-1"
|
|
|
)
|
|
|
|
|
|
- assert list(adapter._seeded_threads) == []
|
|
|
+ assert dict(adapter._seeded_threads) == {}
|
|
|
|
|
|
adapter._chatto_client.get_thread_events = AsyncMock(
|
|
|
return_value=TimelinePage(events=[_thread_event(cron)])
|
|
|
@@ -2412,6 +2414,105 @@ class TestThreadContext:
|
|
|
context = self._dispatched_event(adapter).channel_context
|
|
|
assert "Nightly build finished" in context
|
|
|
|
|
|
+ async def test_a_tool_message_posted_into_a_live_thread_reaches_the_session(self):
|
|
|
+ """A thread the agent is already answering in, into which a
|
|
|
+ chatto_send_room reply lands. That message is the bot's own, so its
|
|
|
+ realtime echo is filtered as a self-event and never dispatched — the
|
|
|
+ thread session therefore does not contain it, and the next human
|
|
|
+ message in that thread is answered by an agent that never saw what it
|
|
|
+ said. Seeding covers this only on the FIRST turn; a thread already
|
|
|
+ answered in gets nothing."""
|
|
|
+ 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),
|
|
|
+ )
|
|
|
+ # 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)]),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+
|
|
|
+ # First turn seeds the thread — it must NOT contain the later message.
|
|
|
+ await self._dispatch(
|
|
|
+ adapter, human.body, message_id="msg-1", thread_root="msg-1"
|
|
|
+ )
|
|
|
+ assert "All green" not in (
|
|
|
+ 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.
|
|
|
+ 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, (
|
|
|
+ "a message the bot posted into this thread via chatto_send_room is "
|
|
|
+ "missing from the thread session: its echo is filtered as a self-"
|
|
|
+ "event, and seeding already happened on the first turn"
|
|
|
+ )
|
|
|
+
|
|
|
+ async def test_a_delivered_tail_is_not_delivered_again(self):
|
|
|
+ """The cursor must advance past what it delivered. Without that, every
|
|
|
+ later turn in the thread repeats the same block — the duplication the
|
|
|
+ once-per-thread rule existed to prevent, now reintroduced per turn."""
|
|
|
+ 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),
|
|
|
+ )
|
|
|
+ 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,
|
|
|
+ ]
|
|
|
+ )
|
|
|
+
|
|
|
+ await self._dispatch(
|
|
|
+ adapter, human.body, message_id="msg-1", thread_root="msg-1"
|
|
|
+ )
|
|
|
+ await self._dispatch(
|
|
|
+ adapter, "@hermes_bot and now?", message_id="msg-2", thread_root="msg-1"
|
|
|
+ )
|
|
|
+ assert "All green, deploying now" in (
|
|
|
+ self._dispatched_event(adapter).channel_context or ""
|
|
|
+ )
|
|
|
+
|
|
|
+ adapter.handle_message.reset_mock()
|
|
|
+ await self._dispatch(
|
|
|
+ adapter, "@hermes_bot still?", message_id="msg-3", thread_root="msg-1"
|
|
|
+ )
|
|
|
+ third = adapter.handle_message.await_args.args[0].channel_context or ""
|
|
|
+ assert "All green, deploying now" not in third, (
|
|
|
+ "the tail was delivered again on a later turn — the cursor did not "
|
|
|
+ "advance, so every turn repeats it"
|
|
|
+ )
|
|
|
+
|
|
|
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
|
|
|
the agent the user said it twice."""
|
|
|
@@ -2541,7 +2642,7 @@ class TestThreadContext:
|
|
|
await self._dispatch(adapter, "@hermes_bot hi")
|
|
|
|
|
|
adapter._chatto_client.get_thread_events.assert_not_called()
|
|
|
- assert list(adapter._seeded_threads) == []
|
|
|
+ assert dict(adapter._seeded_threads) == {}
|
|
|
|
|
|
|
|
|
class TestEditDispatch:
|