|
|
@@ -42,7 +42,7 @@ setup_vendor_path()
|
|
|
|
|
|
from chattolib._pb.chatto.realtime.v1.events_pb2 import MessageMention
|
|
|
from chattolib.client import ChattoClient
|
|
|
-from chattolib.exceptions import ChattoError
|
|
|
+from chattolib.exceptions import ChattoConnectError, ChattoError
|
|
|
from chattolib.types import (
|
|
|
Asset,
|
|
|
AssetUpload,
|
|
|
@@ -52,12 +52,24 @@ from chattolib.types import (
|
|
|
Message,
|
|
|
MessageAttachment,
|
|
|
MessageReaction,
|
|
|
+ MessageSearchGroupBy,
|
|
|
+ MessageSearchOrder,
|
|
|
+ MessageSearchResult,
|
|
|
+ MessageSearchScope,
|
|
|
+ MessageSearchState,
|
|
|
Page,
|
|
|
+ PinnedMessage,
|
|
|
+ PinnedMessagesPage,
|
|
|
PresenceStatus,
|
|
|
Room,
|
|
|
+ RoomGroup,
|
|
|
RoomKind,
|
|
|
+ RoomSummary,
|
|
|
RoomViewerState,
|
|
|
RoomWithViewerState,
|
|
|
+ SearchResultsPage,
|
|
|
+ SearchStatus,
|
|
|
+ ThreadSearchContext,
|
|
|
ThreadSummary,
|
|
|
TimelineEvent,
|
|
|
TimelinePage,
|
|
|
@@ -80,6 +92,7 @@ from adapter import (
|
|
|
CHATTO_GET_HISTORY_SCHEMA,
|
|
|
CHATTO_GET_MESSAGE_SCHEMA,
|
|
|
CHATTO_LIST_ROOMS_SCHEMA,
|
|
|
+ CHATTO_SEARCH_MESSAGES_SCHEMA,
|
|
|
CHATTO_SEND_DM_SCHEMA,
|
|
|
CHATTO_SEND_ROOM_SCHEMA,
|
|
|
ChattoAdapter,
|
|
|
@@ -87,11 +100,21 @@ from adapter import (
|
|
|
RoomPolicy,
|
|
|
_capabilities,
|
|
|
_chatto_config_for_tools,
|
|
|
+ _handle_chatto_delete_custom_status,
|
|
|
+ _handle_chatto_fetch_link_preview,
|
|
|
_handle_chatto_get_history,
|
|
|
_handle_chatto_get_message,
|
|
|
+ _handle_chatto_list_pinned_messages,
|
|
|
+ _handle_chatto_list_room_attachments,
|
|
|
+ _handle_chatto_list_room_groups,
|
|
|
_handle_chatto_list_rooms,
|
|
|
+ _handle_chatto_mark_thread_as_read,
|
|
|
+ _handle_chatto_pin_message,
|
|
|
+ _handle_chatto_search_messages,
|
|
|
_handle_chatto_send_dm,
|
|
|
+ _handle_chatto_send_ephemeral,
|
|
|
_handle_chatto_send_room,
|
|
|
+ _handle_chatto_set_custom_status,
|
|
|
_Mentions,
|
|
|
_room_answers_in,
|
|
|
chat_type_for_room_kind,
|
|
|
@@ -482,6 +505,11 @@ class TestRegistration:
|
|
|
with patch.object(ChattoAdapter, "send_video", BasePlatformAdapter.send_video):
|
|
|
assert "video" not in _capabilities()
|
|
|
|
|
|
+ def test_per_room_toolsets_are_announced(self):
|
|
|
+ """The banner is the only place a startup tells an operator that a
|
|
|
+ per-room restriction exists at all."""
|
|
|
+ assert any("per-room toolsets" in line for line in _capabilities())
|
|
|
+
|
|
|
def test_readme_yaml_example_is_shaped_like_a_config_hermes_reads(self):
|
|
|
"""The example is what users paste into ~/.hermes/config.yaml. Two ways
|
|
|
it silently did nothing: `platforms:` nested under `gateway:` (only the
|
|
|
@@ -605,6 +633,16 @@ class TestValidateConfigGates:
|
|
|
)
|
|
|
assert validate_config(cfg) is True
|
|
|
|
|
|
+ def test_a_malformed_room_toolset_entry_is_rejected(self):
|
|
|
+ """An entry that names a restriction but cannot be read must not leave
|
|
|
+ the room silently on the platform default — that fails open."""
|
|
|
+ cfg = self._cfg(room_toolsets=["room-1:web", "room-2"])
|
|
|
+ assert validate_config(cfg) is False
|
|
|
+
|
|
|
+ def test_well_formed_room_toolsets_are_accepted(self):
|
|
|
+ cfg = self._cfg(room_toolsets=["room-1:web", "room-1:file"])
|
|
|
+ assert validate_config(cfg) is True
|
|
|
+
|
|
|
@pytest.mark.parametrize("key", ["require_mention_rooms", "optional_mention_rooms"])
|
|
|
def test_known_keys_do_not_warn(self, key, caplog):
|
|
|
cfg = self._cfg(**{key: ["room-1"]})
|
|
|
@@ -650,6 +688,48 @@ class TestValidateConfigGates:
|
|
|
assert not [m for m in caplog.messages if "matches no config field" in m]
|
|
|
|
|
|
|
|
|
+# -- Per-room toolsets --
|
|
|
+
|
|
|
+
|
|
|
+class TestRoomToolsets:
|
|
|
+ """``CHATTO_ROOM_TOOLSETS`` replaces the platform's toolset list for a room.
|
|
|
+
|
|
|
+ Hermes' contract for ``toolsets_for_source`` is *replacement*, not
|
|
|
+ subtraction, so the answer is exactly what the entries name.
|
|
|
+ """
|
|
|
+
|
|
|
+ def _source(self, chat_id="room-1", chat_type="channel"):
|
|
|
+ from gateway.session import SessionSource
|
|
|
+
|
|
|
+ return SessionSource(
|
|
|
+ platform=Platform(ChattoConstants.PLATFORM_NAME),
|
|
|
+ chat_id=chat_id,
|
|
|
+ chat_type=chat_type,
|
|
|
+ )
|
|
|
+
|
|
|
+ def test_a_listed_room_gets_exactly_the_listed_toolsets(self):
|
|
|
+ adapter = _make_adapter(room_toolsets=["room-1:web", "room-1:file"])
|
|
|
+ assert adapter.toolsets_for_source(self._source()) == ["web", "file"]
|
|
|
+
|
|
|
+ def test_an_unlisted_room_keeps_the_platform_default(self):
|
|
|
+ adapter = _make_adapter(room_toolsets=["room-1:web"])
|
|
|
+ assert adapter.toolsets_for_source(self._source(chat_id="room-2")) is None
|
|
|
+
|
|
|
+ def test_a_thread_turn_is_governed_by_its_room(self):
|
|
|
+ """A thread's source carries the room as ``chat_id`` and the thread
|
|
|
+ separately, so the room's restriction has to reach its threads."""
|
|
|
+ adapter = _make_adapter(room_toolsets=["room-1:web"])
|
|
|
+ assert adapter.toolsets_for_source(self._source(chat_type="thread")) == ["web"]
|
|
|
+
|
|
|
+ def test_a_duplicate_toolset_is_listed_once(self):
|
|
|
+ adapter = _make_adapter(room_toolsets=["room-1:web", "room-1:web"])
|
|
|
+ assert adapter.toolsets_for_source(self._source()) == ["web"]
|
|
|
+
|
|
|
+ def test_no_configuration_means_no_override(self):
|
|
|
+ adapter = _make_adapter()
|
|
|
+ assert adapter.toolsets_for_source(self._source()) is None
|
|
|
+
|
|
|
+
|
|
|
# -- Send functionality --
|
|
|
|
|
|
|
|
|
@@ -1204,6 +1284,254 @@ class TestReactionForwarding:
|
|
|
await adapter._handle_realtime_event(event)
|
|
|
|
|
|
|
|
|
+# -- Interactive prompt reactions --
|
|
|
+
|
|
|
+
|
|
|
+class TestInteractiveReactionRouting:
|
|
|
+ """A reaction on a prompt card resolves it; the bot's own echo does not.
|
|
|
+
|
|
|
+ Routing sits behind the envelope's self-event filter, so a reaction the
|
|
|
+ bot itself adds can never answer the prompt it decorates — the echo rule
|
|
|
+ and the prompt rule have to hold together, which is what these pin.
|
|
|
+ """
|
|
|
+
|
|
|
+ @pytest_asyncio.fixture
|
|
|
+ def adapter(self):
|
|
|
+ adapter = _make_adapter(allowed_users="human-1")
|
|
|
+ adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
+ adapter.edit_message = AsyncMock()
|
|
|
+ return adapter
|
|
|
+
|
|
|
+ def _event(self, emoji, *, actor_id="human-1", message_id="prompt-1"):
|
|
|
+ event = MagicMock()
|
|
|
+ event.id = f"evt-{emoji}-{actor_id}"
|
|
|
+ event.kind = "reaction_added"
|
|
|
+ event.actor_id = actor_id
|
|
|
+ event.payload = MagicMock(
|
|
|
+ room_id="room-1", message_event_id=message_id, emoji=emoji
|
|
|
+ )
|
|
|
+ return event
|
|
|
+
|
|
|
+ def _clarify(self, adapter):
|
|
|
+ from adapter import _ClarifyPrompt
|
|
|
+
|
|
|
+ prompt = _ClarifyPrompt(
|
|
|
+ clarify_id="clarify-1",
|
|
|
+ choices=["yes", "no"],
|
|
|
+ session_key="session-1",
|
|
|
+ chat_id="room-1",
|
|
|
+ message_id="prompt-1",
|
|
|
+ expires_at=float("inf"),
|
|
|
+ )
|
|
|
+ adapter._clarify_prompts["prompt-1"] = prompt
|
|
|
+ adapter._clarify_id_to_message["clarify-1"] = "prompt-1"
|
|
|
+ return prompt
|
|
|
+
|
|
|
+ async def test_a_numbered_reaction_resolves_the_clarify(self, adapter):
|
|
|
+ self._clarify(adapter)
|
|
|
+ resolve = MagicMock()
|
|
|
+ with patch("tools.clarify_gateway.resolve_gateway_clarify", resolve):
|
|
|
+ await adapter._handle_realtime_event(self._event("2️⃣"))
|
|
|
+
|
|
|
+ resolve.assert_called_once_with("clarify-1", "no")
|
|
|
+ assert "prompt-1" not in adapter._clarify_prompts
|
|
|
+ assert "clarify-1" not in adapter._clarify_id_to_message
|
|
|
+
|
|
|
+ async def test_the_bots_own_reaction_does_not_resolve_its_prompt(self, adapter):
|
|
|
+ """The self-event filter runs before routing, so an echo of the bot's
|
|
|
+ own marker cannot answer the prompt it decorates."""
|
|
|
+ self._clarify(adapter)
|
|
|
+ resolve = MagicMock()
|
|
|
+ with patch("tools.clarify_gateway.resolve_gateway_clarify", resolve):
|
|
|
+ await adapter._handle_realtime_event(
|
|
|
+ self._event("2️⃣", actor_id="bot-user-id")
|
|
|
+ )
|
|
|
+
|
|
|
+ resolve.assert_not_called()
|
|
|
+ assert "prompt-1" in adapter._clarify_prompts
|
|
|
+
|
|
|
+ async def test_an_unauthorized_reaction_leaves_the_prompt_live(self, adapter):
|
|
|
+ """Auth is fail-closed here too: a stranger's reaction is consumed but
|
|
|
+ answers nothing."""
|
|
|
+ self._clarify(adapter)
|
|
|
+ resolve = MagicMock()
|
|
|
+ with patch("tools.clarify_gateway.resolve_gateway_clarify", resolve):
|
|
|
+ await adapter._handle_realtime_event(
|
|
|
+ self._event("2️⃣", actor_id="stranger-9")
|
|
|
+ )
|
|
|
+
|
|
|
+ resolve.assert_not_called()
|
|
|
+ assert "prompt-1" in adapter._clarify_prompts
|
|
|
+
|
|
|
+ async def test_a_reaction_on_an_untracked_message_is_forwarded(self, adapter):
|
|
|
+ """Only prompt cards are consumed — every other reaction still reaches
|
|
|
+ the gateway's reaction hook."""
|
|
|
+ handler = AsyncMock()
|
|
|
+ adapter.set_reaction_handler(handler)
|
|
|
+
|
|
|
+ await adapter._handle_realtime_event(
|
|
|
+ self._event("👍", message_id="ordinary-msg")
|
|
|
+ )
|
|
|
+
|
|
|
+ handler.assert_called_once()
|
|
|
+
|
|
|
+
|
|
|
+# -- Realtime resume --
|
|
|
+
|
|
|
+
|
|
|
+class TestRealtimeResume:
|
|
|
+ """A reconnect resumes from the last applied cursor instead of starting at
|
|
|
+ the live boundary, which would silently skip everything that arrived while
|
|
|
+ the socket was down."""
|
|
|
+
|
|
|
+ def _event(self, cursor):
|
|
|
+ from chattolib.realtime import RealtimeEvent
|
|
|
+
|
|
|
+ return RealtimeEvent(
|
|
|
+ id=f"evt-{cursor}",
|
|
|
+ created_at=None,
|
|
|
+ actor_id="human-1",
|
|
|
+ cursor=cursor,
|
|
|
+ kind="reaction_added",
|
|
|
+ payload=MagicMock(),
|
|
|
+ raw=None,
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _run_loop(self, adapter, passes):
|
|
|
+ """Drive the loop through ``passes`` stream connections, then stop it.
|
|
|
+
|
|
|
+ ``passes`` is a list of event lists, one per connection; the recorded
|
|
|
+ ``resume_cursor`` of each call comes back so the test can assert what
|
|
|
+ the second connection asked the server for.
|
|
|
+ """
|
|
|
+ adapter._closing = False
|
|
|
+ adapter._require_chatto_client = AsyncMock(return_value=adapter._chatto_client)
|
|
|
+ adapter._refresh_rooms = AsyncMock()
|
|
|
+ adapter._handle_realtime_event = AsyncMock()
|
|
|
+
|
|
|
+ resume_cursors: list[str | None] = []
|
|
|
+
|
|
|
+ async def fake_stream(client, *, resume_cursor=None):
|
|
|
+ resume_cursors.append(resume_cursor)
|
|
|
+ for event in passes[len(resume_cursors) - 1]:
|
|
|
+ yield event
|
|
|
+
|
|
|
+ async def stop_after_last_pass(seconds):
|
|
|
+ if len(resume_cursors) >= len(passes):
|
|
|
+ adapter._closing = True
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch("adapter.stream_events", fake_stream),
|
|
|
+ patch.object(adapter, "_sleep_interruptible", stop_after_last_pass),
|
|
|
+ ):
|
|
|
+ await adapter._chattolib_event_loop()
|
|
|
+
|
|
|
+ return resume_cursors
|
|
|
+
|
|
|
+ async def test_reconnect_resumes_from_the_last_event_cursor(self):
|
|
|
+ adapter = _make_adapter()
|
|
|
+ resume_cursors = await self._run_loop(adapter, [[self._event("cur-1")], []])
|
|
|
+ assert resume_cursors == [None, "cur-1"]
|
|
|
+
|
|
|
+ async def test_an_unreplayable_event_keeps_the_previous_cursor(self):
|
|
|
+ """``cursor`` is None for events the server cannot replay; it must not
|
|
|
+ wipe the last good resume point."""
|
|
|
+ adapter = _make_adapter()
|
|
|
+ resume_cursors = await self._run_loop(
|
|
|
+ adapter, [[self._event("cur-1"), self._event(None)], []]
|
|
|
+ )
|
|
|
+ assert resume_cursors == [None, "cur-1"]
|
|
|
+
|
|
|
+
|
|
|
+class TestPersistedRealtimeCursor:
|
|
|
+ """A clean shutdown stores the stream position; a restart inside the window
|
|
|
+ resumes from it instead of starting at the live boundary."""
|
|
|
+
|
|
|
+ def _cfg(self, **extra):
|
|
|
+ return ChattoConfiguration(pconfig=_make_config(token="test-token", **extra))
|
|
|
+
|
|
|
+ def _store(self, monkeypatch, tmp_path, payload):
|
|
|
+ path = tmp_path / "chatto_realtime_cursor.json"
|
|
|
+ monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
|
|
|
+ path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
+ return path
|
|
|
+
|
|
|
+ def test_a_stored_cursor_survives_a_clean_restart(self, monkeypatch, tmp_path):
|
|
|
+ from adapter import _persist_realtime_cursor, _restore_realtime_cursor
|
|
|
+
|
|
|
+ path = tmp_path / "chatto_realtime_cursor.json"
|
|
|
+ monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
|
|
|
+ _persist_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):
|
|
|
+ """Replaying an outage of hours is worse than losing it: the window is
|
|
|
+ what keeps a restart bounded."""
|
|
|
+ from adapter import _profile_scope_key, _restore_realtime_cursor
|
|
|
+
|
|
|
+ stale = datetime.now(UTC) - timedelta(seconds=1000)
|
|
|
+ self._store(
|
|
|
+ monkeypatch,
|
|
|
+ tmp_path,
|
|
|
+ {
|
|
|
+ "scope": _profile_scope_key(),
|
|
|
+ "cursor": "cur-old",
|
|
|
+ "saved_at": stale.isoformat(),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ assert _restore_realtime_cursor(self._cfg(resume_window=900)) is None
|
|
|
+
|
|
|
+ def test_a_cursor_written_by_another_profile_is_ignored(
|
|
|
+ self, monkeypatch, tmp_path
|
|
|
+ ):
|
|
|
+ from adapter import _restore_realtime_cursor
|
|
|
+
|
|
|
+ self._store(
|
|
|
+ monkeypatch,
|
|
|
+ tmp_path,
|
|
|
+ {
|
|
|
+ "scope": "some-other-profile",
|
|
|
+ "cursor": "cur-other",
|
|
|
+ "saved_at": datetime.now(UTC).isoformat(),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ assert _restore_realtime_cursor(self._cfg()) is None
|
|
|
+
|
|
|
+ def test_an_unreadable_file_means_no_resume(self, monkeypatch, tmp_path):
|
|
|
+ from adapter import _restore_realtime_cursor
|
|
|
+
|
|
|
+ path = tmp_path / "chatto_realtime_cursor.json"
|
|
|
+ monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
|
|
|
+ path.write_text("{not json", encoding="utf-8")
|
|
|
+ assert _restore_realtime_cursor(self._cfg()) is None
|
|
|
+
|
|
|
+ def test_window_zero_disables_the_stored_cursor(self, monkeypatch, tmp_path):
|
|
|
+ from adapter import _persist_realtime_cursor, _restore_realtime_cursor
|
|
|
+
|
|
|
+ path = tmp_path / "chatto_realtime_cursor.json"
|
|
|
+ monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
|
|
|
+ cfg = self._cfg(resume_window=0)
|
|
|
+ _persist_realtime_cursor(cfg, "cur-1")
|
|
|
+ assert not path.exists()
|
|
|
+ assert _restore_realtime_cursor(cfg) is None
|
|
|
+
|
|
|
+ async def test_disconnect_stores_the_last_applied_cursor(
|
|
|
+ self, monkeypatch, tmp_path
|
|
|
+ ):
|
|
|
+ """The shutdown path is the only writer — one small file instead of a
|
|
|
+ write on every presence change."""
|
|
|
+ path = tmp_path / "chatto_realtime_cursor.json"
|
|
|
+ monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
|
|
|
+ adapter = _make_adapter()
|
|
|
+ adapter._chatto_client = None
|
|
|
+ adapter._realtime_cursor = "cur-final"
|
|
|
+
|
|
|
+ await adapter.disconnect()
|
|
|
+
|
|
|
+ payload = json.loads(path.read_text(encoding="utf-8"))
|
|
|
+ assert payload["cursor"] == "cur-final"
|
|
|
+
|
|
|
+
|
|
|
# -- chat_type mapping --
|
|
|
|
|
|
|
|
|
@@ -2490,6 +2818,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:
|
|
|
"""An in-memory session store with the real store's shape.
|
|
|
|
|
|
@@ -2526,6 +2868,276 @@ class _FakeSessionStore:
|
|
|
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:
|
|
|
"""A thread session starts empty, so the first turn into a Chatto thread
|
|
|
seeds the messages that came before it.
|
|
|
@@ -4447,8 +5059,16 @@ class TestPluginToolSchemas:
|
|
|
CHATTO_SEND_ROOM_SCHEMA,
|
|
|
CHATTO_GET_MESSAGE_SCHEMA,
|
|
|
CHATTO_GET_HISTORY_SCHEMA,
|
|
|
+ CHATTO_SEARCH_MESSAGES_SCHEMA,
|
|
|
+ ],
|
|
|
+ ids=[
|
|
|
+ "list_rooms",
|
|
|
+ "send_dm",
|
|
|
+ "send_room",
|
|
|
+ "get_message",
|
|
|
+ "get_history",
|
|
|
+ "search_messages",
|
|
|
],
|
|
|
- ids=["list_rooms", "send_dm", "send_room", "get_message", "get_history"],
|
|
|
)
|
|
|
def test_schema_advertises_no_credentials(self, schema):
|
|
|
"""A base_url argument would send the configured bot token to a host
|
|
|
@@ -4465,9 +5085,19 @@ class TestPluginToolSchemas:
|
|
|
assert [tool["name"] for tool in ctx.registered_tools] == [
|
|
|
"chatto_send_dm",
|
|
|
"chatto_send_room",
|
|
|
+ "chatto_send_ephemeral",
|
|
|
"chatto_list_rooms",
|
|
|
"chatto_get_message",
|
|
|
"chatto_get_history",
|
|
|
+ "chatto_search_messages",
|
|
|
+ "chatto_list_room_attachments",
|
|
|
+ "chatto_list_pinned_messages",
|
|
|
+ "chatto_pin_message",
|
|
|
+ "chatto_set_custom_status",
|
|
|
+ "chatto_delete_custom_status",
|
|
|
+ "chatto_fetch_link_preview",
|
|
|
+ "chatto_mark_thread_as_read",
|
|
|
+ "chatto_list_room_groups",
|
|
|
]
|
|
|
for tool in ctx.registered_tools:
|
|
|
assert tool["is_async"] is True, tool["name"]
|
|
|
@@ -4501,6 +5131,15 @@ class TestPluginToolHandlers:
|
|
|
client.batch_get_room_members = AsyncMock(return_value=[])
|
|
|
client.follow_thread = AsyncMock()
|
|
|
client.start_dm = AsyncMock()
|
|
|
+ client.list_room_attachments = AsyncMock(return_value=([], Page()))
|
|
|
+ client.list_pinned_messages = AsyncMock(return_value=PinnedMessagesPage())
|
|
|
+ client.pin_message = AsyncMock()
|
|
|
+ client.unpin_message = AsyncMock(return_value=True)
|
|
|
+ client.set_custom_status = AsyncMock(return_value={})
|
|
|
+ client.delete_custom_status = AsyncMock(return_value={})
|
|
|
+ client.fetch_link_preview = AsyncMock(return_value=(None, ""))
|
|
|
+ client.mark_thread_as_read = AsyncMock(return_value=None)
|
|
|
+ client.list_room_groups = AsyncMock(return_value=[])
|
|
|
client.post_message = AsyncMock(
|
|
|
side_effect=lambda *a, **kw: _make_message(
|
|
|
message_id=f"msg-{client.post_message.await_count}"
|
|
|
@@ -5613,3 +6252,619 @@ class TestPluginToolHandlers:
|
|
|
result = json.loads(raw)
|
|
|
assert result["success"] is True
|
|
|
client.post_message.assert_awaited()
|
|
|
+
|
|
|
+ # -- chatto_search_messages --
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _search_page(results, *, next_cursor="", thread_total_count=None, users=None):
|
|
|
+ """A SearchResultsPage as the server returns one."""
|
|
|
+ return SearchResultsPage(
|
|
|
+ results=results,
|
|
|
+ next_cursor=next_cursor,
|
|
|
+ thread_total_count=thread_total_count,
|
|
|
+ users_by_id=dict(users or {}),
|
|
|
+ )
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _hit(message, relevance=0.9, thread_context=None):
|
|
|
+ return MessageSearchResult(
|
|
|
+ message=message, relevance_score=relevance, thread_context=thread_context
|
|
|
+ )
|
|
|
+
|
|
|
+ async def test_search_returns_hits_with_their_room_and_author(self):
|
|
|
+ """A hit is a pointer: it needs to say where it is, who said it and
|
|
|
+ what it says, or the caller cannot decide which one to read."""
|
|
|
+ client = self._stub_client()
|
|
|
+ message = _make_message(
|
|
|
+ message_id="msg-7",
|
|
|
+ room_id="room-1",
|
|
|
+ body="the deploy finished at noon",
|
|
|
+ actor_id="user-7",
|
|
|
+ created_at=datetime(2026, 10, 20, 12, 0, tzinfo=UTC),
|
|
|
+ )
|
|
|
+ client.search_messages = AsyncMock(
|
|
|
+ return_value=self._search_page(
|
|
|
+ [self._hit(message)],
|
|
|
+ users={"user-7": _make_user("user-7", "seven")},
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages, {"query": "deploy noon"}, client=client
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["count"] == 1
|
|
|
+ hit = result["results"][0]
|
|
|
+ assert hit["event_id"] == "msg-7"
|
|
|
+ assert hit["room_id"] == "room-1"
|
|
|
+ assert hit["author_login"] == "seven"
|
|
|
+ assert hit["body"] == "the deploy finished at noon"
|
|
|
+ assert hit["relevance"] == 0.9
|
|
|
+
|
|
|
+ async def test_search_passes_every_filter_through(self):
|
|
|
+ """The server owns the filtering; the tool's job is to hand it over
|
|
|
+ intact rather than second-guess it."""
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.search_messages = AsyncMock(return_value=self._search_page([]))
|
|
|
+
|
|
|
+ await self._run(
|
|
|
+ _handle_chatto_search_messages,
|
|
|
+ {
|
|
|
+ "query": "deploy",
|
|
|
+ "room_id": "room-1",
|
|
|
+ "author_id": "user-7",
|
|
|
+ "created_after": "2026-10-01",
|
|
|
+ "created_before": "2026-10-31T00:00:00Z",
|
|
|
+ "has_attachments": True,
|
|
|
+ "order": "newest",
|
|
|
+ "group_by": "thread",
|
|
|
+ "scope": "followed_threads",
|
|
|
+ "limit": 5,
|
|
|
+ "cursor": "cur-1",
|
|
|
+ },
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+
|
|
|
+ kwargs = client.search_messages.await_args.kwargs
|
|
|
+ assert kwargs["room_id"] == "room-1"
|
|
|
+ assert kwargs["author_id"] == "user-7"
|
|
|
+ assert kwargs["created_after"] == datetime(2026, 10, 1, tzinfo=UTC)
|
|
|
+ assert kwargs["created_before"] == datetime(2026, 10, 31, 0, 0, tzinfo=UTC)
|
|
|
+ assert kwargs["has_attachments"] is True
|
|
|
+ assert kwargs["order"] is MessageSearchOrder.NEWEST
|
|
|
+ assert kwargs["group_by"] is MessageSearchGroupBy.THREAD
|
|
|
+ assert kwargs["scope"] is MessageSearchScope.FOLLOWED_THREADS
|
|
|
+ assert kwargs["page_size"] == 5
|
|
|
+ assert kwargs["cursor"] == "cur-1"
|
|
|
+
|
|
|
+ async def test_search_resolves_a_room_name_before_searching(self):
|
|
|
+ """A name is not an ID: the room has to be resolved first, and the
|
|
|
+ server is then scoped by the ID it stands for."""
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_rooms = AsyncMock(return_value=[_make_room_state(room)])
|
|
|
+ client.search_messages = AsyncMock(return_value=self._search_page([]))
|
|
|
+
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages,
|
|
|
+ {"query": "deploy", "room_name": "Ops"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["room_id"] == "room-1"
|
|
|
+ assert client.search_messages.await_args.kwargs["room_id"] == "room-1"
|
|
|
+
|
|
|
+ async def test_search_answers_a_disabled_feature_with_the_reason(self):
|
|
|
+ """Search off is not "no matches". The server says FAILED_PRECONDITION
|
|
|
+ and GetStatus says why — an empty result list would be a lie."""
|
|
|
+ client = self._stub_client()
|
|
|
+ client.search_messages = AsyncMock(
|
|
|
+ side_effect=ChattoConnectError("failed_precondition", "search disabled")
|
|
|
+ )
|
|
|
+ client.get_search_status = AsyncMock(
|
|
|
+ return_value=SearchStatus(state=MessageSearchState.DISABLED)
|
|
|
+ )
|
|
|
+
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages, {"query": "deploy"}, client=client
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "turned off" in result["error"]
|
|
|
+ assert "search.enabled" in result["error"]
|
|
|
+
|
|
|
+ async def test_search_reports_a_building_index_with_its_retry_hint(self):
|
|
|
+ """Indexing resolves itself, so the caller must not be told the feature
|
|
|
+ is broken — it should retry, and the server says when."""
|
|
|
+ client = self._stub_client()
|
|
|
+ client.search_messages = AsyncMock(
|
|
|
+ side_effect=ChattoConnectError("unavailable", "provider not ready")
|
|
|
+ )
|
|
|
+ client.get_search_status = AsyncMock(
|
|
|
+ return_value=SearchStatus(
|
|
|
+ state=MessageSearchState.INDEXING, retry_after_seconds=30
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages, {"query": "deploy"}, client=client
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "building its index" in result["error"]
|
|
|
+ assert "30s" in result["error"]
|
|
|
+
|
|
|
+ async def test_search_relays_a_real_error_instead_of_blaming_the_feature(self):
|
|
|
+ """Only the two search-state codes mean "the feature is not ready". A
|
|
|
+ permission error is the caller's problem and must not be dressed up as
|
|
|
+ a disabled feature — that would send them to the operator for nothing."""
|
|
|
+ client = self._stub_client()
|
|
|
+ client.search_messages = AsyncMock(
|
|
|
+ side_effect=ChattoConnectError("permission_denied", "no")
|
|
|
+ )
|
|
|
+ client.get_search_status = AsyncMock()
|
|
|
+
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages, {"query": "deploy"}, client=client
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "Chatto API error" in result["error"]
|
|
|
+ client.get_search_status.assert_not_awaited()
|
|
|
+
|
|
|
+ async def test_search_reports_thread_grouping_with_the_thread_it_stands_for(self):
|
|
|
+ """With group_by=thread a row is a whole conversation, so the root and
|
|
|
+ the latest reply are what makes it readable."""
|
|
|
+ client = self._stub_client()
|
|
|
+ root = _make_message(message_id="msg-1", body="deploy question")
|
|
|
+ reply = _make_message(
|
|
|
+ message_id="msg-2", body="deploy answer", thread_root_event_id="msg-1"
|
|
|
+ )
|
|
|
+ context = ThreadSearchContext(
|
|
|
+ root_message=root,
|
|
|
+ room=RoomSummary(id="room-1", name="Ops"),
|
|
|
+ thread=ThreadSummary(
|
|
|
+ thread_root_event_id="msg-1",
|
|
|
+ reply_count=2,
|
|
|
+ participant_count=2,
|
|
|
+ last_reply_at=datetime(2026, 10, 20, 13, 0, tzinfo=UTC),
|
|
|
+ ),
|
|
|
+ latest_reply=reply,
|
|
|
+ )
|
|
|
+ client.search_messages = AsyncMock(
|
|
|
+ return_value=self._search_page(
|
|
|
+ [self._hit(root, thread_context=context)], thread_total_count=1
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages,
|
|
|
+ {"query": "deploy", "group_by": "thread"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["grouped_by_thread"] is True
|
|
|
+ assert result["thread_total_count"] == 1
|
|
|
+ thread = result["results"][0]["thread"]
|
|
|
+ assert thread["root_event_id"] == "msg-1"
|
|
|
+ assert thread["root_body"] == "deploy question"
|
|
|
+ assert thread["room_name"] == "Ops"
|
|
|
+ assert thread["reply_count"] == 2
|
|
|
+ assert thread["latest_reply_event_id"] == "msg-2"
|
|
|
+
|
|
|
+ async def test_search_pages_with_the_cursor_it_was_given(self):
|
|
|
+ """The cursor is this tool's only way past one page, so it has to come
|
|
|
+ back out as next_cursor — and be usable as an argument again."""
|
|
|
+ client = self._stub_client()
|
|
|
+ client.search_messages = AsyncMock(
|
|
|
+ return_value=self._search_page([], next_cursor="cur-2")
|
|
|
+ )
|
|
|
+
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages,
|
|
|
+ {"query": "deploy", "cursor": "cur-1"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["next_cursor"] == "cur-2"
|
|
|
+ assert client.search_messages.await_args.kwargs["cursor"] == "cur-1"
|
|
|
+
|
|
|
+ async def test_search_refuses_an_empty_query(self):
|
|
|
+ """The schema marks it required, but a model can still send "" — an
|
|
|
+ empty query would search for nothing and return everything."""
|
|
|
+ result, _, _ = await self._run(_handle_chatto_search_messages, {"query": " "})
|
|
|
+
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "query is required" in result["error"]
|
|
|
+
|
|
|
+ async def test_search_refuses_thread_activity_order_without_thread_grouping(self):
|
|
|
+ """The server rejects this pair; naming both arguments is more useful
|
|
|
+ than relaying INVALID_ARGUMENT."""
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages,
|
|
|
+ {"query": "deploy", "order": "thread_activity"},
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "requires group_by" in result["error"]
|
|
|
+
|
|
|
+ async def test_search_refuses_a_malformed_date_instead_of_ignoring_it(self):
|
|
|
+ """Silently dropping a bound would answer a wider question than the one
|
|
|
+ asked, which reads as a correct answer."""
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages,
|
|
|
+ {"query": "deploy", "created_after": "last tuesday"},
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "created_after" in result["error"]
|
|
|
+
|
|
|
+ async def test_search_refuses_an_inverted_time_window(self):
|
|
|
+ client = self._stub_client()
|
|
|
+
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_search_messages,
|
|
|
+ {
|
|
|
+ "query": "deploy",
|
|
|
+ "created_after": "2026-10-31",
|
|
|
+ "created_before": "2026-10-01",
|
|
|
+ },
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "earlier" in result["error"]
|
|
|
+
|
|
|
+ # -- chatto_list_room_attachments --
|
|
|
+
|
|
|
+ async def test_list_room_attachments_reports_the_server_page(self):
|
|
|
+ """The attachment list is pageable server-side, so the answer has to
|
|
|
+ carry the page it came from — ``has_more`` is what tells the agent to
|
|
|
+ ask again with a larger offset."""
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.list_room_attachments = AsyncMock(
|
|
|
+ return_value=(
|
|
|
+ [{"id": "asset-1", "filename": "a.png", "contentType": "image/png"}],
|
|
|
+ Page(total_count=10, has_more=True),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_list_room_attachments,
|
|
|
+ {"room_id": "room-1"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["count"] == 1
|
|
|
+ assert result["has_more"] is True
|
|
|
+ assert result["total_count"] == 10
|
|
|
+ assert result["attachments"][0]["filename"] == "a.png"
|
|
|
+
|
|
|
+ # -- chatto_list_pinned_messages --
|
|
|
+
|
|
|
+ async def test_list_pinned_messages_hydrates_authors_in_one_lookup(self):
|
|
|
+ """Pinned messages carry only actor IDs. The directory is asked once
|
|
|
+ for the whole page, not once per pinned message."""
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.list_pinned_messages = AsyncMock(
|
|
|
+ return_value=PinnedMessagesPage(
|
|
|
+ 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")
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ page=Page(total_count=2, has_more=False),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ client.batch_get_room_members = AsyncMock(
|
|
|
+ return_value=[
|
|
|
+ DirectoryMember(user=_make_user("user-1", "alice")),
|
|
|
+ DirectoryMember(user=_make_user("user-2", "bob")),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_list_pinned_messages,
|
|
|
+ {"room_id": "room-1"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ 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 client.batch_get_room_members.await_count == 1
|
|
|
+
|
|
|
+ # -- chatto_pin_message --
|
|
|
+
|
|
|
+ async def test_pin_message_refuses_a_read_only_room(self):
|
|
|
+ """Pinning changes what the room shows everyone, so it is a write: the
|
|
|
+ read-only promise for unlisted channels has to hold here too."""
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_pin_message,
|
|
|
+ {"room_id": "room-1", "event_id": "msg-1", "pin": True},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "read-only" in result["error"]
|
|
|
+ client.pin_message.assert_not_awaited()
|
|
|
+
|
|
|
+ async def test_pin_message_pins_in_an_opted_in_channel(self):
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_pin_message,
|
|
|
+ {"room_id": "room-1", "event_id": "msg-1", "pin": True},
|
|
|
+ client=client,
|
|
|
+ optional_mention_rooms="room-1",
|
|
|
+ )
|
|
|
+ assert result["action"] == "pinned"
|
|
|
+ client.pin_message.assert_awaited_once_with("room-1", "msg-1")
|
|
|
+
|
|
|
+ async def test_pin_message_unpins(self):
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_pin_message,
|
|
|
+ {"room_id": "room-1", "event_id": "msg-1", "pin": False},
|
|
|
+ client=client,
|
|
|
+ optional_mention_rooms="room-1",
|
|
|
+ )
|
|
|
+ assert result["action"] == "unpinned"
|
|
|
+ assert result["unpinned"] is True
|
|
|
+ client.unpin_message.assert_awaited_once_with("room-1", "msg-1")
|
|
|
+
|
|
|
+ # -- chatto_send_ephemeral --
|
|
|
+
|
|
|
+ async def test_send_ephemeral_posts_then_schedules_the_ttl(self):
|
|
|
+ """The handler answers before the notice is gone: deletion is a
|
|
|
+ scheduled task, not something the tool call waits out."""
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ schedule = MagicMock()
|
|
|
+ with patch("adapter._schedule_ephemeral_deletion", schedule):
|
|
|
+ result, client, _ = await self._run(
|
|
|
+ _handle_chatto_send_ephemeral,
|
|
|
+ {"room_id": "room-1", "message": "working", "ttl_seconds": 30},
|
|
|
+ client=client,
|
|
|
+ optional_mention_rooms="room-1",
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["expires_in_seconds"] == 30
|
|
|
+ client.post_message.assert_awaited_once()
|
|
|
+ schedule.assert_called_once()
|
|
|
+ _cfg, room_id, event_ids, ttl = schedule.call_args.args
|
|
|
+ assert (room_id, event_ids, ttl) == ("room-1", ["msg-1"], 30)
|
|
|
+
|
|
|
+ async def test_send_ephemeral_refuses_a_read_only_room(self):
|
|
|
+ """It posts, so an unlisted channel stays closed to it too."""
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ schedule = MagicMock()
|
|
|
+ with patch("adapter._schedule_ephemeral_deletion", schedule):
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_ephemeral,
|
|
|
+ {"room_id": "room-1", "message": "working"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "read-only" in result["error"]
|
|
|
+ client.post_message.assert_not_awaited()
|
|
|
+ schedule.assert_not_called()
|
|
|
+
|
|
|
+ async def test_delete_after_ttl_removes_every_chunk(self):
|
|
|
+ """A split notice is several messages; the TTL covers all of them."""
|
|
|
+ from adapter import _delete_after_ttl
|
|
|
+
|
|
|
+ client = self._stub_client()
|
|
|
+ client.delete_message = AsyncMock()
|
|
|
+ cfg = ChattoConfiguration(pconfig=_make_config(token="test-token"))
|
|
|
+ with patch("adapter.ChattoClient", return_value=client):
|
|
|
+ await _delete_after_ttl(cfg, "room-1", ["msg-1", "msg-2"], 0)
|
|
|
+
|
|
|
+ assert [call.args for call in client.delete_message.await_args_list] == [
|
|
|
+ ("room-1", "msg-1"),
|
|
|
+ ("room-1", "msg-2"),
|
|
|
+ ]
|
|
|
+ client.close.assert_awaited_once()
|
|
|
+
|
|
|
+ async def test_a_failed_delete_is_swallowed_and_the_rest_still_go(self):
|
|
|
+ """The notice was already delivered; a delete that fails is not an
|
|
|
+ error the caller could act on, and one chunk must not strand the rest."""
|
|
|
+ from adapter import _delete_after_ttl
|
|
|
+
|
|
|
+ client = self._stub_client()
|
|
|
+ client.delete_message = AsyncMock(side_effect=[ChattoError("gone"), None])
|
|
|
+ cfg = ChattoConfiguration(pconfig=_make_config(token="test-token"))
|
|
|
+ with patch("adapter.ChattoClient", return_value=client):
|
|
|
+ await _delete_after_ttl(cfg, "room-1", ["msg-1", "msg-2"], 0)
|
|
|
+
|
|
|
+ assert client.delete_message.await_count == 2
|
|
|
+
|
|
|
+ # -- custom status --
|
|
|
+
|
|
|
+ async def test_set_custom_status_passes_emoji_and_text(self):
|
|
|
+ result, client, _ = await self._run(
|
|
|
+ _handle_chatto_set_custom_status,
|
|
|
+ {"emoji": "🛠️", "text": "processing"},
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["emoji"] == "🛠️"
|
|
|
+ client.set_custom_status.assert_awaited_once_with(
|
|
|
+ "🛠️", "processing", expires_at=None
|
|
|
+ )
|
|
|
+
|
|
|
+ async def test_set_custom_status_turns_a_ttl_into_an_expiry(self):
|
|
|
+ result, client, _ = await self._run(
|
|
|
+ _handle_chatto_set_custom_status,
|
|
|
+ {"emoji": "⏳", "text": "waiting", "expires_in_seconds": 60},
|
|
|
+ )
|
|
|
+ assert result["expires_at"] is not None
|
|
|
+ assert client.set_custom_status.call_args.kwargs["expires_at"] is not None
|
|
|
+
|
|
|
+ async def test_delete_custom_status_clears_it(self):
|
|
|
+ result, client, _ = await self._run(_handle_chatto_delete_custom_status, {})
|
|
|
+ assert result["success"] is True
|
|
|
+ client.delete_custom_status.assert_awaited_once()
|
|
|
+
|
|
|
+ # -- chatto_fetch_link_preview --
|
|
|
+
|
|
|
+ async def test_fetch_link_preview_returns_the_metadata(self):
|
|
|
+ client = self._stub_client()
|
|
|
+ client.fetch_link_preview = AsyncMock(
|
|
|
+ return_value=(
|
|
|
+ LinkPreview(
|
|
|
+ url="https://example.com",
|
|
|
+ title="Example",
|
|
|
+ description="An example page",
|
|
|
+ image_url="https://example.com/preview.png",
|
|
|
+ site_name="Example",
|
|
|
+ ),
|
|
|
+ "preview-token",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_fetch_link_preview,
|
|
|
+ {"url": "https://example.com"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["title"] == "Example"
|
|
|
+ assert result["image_url"] == "https://example.com/preview.png"
|
|
|
+ client.fetch_link_preview.assert_awaited_once_with("https://example.com")
|
|
|
+
|
|
|
+ async def test_fetch_link_preview_answers_empty_metadata_as_nulls(self):
|
|
|
+ """A URL the server could not enrich is not an error: the fields come
|
|
|
+ back null so the agent can still post the link."""
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_fetch_link_preview, {"url": "https://example.com"}
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["title"] is None
|
|
|
+
|
|
|
+ # -- chatto_mark_thread_as_read --
|
|
|
+
|
|
|
+ async def test_mark_thread_as_read_reports_the_previous_marker(self):
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.mark_thread_as_read = AsyncMock(
|
|
|
+ return_value=datetime(2026, 10, 18, 9, 0, tzinfo=UTC)
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_mark_thread_as_read,
|
|
|
+ {"room_id": "room-1", "thread_root_event_id": "msg-1"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["previous_last_read_at"].startswith("2026-10-18T09:00")
|
|
|
+ client.mark_thread_as_read.assert_awaited_once_with("room-1", "msg-1")
|
|
|
+
|
|
|
+ # -- chatto_list_room_groups --
|
|
|
+
|
|
|
+ async def test_list_room_groups_reports_each_group_and_its_rooms(self):
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_room_groups = AsyncMock(
|
|
|
+ return_value=[
|
|
|
+ RoomGroup(
|
|
|
+ id="group-1",
|
|
|
+ name="Team",
|
|
|
+ description="Internal rooms",
|
|
|
+ rooms=[
|
|
|
+ _make_room_state(_make_room("room-1", "Ops", RoomKind.CHANNEL))
|
|
|
+ ],
|
|
|
+ )
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_list_room_groups, {}, client=client
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ (group,) = result["groups"]
|
|
|
+ assert group["id"] == "group-1"
|
|
|
+ assert group["name"] == "Team"
|
|
|
+ assert group["room_ids"] == ["room-1"]
|
|
|
+
|
|
|
+
|
|
|
+class TestInteractivePromptAuth:
|
|
|
+ """Allowed-user helper for reaction-driven interactive prompts."""
|
|
|
+
|
|
|
+ def test_interactive_prompt_auth_fail_closed(self):
|
|
|
+ adapter = _make_adapter()
|
|
|
+ adapter.chatto_config.allow_all_users.value = False
|
|
|
+ adapter.chatto_config.allowed_users.value = []
|
|
|
+ assert adapter._is_allowed_user_id("user-1") is False
|
|
|
+
|
|
|
+
|
|
|
+class TestRetireClarifyCard:
|
|
|
+ """Test retire_clarify_card edits the prompt message and untracks."""
|
|
|
+
|
|
|
+ def _make_clarify_prompt(self, adapter, clarify_id, chat_id):
|
|
|
+ """Helper to populate the clarify registries."""
|
|
|
+ import time
|
|
|
+
|
|
|
+ from adapter import INTERACTIVE_PROMPT_TIMEOUT_SECONDS, _ClarifyPrompt
|
|
|
+
|
|
|
+ prompt = _ClarifyPrompt(
|
|
|
+ clarify_id=clarify_id,
|
|
|
+ choices=["A", "B"],
|
|
|
+ session_key="sk-1",
|
|
|
+ chat_id=chat_id,
|
|
|
+ message_id="msg-prompt",
|
|
|
+ expires_at=time.monotonic() + INTERACTIVE_PROMPT_TIMEOUT_SECONDS,
|
|
|
+ )
|
|
|
+ adapter._clarify_prompts["msg-prompt"] = prompt
|
|
|
+ adapter._clarify_id_to_message[clarify_id] = "msg-prompt"
|
|
|
+ return prompt
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_retire_edits_message_and_untracks(self):
|
|
|
+ adapter = _make_adapter()
|
|
|
+ adapter.edit_message = AsyncMock()
|
|
|
+ self._make_clarify_prompt(adapter, "clarify-1", "room-1")
|
|
|
+
|
|
|
+ await adapter.retire_clarify_card("clarify-1", "Timed out")
|
|
|
+
|
|
|
+ adapter.edit_message.assert_awaited_once_with(
|
|
|
+ "room-1", "msg-prompt", "❌ Timed out"
|
|
|
+ )
|
|
|
+ assert "clarify-1" not in adapter._clarify_id_to_message
|
|
|
+ assert "msg-prompt" not in adapter._clarify_prompts
|
|
|
+ assert adapter._clarify_prompts.get("msg-prompt") is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_retire_unknown_clarify_id_noop(self):
|
|
|
+ adapter = _make_adapter()
|
|
|
+ adapter.edit_message = AsyncMock()
|
|
|
+
|
|
|
+ await adapter.retire_clarify_card("nonexistent", "gone")
|
|
|
+
|
|
|
+ adapter.edit_message.assert_not_awaited()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_retire_edited_message_on_error_logs(self):
|
|
|
+ adapter = _make_adapter()
|
|
|
+ adapter.edit_message = AsyncMock(side_effect=Exception("edit failed"))
|
|
|
+
|
|
|
+ self._make_clarify_prompt(adapter, "clarify-2", "room-1")
|
|
|
+
|
|
|
+ await adapter.retire_clarify_card("clarify-2", "error")
|
|
|
+
|
|
|
+ assert "clarify-2" not in adapter._clarify_id_to_message
|
|
|
+ assert "msg-prompt" not in adapter._clarify_prompts
|