|
|
@@ -14,6 +14,8 @@ All network calls are mocked — no real HTTP or WebSocket connections.
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
+import inspect
|
|
|
+import json
|
|
|
import os
|
|
|
import sys
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
@@ -68,10 +70,17 @@ from gateway.platforms.base import (
|
|
|
)
|
|
|
|
|
|
from adapter import (
|
|
|
+ CHATTO_LIST_ROOMS_SCHEMA,
|
|
|
+ CHATTO_SEND_DM_SCHEMA,
|
|
|
+ CHATTO_SEND_ROOM_SCHEMA,
|
|
|
ChattoAdapter,
|
|
|
HermesChatType,
|
|
|
RoomPolicy,
|
|
|
_capabilities,
|
|
|
+ _chatto_config_for_tools,
|
|
|
+ _handle_chatto_list_rooms,
|
|
|
+ _handle_chatto_send_dm,
|
|
|
+ _handle_chatto_send_room,
|
|
|
chat_type_for_room_kind,
|
|
|
hermes_env_enablement_fn,
|
|
|
hermes_standalone_sender_fn,
|
|
|
@@ -83,7 +92,7 @@ from adapter import (
|
|
|
from adapter import (
|
|
|
hermes_validate_config as validate_config,
|
|
|
)
|
|
|
-from platform_config import ChattoConstants
|
|
|
+from platform_config import ChattoConfiguration, ChattoConstants
|
|
|
|
|
|
_EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
|
|
|
_MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
|
|
|
@@ -99,11 +108,17 @@ class _MockPluginContext:
|
|
|
def __init__(self):
|
|
|
self.registered_names = []
|
|
|
self.registered_kwargs = None
|
|
|
+ self.registered_tools = []
|
|
|
+ self.registered_skills = []
|
|
|
|
|
|
def register_tool(self, **kwargs):
|
|
|
"""Record plugin-tool registrations (chatto_send_dm / chatto_send_room)."""
|
|
|
+ self.registered_tools.append(kwargs)
|
|
|
self.registered_names.append(kwargs.get("name", "<tool>"))
|
|
|
|
|
|
+ def register_skill(self, name, path, **kwargs):
|
|
|
+ self.registered_skills.append((name, path, kwargs))
|
|
|
+
|
|
|
def register_platform(self, **kwargs):
|
|
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
|
|
|
|
|
@@ -2767,7 +2782,7 @@ class TestInboundAttachments:
|
|
|
|
|
|
|
|
|
class TestRoomOperations:
|
|
|
- """Test room creation and DM initiation."""
|
|
|
+ """Test room creation."""
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
def adapter(self):
|
|
|
@@ -2775,11 +2790,10 @@ class TestRoomOperations:
|
|
|
cfg = _make_config()
|
|
|
adapter = ChattoAdapter(cfg)
|
|
|
adapter._chatto_client = MagicMock()
|
|
|
- # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
|
|
|
- # plain MagicMock raises TypeError, which create_room()/start_dm()
|
|
|
- # swallow into a None return.
|
|
|
+ # AsyncMock, not MagicMock: the adapter awaits this, and awaiting a
|
|
|
+ # plain MagicMock raises TypeError, which create_room() swallows into a
|
|
|
+ # None return.
|
|
|
adapter._chatto_client.create_room = AsyncMock()
|
|
|
- adapter._chatto_client.start_dm = AsyncMock()
|
|
|
adapter._room_names = {}
|
|
|
adapter._room_kinds = {}
|
|
|
return adapter
|
|
|
@@ -2795,17 +2809,6 @@ class TestRoomOperations:
|
|
|
adapter._chatto_client.create_room.assert_called_once()
|
|
|
assert adapter._room_names["room-123"] == "Test Room"
|
|
|
|
|
|
- async def test_start_dm(self, adapter):
|
|
|
- adapter._chatto_client.start_dm.return_value = _make_room(
|
|
|
- "dm-123",
|
|
|
- "DM with user",
|
|
|
- RoomKind.DM,
|
|
|
- )
|
|
|
- result = await adapter.start_dm("user-123")
|
|
|
- assert result == "dm-123"
|
|
|
- adapter._chatto_client.start_dm.assert_called_once()
|
|
|
- assert adapter._room_kinds["dm-123"] == RoomKind.DM
|
|
|
-
|
|
|
|
|
|
# -- DM room management (/join, /leave) --
|
|
|
|
|
|
@@ -3355,3 +3358,406 @@ class TestStandaloneSender:
|
|
|
assert result.success is True
|
|
|
for call in client.post_message.await_args_list:
|
|
|
assert call.kwargs["thread_root_event_id"] == "root-9"
|
|
|
+
|
|
|
+
|
|
|
+# -- Plugin tools (chatto_list_rooms / chatto_send_dm / chatto_send_room) --
|
|
|
+
|
|
|
+
|
|
|
+def _make_room_state(room, is_member=True, member_user_ids=None):
|
|
|
+ """RoomWithViewerState as list_rooms()/get_room() return it, with membership."""
|
|
|
+ return RoomWithViewerState(
|
|
|
+ room=room,
|
|
|
+ viewer_state=RoomViewerState(is_member=is_member),
|
|
|
+ member_user_ids=list(member_user_ids or []),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class TestPluginToolSchemas:
|
|
|
+ """The schemas are the model's only view of these tools — they must not
|
|
|
+ offer credentials, and registration must satisfy the handler contract."""
|
|
|
+
|
|
|
+ @pytest.mark.parametrize(
|
|
|
+ "schema",
|
|
|
+ [CHATTO_LIST_ROOMS_SCHEMA, CHATTO_SEND_DM_SCHEMA, CHATTO_SEND_ROOM_SCHEMA],
|
|
|
+ ids=["list_rooms", "send_dm", "send_room"],
|
|
|
+ )
|
|
|
+ def test_schema_advertises_no_credentials(self, schema):
|
|
|
+ """A base_url argument would send the configured bot token to a host
|
|
|
+ the model names — one injected message away from full credential loss."""
|
|
|
+ properties = schema["parameters"]["properties"]
|
|
|
+ assert "token" not in properties
|
|
|
+ assert "base_url" not in properties
|
|
|
+
|
|
|
+ def test_every_tool_is_registered_as_an_async_handler(self):
|
|
|
+ """Hermes only awaits a handler whose entry is flagged async, and only
|
|
|
+ accepts a string result — an unflagged coroutine is dead on arrival."""
|
|
|
+ ctx = _MockPluginContext()
|
|
|
+ register(ctx)
|
|
|
+ assert [tool["name"] for tool in ctx.registered_tools] == [
|
|
|
+ "chatto_send_dm",
|
|
|
+ "chatto_send_room",
|
|
|
+ "chatto_list_rooms",
|
|
|
+ ]
|
|
|
+ for tool in ctx.registered_tools:
|
|
|
+ assert tool["is_async"] is True, tool["name"]
|
|
|
+ assert inspect.iscoroutinefunction(tool["handler"]), tool["name"]
|
|
|
+
|
|
|
+ def test_bundled_skill_is_registered_with_a_description(self):
|
|
|
+ """A skill registered without a description is invisible in the
|
|
|
+ available-skills list, so nothing ever loads it."""
|
|
|
+ ctx = _MockPluginContext()
|
|
|
+ register(ctx)
|
|
|
+ assert ctx.registered_skills, "no bundled skill registered"
|
|
|
+ for name, path, kwargs in ctx.registered_skills:
|
|
|
+ assert kwargs.get("description"), f"{name} registered without description"
|
|
|
+ assert os.path.exists(path), path
|
|
|
+
|
|
|
+
|
|
|
+class TestPluginToolHandlers:
|
|
|
+ """The handlers run outside the adapter, so they resolve config and build
|
|
|
+ their own client — every path through them has to hold the platform's
|
|
|
+ promises on its own."""
|
|
|
+
|
|
|
+ def _stub_client(self):
|
|
|
+ """A ChattoClient stand-in whose every call succeeds by default."""
|
|
|
+ client = MagicMock()
|
|
|
+ client.close = AsyncMock()
|
|
|
+ client.me = AsyncMock(return_value=_make_user("bot-user-id", "hermes_bot"))
|
|
|
+ client.list_rooms = AsyncMock(return_value=[])
|
|
|
+ client.get_room = AsyncMock(return_value=None)
|
|
|
+ client.get_message = AsyncMock(return_value=None)
|
|
|
+ client.list_room_members = AsyncMock(return_value=([], Page()))
|
|
|
+ client.batch_get_room_members = AsyncMock(return_value=[])
|
|
|
+ client.follow_thread = AsyncMock()
|
|
|
+ client.start_dm = AsyncMock()
|
|
|
+ client.post_message = AsyncMock(
|
|
|
+ side_effect=lambda *a, **kw: _make_message(
|
|
|
+ message_id=f"msg-{client.post_message.await_count}"
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return client
|
|
|
+
|
|
|
+ async def _run(self, handler, args, *, client=None, **config_extra):
|
|
|
+ """Call a tool handler with a stubbed client; returns (parsed, client)."""
|
|
|
+ stub = self._stub_client() if client is None else client
|
|
|
+ cfg = ChattoConfiguration(
|
|
|
+ pconfig=_make_config(token="test-token", **config_extra)
|
|
|
+ )
|
|
|
+ with (
|
|
|
+ patch("adapter.ChattoClient", return_value=stub) as client_cls,
|
|
|
+ patch("adapter._chatto_config_for_tools", return_value=cfg),
|
|
|
+ ):
|
|
|
+ raw = await handler(args)
|
|
|
+ return json.loads(raw), stub, client_cls
|
|
|
+
|
|
|
+ # -- credentials never come from the model --
|
|
|
+
|
|
|
+ async def test_injected_credentials_are_ignored(self):
|
|
|
+ """base_url/token in args must not reach the client: the configured
|
|
|
+ token is the one the plugin holds, and the configured server its own."""
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ _, _, client_cls = await self._run(
|
|
|
+ _handle_chatto_send_room,
|
|
|
+ {
|
|
|
+ "room_id": "room-1",
|
|
|
+ "message": "hi",
|
|
|
+ "base_url": "https://attacker.example",
|
|
|
+ "token": "cht_ATTACKER",
|
|
|
+ },
|
|
|
+ client=client,
|
|
|
+ optional_mention_rooms="room-1",
|
|
|
+ )
|
|
|
+ assert client_cls.call_args.kwargs["base_url"] == "https://chat.example.com"
|
|
|
+ assert client_cls.call_args.kwargs["token"] == "test-token"
|
|
|
+
|
|
|
+ # -- DM rooms --
|
|
|
+
|
|
|
+ def _dm_room_with(self, user_id):
|
|
|
+ """One DM room between the bot and ``user_id``, as list_rooms() reports it."""
|
|
|
+ return _make_room_state(
|
|
|
+ _make_room(f"dm-{user_id}", "", RoomKind.DM),
|
|
|
+ member_user_ids=["bot-user-id", user_id],
|
|
|
+ )
|
|
|
+
|
|
|
+ async def test_send_dm_uses_the_existing_dm_room(self):
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_rooms = AsyncMock(return_value=[self._dm_room_with("user-1")])
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_dm,
|
|
|
+ {"user_id": "user-1", "message": "hi"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["dm_room_id"] == "dm-user-1"
|
|
|
+
|
|
|
+ async def test_send_dm_resolves_a_login_in_the_shared_dm_room(self):
|
|
|
+ """A login is looked up in the rooms we share, not in an admin-only
|
|
|
+ server-wide directory the bot cannot read."""
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_rooms = AsyncMock(return_value=[self._dm_room_with("user-1")])
|
|
|
+ client.list_room_members = AsyncMock(return_value=(["user-1"], Page()))
|
|
|
+ client.batch_get_room_members = AsyncMock(
|
|
|
+ return_value=[DirectoryMember(user=_make_user("user-1", "Alice"))]
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_dm,
|
|
|
+ {"user_login": "alice", "message": "hi"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["recipient_user_id"] == "user-1"
|
|
|
+ assert result["dm_room_id"] == "dm-user-1"
|
|
|
+
|
|
|
+ async def test_send_dm_does_not_search_channels_for_a_login(self):
|
|
|
+ """Only a DM room can carry the reply, so scanning every channel the
|
|
|
+ bot sits in buys nothing but RPCs — and can resolve the wrong person."""
|
|
|
+ target = _make_user("user-1", "Alice")
|
|
|
+ client = self._stub_client()
|
|
|
+ channel = _make_room_state(
|
|
|
+ _make_room("room-1", "Ops", RoomKind.CHANNEL), member_user_ids=["user-1"]
|
|
|
+ )
|
|
|
+ client.list_rooms = AsyncMock(
|
|
|
+ return_value=[channel, self._dm_room_with("user-1")]
|
|
|
+ )
|
|
|
+ client.list_room_members = AsyncMock(return_value=(["user-1"], Page()))
|
|
|
+ client.batch_get_room_members = AsyncMock(
|
|
|
+ return_value=[DirectoryMember(user=target)]
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_dm,
|
|
|
+ {"user_login": "alice", "message": "hi"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["dm_room_id"] == "dm-user-1"
|
|
|
+ searched = [call.args[0] for call in client.list_room_members.await_args_list]
|
|
|
+ assert searched == ["dm-user-1"]
|
|
|
+
|
|
|
+ async def test_send_dm_refuses_to_open_a_dm_room(self):
|
|
|
+ """Only people may start a DM (Chatto permissions guide), so a recipient
|
|
|
+ who has never written to the bot is unreachable — say so, don't guess."""
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_rooms = AsyncMock(
|
|
|
+ return_value=[
|
|
|
+ _make_room_state(_make_room("room-1", "Ops", RoomKind.CHANNEL))
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_dm,
|
|
|
+ {"user_id": "user-1", "message": "hi"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "user-1" in result["error"]
|
|
|
+ client.start_dm.assert_not_awaited()
|
|
|
+ client.post_message.assert_not_awaited()
|
|
|
+
|
|
|
+ # -- room policy --
|
|
|
+
|
|
|
+ async def test_send_room_refuses_a_read_only_room(self):
|
|
|
+ """A channel on neither mention list is read-only by contract — the
|
|
|
+ send path must not become a way around that."""
|
|
|
+ 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_send_room,
|
|
|
+ {"room_id": "room-1", "message": "hi"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "CHATTO_REQUIRE_MENTION_ROOMS" in result["error"]
|
|
|
+ assert "CHATTO_OPTIONAL_MENTION_ROOMS" in result["error"]
|
|
|
+ client.post_message.assert_not_awaited()
|
|
|
+
|
|
|
+ async def test_send_room_posts_into_a_listed_room(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_send_room,
|
|
|
+ {"room_id": "room-1", "message": "hi"},
|
|
|
+ client=client,
|
|
|
+ require_mention_rooms="room-1",
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+
|
|
|
+ async def test_send_room_posts_into_a_dm_room_that_is_on_no_list(self):
|
|
|
+ """DMs answer regardless of the mention lists, so they stay writable."""
|
|
|
+ room = _make_room("dm-1", "", RoomKind.DM)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_room,
|
|
|
+ {"room_id": "dm-1", "message": "hi"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+
|
|
|
+ # -- room lookup --
|
|
|
+
|
|
|
+ async def test_send_room_rejects_an_ambiguous_room_name(self):
|
|
|
+ """Two channels can share a name; delivering to whichever the directory
|
|
|
+ happened to list first is not a choice anyone made."""
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_rooms = AsyncMock(
|
|
|
+ return_value=[
|
|
|
+ _make_room_state(_make_room("room-1", "Liv", RoomKind.CHANNEL)),
|
|
|
+ _make_room_state(_make_room("room-2", "liv", RoomKind.CHANNEL)),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_room,
|
|
|
+ {"room_name": "liv", "message": "hi"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "room-1" in result["error"] and "room-2" in result["error"]
|
|
|
+ client.post_message.assert_not_awaited()
|
|
|
+
|
|
|
+ # -- threading --
|
|
|
+
|
|
|
+ async def test_send_room_refuses_to_thread_in_a_dm(self):
|
|
|
+ """A DM conversation has no threads — the adapter never opens one."""
|
|
|
+ room = _make_room("dm-1", "", RoomKind.DM)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_room,
|
|
|
+ {"room_id": "dm-1", "message": "hi", "create_thread": True},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ client.post_message.assert_not_awaited()
|
|
|
+
|
|
|
+ async def test_send_room_refuses_a_thread_root_from_another_room(self):
|
|
|
+ """A stale or foreign root must not silently attach the message to a
|
|
|
+ thread somewhere else."""
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.get_message = AsyncMock(return_value=None)
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_room,
|
|
|
+ {"room_id": "room-1", "message": "hi", "thread_root_event_id": "msg-9"},
|
|
|
+ client=client,
|
|
|
+ optional_mention_rooms="room-1",
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "msg-9" in result["error"]
|
|
|
+ client.post_message.assert_not_awaited()
|
|
|
+
|
|
|
+ async def test_send_room_accepts_a_thread_root_in_the_same_room(self):
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.get_message = AsyncMock(
|
|
|
+ return_value=_make_message(message_id="msg-9", room_id="room-1")
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_room,
|
|
|
+ {"room_id": "room-1", "message": "hi", "thread_root_event_id": "msg-9"},
|
|
|
+ client=client,
|
|
|
+ optional_mention_rooms="room-1",
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert client.post_message.await_args.kwargs["thread_root_event_id"] == "msg-9"
|
|
|
+
|
|
|
+ # -- chunked delivery --
|
|
|
+
|
|
|
+ async def test_send_room_reports_a_partial_delivery(self):
|
|
|
+ """Chunks already sent are out in the world; reporting total failure
|
|
|
+ invites a retry that sends them twice."""
|
|
|
+ room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.post_message = AsyncMock(
|
|
|
+ side_effect=[_make_message(message_id="msg-1"), ChattoError("rate limited")]
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_room,
|
|
|
+ {"room_id": "room-1", "message": "x" * 12000},
|
|
|
+ client=client,
|
|
|
+ optional_mention_rooms="room-1",
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert result["partial"] is True
|
|
|
+ assert result["message_ids"] == ["msg-1"]
|
|
|
+ assert result["chunks_sent"] == 1
|
|
|
+ assert "rate limited" in result["error"]
|
|
|
+
|
|
|
+ async def test_send_dm_reports_a_partial_delivery(self):
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_rooms = AsyncMock(return_value=[self._dm_room_with("user-1")])
|
|
|
+ client.post_message = AsyncMock(
|
|
|
+ side_effect=[_make_message(message_id="msg-1"), ChattoError("rate limited")]
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_send_dm,
|
|
|
+ {"user_id": "user-1", "message": "x" * 12000},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert result["partial"] is True
|
|
|
+ assert result["message_ids"] == ["msg-1"]
|
|
|
+
|
|
|
+ # -- config resolution --
|
|
|
+
|
|
|
+ # -- list_rooms --
|
|
|
+
|
|
|
+ def _directory(self):
|
|
|
+ """A directory with a named channel, an archived one and a DM room."""
|
|
|
+ return [
|
|
|
+ _make_room_state(_make_room("room-1", "Liv", RoomKind.CHANNEL)),
|
|
|
+ _make_room_state(_make_room("room-2", "Liv-Dev", RoomKind.CHANNEL)),
|
|
|
+ _make_room_state(
|
|
|
+ _make_room("dm-1", "", RoomKind.DM), member_user_ids=["u1", "u2"]
|
|
|
+ ),
|
|
|
+ ]
|
|
|
+
|
|
|
+ async def test_list_rooms_reports_ids_kinds_and_membership(self):
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_rooms = AsyncMock(return_value=self._directory())
|
|
|
+ result, _, _ = await self._run(_handle_chatto_list_rooms, {}, client=client)
|
|
|
+ assert result["success"] is True
|
|
|
+ assert result["count"] == 3
|
|
|
+ by_id = {room["room_id"]: room for room in result["rooms"]}
|
|
|
+ assert by_id["room-1"]["name"] == "Liv"
|
|
|
+ assert by_id["room-1"]["is_member"] is True
|
|
|
+ assert by_id["room-1"]["kind"] == "ROOM_KIND_CHANNEL"
|
|
|
+ # A DM room has no name, so it is labelled by its ID instead of blank.
|
|
|
+ assert by_id["dm-1"]["label"] == "(DM room dm-1)"
|
|
|
+ assert by_id["dm-1"]["name"] == ""
|
|
|
+
|
|
|
+ async def test_list_rooms_name_filter_skips_dm_rooms(self):
|
|
|
+ client = self._stub_client()
|
|
|
+ client.list_rooms = AsyncMock(return_value=self._directory())
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_list_rooms, {"name": "liv"}, client=client
|
|
|
+ )
|
|
|
+ assert [room["room_id"] for room in result["rooms"]] == ["room-1", "room-2"]
|
|
|
+
|
|
|
+ async def test_tool_config_falls_back_to_config_yaml(self):
|
|
|
+ """A cron session never runs hermes_validate_config, so the fallback
|
|
|
+ has to read config.yaml's own extra block — not an empty one."""
|
|
|
+ import gateway.config as gw_config
|
|
|
+
|
|
|
+ yaml_cfg = gw_config.PlatformConfig(
|
|
|
+ enabled=True,
|
|
|
+ extra={"token": "yaml-token", "base_url": "https://yaml.example"},
|
|
|
+ )
|
|
|
+ gateway_cfg = gw_config.GatewayConfig(
|
|
|
+ platforms={Platform(ChattoConstants.PLATFORM_NAME): yaml_cfg}
|
|
|
+ )
|
|
|
+ with (
|
|
|
+ patch("adapter._resolved_chatto_config", None),
|
|
|
+ patch.object(gw_config, "load_gateway_config", return_value=gateway_cfg),
|
|
|
+ # None in sys.modules makes the import fail, as in a bare cron process.
|
|
|
+ patch.dict(sys.modules, {"agent.secret_scope": None}),
|
|
|
+ ):
|
|
|
+ resolved = _chatto_config_for_tools()
|
|
|
+ assert resolved is not None
|
|
|
+ assert resolved.token.value == "yaml-token"
|
|
|
+ assert resolved.base_url.value == "https://yaml.example"
|