|
@@ -708,9 +708,7 @@ class TestRoomToolsets:
|
|
|
"""A thread's source carries the room as ``chat_id`` and the thread
|
|
"""A thread's source carries the room as ``chat_id`` and the thread
|
|
|
separately, so the room's restriction has to reach its threads."""
|
|
separately, so the room's restriction has to reach its threads."""
|
|
|
adapter = _make_adapter(room_toolsets=["room-1:web"])
|
|
adapter = _make_adapter(room_toolsets=["room-1:web"])
|
|
|
- assert adapter.toolsets_for_source(self._source(chat_type="thread")) == [
|
|
|
|
|
- "web"
|
|
|
|
|
- ]
|
|
|
|
|
|
|
+ assert adapter.toolsets_for_source(self._source(chat_type="thread")) == ["web"]
|
|
|
|
|
|
|
|
def test_a_duplicate_toolset_is_listed_once(self):
|
|
def test_a_duplicate_toolset_is_listed_once(self):
|
|
|
adapter = _make_adapter(room_toolsets=["room-1:web", "room-1:web"])
|
|
adapter = _make_adapter(room_toolsets=["room-1:web", "room-1:web"])
|
|
@@ -1421,9 +1419,7 @@ class TestRealtimeResume:
|
|
|
|
|
|
|
|
async def test_reconnect_resumes_from_the_last_event_cursor(self):
|
|
async def test_reconnect_resumes_from_the_last_event_cursor(self):
|
|
|
adapter = _make_adapter()
|
|
adapter = _make_adapter()
|
|
|
- resume_cursors = await self._run_loop(
|
|
|
|
|
- adapter, [[self._event("cur-1")], []]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ resume_cursors = await self._run_loop(adapter, [[self._event("cur-1")], []])
|
|
|
assert resume_cursors == [None, "cur-1"]
|
|
assert resume_cursors == [None, "cur-1"]
|
|
|
|
|
|
|
|
async def test_an_unreplayable_event_keeps_the_previous_cursor(self):
|
|
async def test_an_unreplayable_event_keeps_the_previous_cursor(self):
|
|
@@ -1457,9 +1453,7 @@ class TestPersistedRealtimeCursor:
|
|
|
_persist_realtime_cursor(self._cfg(), "cur-42")
|
|
_persist_realtime_cursor(self._cfg(), "cur-42")
|
|
|
assert _restore_realtime_cursor(self._cfg()) == "cur-42"
|
|
assert _restore_realtime_cursor(self._cfg()) == "cur-42"
|
|
|
|
|
|
|
|
- def test_a_cursor_older_than_the_window_is_discarded(
|
|
|
|
|
- self, monkeypatch, tmp_path
|
|
|
|
|
- ):
|
|
|
|
|
|
|
+ def test_a_cursor_older_than_the_window_is_discarded(self, monkeypatch, tmp_path):
|
|
|
"""Replaying an outage of hours is worse than losing it: the window is
|
|
"""Replaying an outage of hours is worse than losing it: the window is
|
|
|
what keeps a restart bounded."""
|
|
what keeps a restart bounded."""
|
|
|
from adapter import _profile_scope_key, _restore_realtime_cursor
|
|
from adapter import _profile_scope_key, _restore_realtime_cursor
|
|
@@ -2813,6 +2807,20 @@ def _serve_thread(adapter, messages, limit=None):
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _serve_room_timeline(adapter, messages):
|
|
|
|
|
+ """Point ``get_room_events_around`` at a room's timeline, oldest first.
|
|
|
|
|
+
|
|
|
|
|
+ Modelled on the server's own handler for ``GetRoomEventsAround``: it serves
|
|
|
|
|
+ the whole room timeline around the anchor, and the adapter's room seed
|
|
|
|
|
+ filters both ends (newer messages and the trigger itself) from it.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ async def around(room_id, event_id, *, limit=None):
|
|
|
|
|
+ return TimelinePage(events=[_thread_event(m) for m in messages]), 0
|
|
|
|
|
+
|
|
|
|
|
+ adapter._chatto_client.get_room_events_around = AsyncMock(side_effect=around)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
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.
|
|
|
|
|
|
|
@@ -2849,6 +2857,276 @@ class _FakeSessionStore:
|
|
|
return True
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class TestRoomContext:
|
|
|
|
|
+ """A fresh room message roots its own session, so the first turn into a
|
|
|
|
|
+ room starts empty even when the room has been talking for hours.
|
|
|
|
|
+
|
|
|
|
|
+ The reported case: a human replies to the bot's message in a channel,
|
|
|
|
|
+ without opening a thread. The agent answers with no idea what the reply
|
|
|
|
|
+ refers to — the reply pointer names the message, but nothing carried what
|
|
|
|
|
+ that message said.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ def _adapter(self):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ adapter.chatto_config.allow_all_users.value = True
|
|
|
|
|
+ adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
|
|
+ adapter._user_cache["user-1"] = _roster_user("user-1", "alice")
|
|
|
|
|
+ # The roster rides on every channel turn; an AsyncMock without a
|
|
|
|
|
+ # roster shape would answer a list of IDs, not the (ids, Page) pair.
|
|
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
|
|
+ return_value=(["user-1"], Page())
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter._chatto_client.batch_get_room_members = AsyncMock(
|
|
|
|
|
+ return_value=[DirectoryMember(user=_roster_user("user-1", "alice"))]
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter.handle_message = AsyncMock()
|
|
|
|
|
+ return adapter
|
|
|
|
|
+
|
|
|
|
|
+ def _seed_channel(self, adapter, room_id="room-1"):
|
|
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = [room_id]
|
|
|
|
|
+ adapter._room_kinds[room_id] = RoomKind.CHANNEL
|
|
|
|
|
+
|
|
|
|
|
+ async def _dispatch(
|
|
|
|
|
+ self,
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ body,
|
|
|
|
|
+ *,
|
|
|
|
|
+ room_id="room-1",
|
|
|
|
|
+ message_id="msg-9",
|
|
|
|
|
+ created_at=None,
|
|
|
|
|
+ reply_to="",
|
|
|
|
|
+ reply_target=None,
|
|
|
|
|
+ ):
|
|
|
|
|
+ payload = _make_posted_payload(room_id=room_id, message_event_id=message_id)
|
|
|
|
|
+ message = _make_message(
|
|
|
|
|
+ body=body,
|
|
|
|
|
+ room_id=room_id,
|
|
|
|
|
+ message_id=message_id,
|
|
|
|
|
+ created_at=created_at,
|
|
|
|
|
+ )
|
|
|
|
|
+ message.in_reply_to = reply_to
|
|
|
|
|
+ if reply_target is not None:
|
|
|
|
|
+ # One get_message per call: the trigger first, then the reply
|
|
|
|
|
+ # target — the order the dispatch path asks in.
|
|
|
|
|
+ adapter._chatto_client.get_message = AsyncMock(
|
|
|
|
|
+ side_effect=[message, reply_target]
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ _with_get_message(adapter, payload, message)
|
|
|
|
|
+ await adapter._dispatch_message_posted(payload, payload.message_event_id)
|
|
|
|
|
+
|
|
|
|
|
+ def _dispatched_event(self, adapter):
|
|
|
|
|
+ return adapter.handle_message.await_args.args[0]
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_room_reply_opens_with_the_room_behind_it(self):
|
|
|
|
|
+ """The reported bug: the first turn into a room must not be answered
|
|
|
|
|
+ as a conversation the agent has never seen."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ # A conversation the room had before this turn, ending in an answer
|
|
|
|
|
+ # the bot itself posted — whose echo never reaches a session.
|
|
|
|
|
+ past = _make_message(
|
|
|
|
|
+ body="Deploy finished at noon",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ actor_id="bot-user-id",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ trigger = _make_message(
|
|
|
|
|
+ body="@hermes_bot which build?",
|
|
|
|
|
+ message_id="msg-9",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ _serve_room_timeline(adapter, [past, trigger])
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
|
|
|
|
|
+
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ assert "Deploy finished at noon" in context
|
|
|
|
|
+
|
|
|
|
|
+ async def test_the_block_names_the_room(self):
|
|
|
|
|
+ """The renderer is shared with the thread seed, so the header is the
|
|
|
|
|
+ one difference — a room block must say room, not thread."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ past = _make_message(
|
|
|
|
|
+ body="Deploy finished at noon",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ trigger = _make_message(
|
|
|
|
|
+ body="@hermes_bot which build?",
|
|
|
|
|
+ message_id="msg-9",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ _serve_room_timeline(adapter, [past, trigger])
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
|
|
|
|
|
+
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ assert "[Earlier in this room]" in context
|
|
|
|
|
+ assert "[Earlier in this thread]" not in context
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_message_newer_than_the_trigger_is_not_earlier(self):
|
|
|
|
|
+ """``around`` serves both directions; the block is the room's past,
|
|
|
|
|
+ not its future."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ trigger = _make_message(
|
|
|
|
|
+ body="@hermes_bot which build?",
|
|
|
|
|
+ message_id="msg-9",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ newer = _make_message(
|
|
|
|
|
+ body="Typed while the agent was thinking",
|
|
|
|
|
+ message_id="msg-10",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 6, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ _serve_room_timeline(adapter, [trigger, newer])
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
|
|
|
|
|
+
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ assert "Typed while the agent was thinking" not in context
|
|
|
|
|
+
|
|
|
|
|
+ async def test_an_edit_restart_does_not_reseed(self):
|
|
|
|
|
+ """The session guard protects the one path where the same message id
|
|
|
|
|
+ is dispatched twice: an edit that restarts a turn already running,
|
|
|
|
|
+ whose session now exists. The restart must not carry the room's
|
|
|
|
|
+ history a second time — the session holds the original turn."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ adapter.set_session_store(_FakeSessionStore())
|
|
|
|
|
+ store = adapter._session_store
|
|
|
|
|
+ past = _make_message(
|
|
|
|
|
+ body="Deploy finished at noon",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ trigger = _make_message(
|
|
|
|
|
+ body="@hermes_bot which build?",
|
|
|
|
|
+ message_id="msg-9",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ _serve_room_timeline(adapter, [past, trigger])
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
|
|
|
|
|
+ assert "Deploy finished at noon" in (
|
|
|
|
|
+ self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # The gateway has run the turn now, so the session exists — the same
|
|
|
|
|
+ # message dispatched again (what an edit-restart does) must not carry
|
|
|
|
|
+ # the room's past into a session that already holds it.
|
|
|
|
|
+ session_key = adapter._session_key_for(self._dispatched_event(adapter).source)
|
|
|
|
|
+ store.create_session(session_key)
|
|
|
|
|
+ await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
|
|
|
|
|
+
|
|
|
|
|
+ context = self._dispatched_event(adapter).channel_context or ""
|
|
|
|
|
+ assert "Deploy finished at noon" not in context
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_failed_lookup_leaves_the_turn_running(self):
|
|
|
|
|
+ """Best-effort, like the thread seed: the block is context, and its
|
|
|
|
|
+ absence must not cost the turn itself."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ adapter._chatto_client.get_room_events_around = AsyncMock(
|
|
|
|
|
+ side_effect=ChattoError("boom")
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(adapter, "@hermes_bot help")
|
|
|
|
|
+
|
|
|
|
|
+ assert adapter.handle_message.await_count == 1
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_reply_names_what_it_answers(self):
|
|
|
|
|
+ """The other half of the report: a reply to the bot's message must
|
|
|
|
|
+ reach the agent as a reply, not as a comment from nowhere. The
|
|
|
|
|
+ gateway renders the pointer from ``reply_to_text`` — unfilled, the
|
|
|
|
|
+ turn reads as a fresh conversation about a quote that is not there."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ target = _make_message(
|
|
|
|
|
+ body="Build 4711 is green, logs attached",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ actor_id="bot-user-id",
|
|
|
|
|
+ )
|
|
|
|
|
+ trigger = _make_message(
|
|
|
|
|
+ body="@hermes_bot and the artifacts?",
|
|
|
|
|
+ message_id="msg-9",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ _serve_room_timeline(adapter, [target, trigger])
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ trigger.body,
|
|
|
|
|
+ created_at=trigger.created_at,
|
|
|
|
|
+ reply_to="msg-1",
|
|
|
|
|
+ reply_target=target,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ event = self._dispatched_event(adapter)
|
|
|
|
|
+ assert event.reply_to_message_id == "msg-1"
|
|
|
|
|
+ assert event.reply_to_text == "Build 4711 is green, logs attached"
|
|
|
|
|
+ assert event.reply_to_is_own_message is True
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_reply_to_someone_else_is_not_own(self):
|
|
|
|
|
+ """``reply_to_is_own_message`` is what makes the gateway say "your
|
|
|
|
|
+ previous message" — claiming a colleague's message as the bot's
|
|
|
|
|
+ would be worse than saying nothing."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ target = _make_message(
|
|
|
|
|
+ body="I pushed a fix for that",
|
|
|
|
|
+ message_id="msg-1",
|
|
|
|
|
+ actor_id="user-2",
|
|
|
|
|
+ )
|
|
|
|
|
+ trigger = _make_message(
|
|
|
|
|
+ body="@hermes_bot can you review?",
|
|
|
|
|
+ message_id="msg-9",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ _serve_room_timeline(adapter, [target, trigger])
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ trigger.body,
|
|
|
|
|
+ created_at=trigger.created_at,
|
|
|
|
|
+ reply_to="msg-1",
|
|
|
|
|
+ reply_target=target,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ event = self._dispatched_event(adapter)
|
|
|
|
|
+ assert event.reply_to_text == "I pushed a fix for that"
|
|
|
|
|
+ assert event.reply_to_is_own_message is False
|
|
|
|
|
+
|
|
|
|
|
+ async def test_an_unreadable_reply_target_costs_the_pointer_not_the_turn(self):
|
|
|
|
|
+ """Best-effort like the room block: a deleted or unreadable target
|
|
|
|
|
+ leaves the pointer empty, and the reply is still dispatched."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ self._seed_channel(adapter)
|
|
|
|
|
+ _serve_room_timeline(adapter, [])
|
|
|
|
|
+ deleted = _make_message(
|
|
|
|
|
+ body="Build 4711 is green, logs attached",
|
|
|
|
|
+ message_id="msg-gone",
|
|
|
|
|
+ created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
|
|
|
|
|
+ )
|
|
|
|
|
+ deleted.deleted_at = datetime(2026, 10, 22, 12, 2, tzinfo=UTC)
|
|
|
|
|
+
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ "@hermes_bot hi",
|
|
|
|
|
+ reply_to="msg-gone",
|
|
|
|
|
+ reply_target=deleted,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ event = self._dispatched_event(adapter)
|
|
|
|
|
+ assert event.reply_to_message_id == "msg-gone"
|
|
|
|
|
+ assert event.reply_to_text is None
|
|
|
|
|
+ assert event.reply_to_is_own_message is False
|
|
|
|
|
+ assert adapter.handle_message.await_count == 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
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.
|
|
@@ -5992,8 +6270,12 @@ class TestPluginToolHandlers:
|
|
|
client.list_pinned_messages = AsyncMock(
|
|
client.list_pinned_messages = AsyncMock(
|
|
|
return_value=PinnedMessagesPage(
|
|
return_value=PinnedMessagesPage(
|
|
|
pinned_messages=[
|
|
pinned_messages=[
|
|
|
- PinnedMessage(message=_make_message(message_id="msg-1", actor_id="user-1")),
|
|
|
|
|
- PinnedMessage(message=_make_message(message_id="msg-2", actor_id="user-2")),
|
|
|
|
|
|
|
+ PinnedMessage(
|
|
|
|
|
+ message=_make_message(message_id="msg-1", actor_id="user-1")
|
|
|
|
|
+ ),
|
|
|
|
|
+ PinnedMessage(
|
|
|
|
|
+ message=_make_message(message_id="msg-2", actor_id="user-2")
|
|
|
|
|
+ ),
|
|
|
],
|
|
],
|
|
|
page=Page(total_count=2, has_more=False),
|
|
page=Page(total_count=2, has_more=False),
|
|
|
)
|
|
)
|
|
@@ -6010,7 +6292,10 @@ class TestPluginToolHandlers:
|
|
|
client=client,
|
|
client=client,
|
|
|
)
|
|
)
|
|
|
assert [p["event_id"] for p in result["pinned_messages"]] == ["msg-1", "msg-2"]
|
|
assert [p["event_id"] for p in result["pinned_messages"]] == ["msg-1", "msg-2"]
|
|
|
- assert [p["author_login"] for p in result["pinned_messages"]] == ["alice", "bob"]
|
|
|
|
|
|
|
+ assert [p["author_login"] for p in result["pinned_messages"]] == [
|
|
|
|
|
+ "alice",
|
|
|
|
|
+ "bob",
|
|
|
|
|
+ ]
|
|
|
assert client.batch_get_room_members.await_count == 1
|
|
assert client.batch_get_room_members.await_count == 1
|
|
|
|
|
|
|
|
# -- chatto_pin_message --
|
|
# -- chatto_pin_message --
|
|
@@ -6217,9 +6502,7 @@ class TestPluginToolHandlers:
|
|
|
name="Team",
|
|
name="Team",
|
|
|
description="Internal rooms",
|
|
description="Internal rooms",
|
|
|
rooms=[
|
|
rooms=[
|
|
|
- _make_room_state(
|
|
|
|
|
- _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ _make_room_state(_make_room("room-1", "Ops", RoomKind.CHANNEL))
|
|
|
],
|
|
],
|
|
|
)
|
|
)
|
|
|
]
|
|
]
|