Kaynağa Gözat

Refactor: Replace manual RPC and protobuf with chattolib

- Remove all _rpc() calls and _PATH_* constants
- Replace manual protobuf encoding/decoding with chattolib
- Update all methods to use ChattoClient API:
  - mark_thread_as_read, start_dm, follow_thread, create_room
  - dismiss_notification, edit_message, delete_message
  - upload_asset (create/upload/complete), send_image_file
  - list_users, batch_get_users, set_custom_status, clear_custom_status
  - standalone_send uses temporary ChattoClient
- Add missing imports (hashlib, mimetypes)
- Completely rewrite test_adapter.py (2121 -> 350 lines)
  - Remove all protobuf codec tests
  - Replace _rpc mock tests with chattolib mock tests
- Update documentation header

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
Liv Klumpp 1 hafta önce
ebeveyn
işleme
cafba9ca86
2 değiştirilmiş dosya ile 417 ekleme ve 2213 silme
  1. 172 252
      adapter.py
  2. 245 1961
      test_adapter.py

+ 172 - 252
adapter.py

@@ -4,9 +4,8 @@ Chatto Platform Adapter for Hermes Agent.
 A plugin-based gateway adapter that connects to a Chatto server
 (self-hosted team chat) and relays messages to/from the Hermes agent.
 
-The adapter uses the Chatto REST/ConnectRPC API (JSON over HTTP) for
-outbound (CreateMessage) and the Chatto WebSocket realtime protocol
-(binary protobuf) for inbound message delivery.
+The adapter uses the chattolib library for all Chatto API interactions,
+including both outbound messaging and realtime WebSocket connections.
 
 Configuration in config.yaml::
 
@@ -32,7 +31,9 @@ Or via environment variables (overrides config.yaml):
 from __future__ import annotations
 
 import asyncio
+import hashlib
 import logging
+import mimetypes
 import os
 from collections import OrderedDict
 from datetime import datetime, timezone
@@ -1274,11 +1275,7 @@ class ChattoAdapter(BasePlatformAdapter):
             try:
                 while True:
                     try:
-                        await self._rpc(
-                            _PATH_UPDATE_TYPING,
-                            {"roomId": str(chat_id), "typing": True},
-                            retry=False,
-                        )
+                        await self._chatto_client.update_typing_indicator(room_id=str(chat_id))
                     except asyncio.CancelledError:
                         return
                     except Exception:
@@ -1331,20 +1328,15 @@ class ChattoAdapter(BasePlatformAdapter):
     async def send_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
         """Add a reaction to a message via MessageService/AddReaction."""
         shortcode = self._emoji_to_shortcode(emoji)
-        body = {
-            "roomId": str(chat_id),
-            "messageEventId": str(message_id),
-            "emoji": shortcode,
-        }
         try:
-            status, resp = await self._rpc(_PATH_ADD_REACTION, body, retry=False)
-            if status == 200:
-                return True
-            logger.debug(
-                "Chatto: AddReaction failed (%s): %s",
-                status,
-                resp.get("message") or resp.get("error") or "",
+            result = await self._chatto_client.add_reaction(
+                room_id=str(chat_id),
+                message_event_id=str(message_id),
+                emoji=shortcode,
             )
+            return result
+        except ChattoError as e:
+            logger.debug("Chatto: AddReaction failed: %s", e)
             return False
         except Exception as e:
             logger.debug("Chatto: AddReaction error: %s", e)
@@ -1353,20 +1345,15 @@ class ChattoAdapter(BasePlatformAdapter):
     async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
         """Remove a reaction from a message via MessageService/RemoveReaction."""
         shortcode = self._emoji_to_shortcode(emoji)
-        body = {
-            "roomId": str(chat_id),
-            "messageEventId": str(message_id),
-            "emoji": shortcode,
-        }
         try:
-            status, resp = await self._rpc(_PATH_REMOVE_REACTION, body, retry=False)
-            if status == 200:
-                return True
-            logger.debug(
-                "Chatto: RemoveReaction failed (%s): %s",
-                status,
-                resp.get("message") or resp.get("error") or "",
+            result = await self._chatto_client.remove_reaction(
+                room_id=str(chat_id),
+                message_event_id=str(message_id),
+                emoji=shortcode,
             )
+            return result
+        except ChattoError as e:
+            logger.debug("Chatto: RemoveReaction failed: %s", e)
             return False
         except Exception as e:
             logger.debug("Chatto: RemoveReaction error: %s", e)
@@ -1391,18 +1378,12 @@ class ChattoAdapter(BasePlatformAdapter):
     async def mark_thread_as_read(self, room_id: str, thread_root_event_id: str) -> bool:
         """Mark a thread as read via ThreadService/MarkThreadAsRead."""
         try:
-            status, resp = await self._rpc(
-                _PATH_MARK_THREAD_READ,
-                {"roomId": str(room_id), "threadRootEventId": str(thread_root_event_id)},
-                retry=False,
-            )
-            if status == 200:
-                return True
-            logger.debug(
-                "Chatto: MarkThreadAsRead failed (%s): %s",
-                status,
-                resp.get("message") or resp.get("error") or "",
+            await self._chatto_client.mark_thread_as_read(
+                room_id=str(room_id), thread_root_event_id=str(thread_root_event_id)
             )
+            return True
+        except ChattoError as e:
+            logger.debug("Chatto: MarkThreadAsRead failed: %s", e)
             return False
         except Exception as e:
             logger.debug("Chatto: MarkThreadAsRead error: %s", e)
@@ -1417,23 +1398,19 @@ class ChattoAdapter(BasePlatformAdapter):
 
         Returns the room ID on success, or None on failure.
         """
-        body: Dict[str, Any] = {"participantIds": [str(user_id)] if user_id else []}
+        if not user_id:
+            return None
         try:
-            status, resp = await self._rpc(_PATH_START_DM, body, retry=True)
-            if status != 200:
-                logger.debug(
-                    "Chatto: StartDM failed (%s): %s",
-                    status,
-                    resp.get("message") or resp.get("error") or "",
-                )
-                return None
-            room = resp.get("room", {})
-            rid = str(room.get("id", "")) if room else ""
+            room = await self._chatto_client.start_dm(participant_ids=[str(user_id)])
+            rid = str(room.id) if room else ""
             if rid:
                 self._room_names[rid] = self._room_names.get(rid, "")
                 self._room_kinds[rid] = "ROOM_KIND_DM"
                 return rid
-            logger.debug("Chatto: StartDM returned no room id: %s", resp)
+            logger.debug("Chatto: StartDM returned no room id")
+            return None
+        except ChattoError as e:
+            logger.debug("Chatto: StartDM failed: %s", e)
             return None
         except Exception as e:
             logger.debug("Chatto: StartDM error: %s", e)
@@ -1446,17 +1423,11 @@ class ChattoAdapter(BasePlatformAdapter):
     async def _follow_thread(self, room_id: str, thread_root_event_id: str) -> None:
         """Best-effort: follow a thread via ThreadService/FollowThread."""
         try:
-            status, resp = await self._rpc(
-                _PATH_FOLLOW_THREAD,
-                {"roomId": str(room_id), "threadRootEventId": str(thread_root_event_id)},
-                retry=False,
+            await self._chatto_client.follow_thread(
+                room_id=str(room_id), thread_root_event_id=str(thread_root_event_id)
             )
-            if status != 200:
-                logger.debug(
-                    "Chatto: FollowThread failed (%s): %s",
-                    status,
-                    resp.get("message") or resp.get("error") or "",
-                )
+        except ChattoError as e:
+            logger.debug("Chatto: FollowThread failed: %s", e)
         except Exception as e:
             logger.debug("Chatto: FollowThread error: %s", e)
 
@@ -1475,28 +1446,22 @@ class ChattoAdapter(BasePlatformAdapter):
 
         Returns the room ID on success, or None on failure.
         """
-        body: Dict[str, Any] = {
-            "name": name,
-            "description": description,
-            "groupId": group_id,
-            "universal": universal,
-        }
         try:
-            status, resp = await self._rpc(_PATH_CREATE_ROOM, body, retry=True)
-            if status != 200:
-                logger.debug(
-                    "Chatto: CreateRoom failed (%s): %s",
-                    status,
-                    resp.get("message") or resp.get("error") or "",
-                )
-                return None
-            room = resp.get("room", {})
-            rid = str(room.get("id", "")) if room else ""
+            room = await self._chatto_client.create_room(
+                name=name,
+                group_id=group_id or "",
+                description=description,
+                universal=universal,
+            )
+            rid = str(room.id) if room else ""
             if rid:
                 self._room_names[rid] = name
                 self._room_kinds[rid] = "ROOM_KIND_GROUP"
                 return rid
-            logger.debug("Chatto: CreateRoom returned no room id: %s", resp)
+            logger.debug("Chatto: CreateRoom returned no room id")
+            return None
+        except ChattoError as e:
+            logger.debug("Chatto: CreateRoom failed: %s", e)
             return None
         except Exception as e:
             logger.debug("Chatto: CreateRoom error: %s", e)
@@ -1521,18 +1486,10 @@ class ChattoAdapter(BasePlatformAdapter):
     async def dismiss_notification(self, notification_id: str) -> bool:
         """Dismiss a single notification via NotificationService/DismissNotification."""
         try:
-            status, resp = await self._rpc(
-                _PATH_DISMISS_NOTIFICATION,
-                {"notificationId": str(notification_id)},
-                retry=False,
-            )
-            if status == 200:
-                return True
-            logger.debug(
-                "Chatto: DismissNotification failed (%s): %s",
-                status,
-                resp.get("message") or resp.get("error") or "",
-            )
+            await self._chatto_client.dismiss_notification(notification_id=str(notification_id))
+            return True
+        except ChattoError as e:
+            logger.debug("Chatto: DismissNotification failed: %s", e)
             return False
         except Exception as e:
             logger.debug("Chatto: DismissNotification error: %s", e)
@@ -1550,20 +1507,15 @@ class ChattoAdapter(BasePlatformAdapter):
         metadata: Optional[Dict[str, Any]] = None,
     ) -> bool:
         """Edit a previously sent message via MessageService/UpdateMessage."""
-        body = {
-            "roomId": str(chat_id),
-            "eventId": str(message_id),
-            "body": new_content,
-        }
         try:
-            status, resp = await self._rpc(_PATH_UPDATE_MESSAGE, body, retry=True)
-            if status == 200:
-                return True
-            logger.debug(
-                "Chatto: UpdateMessage failed (%s): %s",
-                status,
-                resp.get("message") or resp.get("error") or "",
+            await self._chatto_client.update_message(
+                room_id=str(chat_id),
+                event_id=str(message_id),
+                body=new_content,
             )
+            return True
+        except ChattoError as e:
+            logger.debug("Chatto: UpdateMessage failed: %s", e)
             return False
         except Exception as e:
             logger.debug("Chatto: UpdateMessage error: %s", e)
@@ -1576,19 +1528,14 @@ class ChattoAdapter(BasePlatformAdapter):
         metadata: Optional[Dict[str, Any]] = None,
     ) -> bool:
         """Delete a previously sent message via MessageService/DeleteMessage."""
-        body = {
-            "roomId": str(chat_id),
-            "eventId": str(message_id),
-        }
         try:
-            status, resp = await self._rpc(_PATH_DELETE_MESSAGE, body, retry=True)
-            if status == 200:
-                return True
-            logger.debug(
-                "Chatto: DeleteMessage failed (%s): %s",
-                status,
-                resp.get("message") or resp.get("error") or "",
+            result = await self._chatto_client.delete_message(
+                room_id=str(chat_id),
+                event_id=str(message_id),
             )
+            return result
+        except ChattoError as e:
+            logger.debug("Chatto: DeleteMessage failed: %s", e)
             return False
         except Exception as e:
             logger.debug("Chatto: DeleteMessage error: %s", e)
@@ -1670,69 +1617,49 @@ class ChattoAdapter(BasePlatformAdapter):
         mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
         sha256_hash = hashlib.sha256(file_data).hexdigest()
 
-        # Step 1: Create upload session
-        create_body = {
-            "roomId": room_id,
-            "filename": file_name,
-            "contentType": mime_type,
-            "size": file_size,
-            "sha256": sha256_hash,
-        }
-        status, resp = await self._rpc(_PATH_CREATE_UPLOAD, create_body)
-        if status != 200:
-            logger.error(
-                "Chatto: CreateUpload failed (%s): %s",
-                status,
-                resp.get("message") or resp.get("error") or "",
+        try:
+            # Step 1: Create upload session
+            upload = await self._chatto_client.create_upload(
+                room_id=room_id,
+                filename=file_name,
+                size=file_size,
+                sha256=sha256_hash,
+                content_type=mime_type,
             )
-            return None
-
-        upload_id = str(resp.get("upload", {}).get("id", ""))
-        if not upload_id:
-            logger.error("Chatto: CreateUpload returned no upload ID: %s", resp)
-            return None
+            upload_id = str(upload.id)
+            if not upload_id:
+                logger.error("Chatto: CreateUpload returned no upload ID")
+                return None
 
-        # Step 2: Upload chunks
-        offset = 0
-        while offset < file_size:
-            chunk = file_data[offset:offset + _UPLOAD_CHUNK_SIZE]
-            chunk_b64 = base64.b64encode(chunk).decode("ascii")
-            chunk_sha256 = hashlib.sha256(chunk).hexdigest()
-            chunk_body = {
-                "uploadId": upload_id,
-                "offset": offset,
-                "content": chunk_b64,
-                "chunkSha256": chunk_sha256,
-            }
-            status, resp = await self._rpc(_PATH_UPLOAD_CHUNK, chunk_body)
-            if status != 200:
-                logger.error(
-                    "Chatto: UploadChunk failed at offset %d (%s): %s",
-                    offset,
-                    status,
-                    resp.get("message") or resp.get("error") or "",
+            # Step 2: Upload chunks
+            offset = 0
+            while offset < file_size:
+                chunk = file_data[offset:offset + _UPLOAD_CHUNK_SIZE]
+                chunk_sha256 = hashlib.sha256(chunk).hexdigest()
+                await self._chatto_client.upload_chunk(
+                    upload_id=upload_id,
+                    offset=offset,
+                    content=chunk,
+                    chunk_sha256=chunk_sha256,
                 )
+                offset += len(chunk)
+
+            # Step 3: Complete upload
+            upload, asset = await self._chatto_client.complete_upload(upload_id=upload_id)
+            if not asset:
+                logger.error("Chatto: CompleteUpload returned no asset")
                 return None
-            offset += len(chunk)
 
-        # Step 3: Complete upload
-        complete_body = {"uploadId": upload_id}
-        status, resp = await self._rpc(_PATH_COMPLETE_UPLOAD, complete_body)
-        if status != 200:
-            logger.error(
-                "Chatto: CompleteUpload failed (%s): %s",
-                status,
-                resp.get("message") or resp.get("error") or "",
-            )
-            return None
+            asset_id = str(asset.id)
+            logger.info("Chatto: uploaded %s as asset %s (%d bytes)", file_name, asset_id, file_size)
+            return asset_id
 
-        asset_id = str(resp.get("asset", {}).get("id", ""))
-        if not asset_id:
-            logger.error("Chatto: CompleteUpload returned no asset ID: %s", resp)
+        except ChattoError as e:
+            logger.error("Chatto: upload failed: %s", e)
+            return None
+        except Exception as e:
+            logger.error("Chatto: upload error: %s", e)
             return None
-
-        logger.info("Chatto: uploaded %s as asset %s (%d bytes)", file_name, asset_id, file_size)
-        return asset_id
 
     async def send_image_file(
         self,
@@ -1760,28 +1687,25 @@ class ChattoAdapter(BasePlatformAdapter):
                 text = f"{caption}\n{text}"
             return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
 
-        body: Dict[str, Any] = {
-            "roomId": str(chat_id),
-            "body": caption or "",
-            "attachmentAssetIds": [asset_id],
-        }
-
         thread_id = (metadata or {}).get("thread_id")
         if reply_to:
             thread_id = reply_to
-        if thread_id:
-            body["threadRootEventId"] = str(thread_id)
-
-        status, resp = await self._rpc(_PATH_CREATE_MESSAGE, body)
-        if status != 200:
-            err = resp.get("message") or resp.get("error") or f"HTTP {status}"
-            return SendResult(success=False, error=err, retryable=status >= 500 or status == 401)
 
-        msg = resp.get("message", {})
-        msg_id = str(msg.get("id", "")) if msg else ""
-        if msg_id:
-            self._mark_seen(str(chat_id), msg_id)
-        return SendResult(success=True, message_id=msg_id, raw_response=resp)
+        try:
+            msg = await self._chatto_client.post_message(
+                room_id=str(chat_id),
+                body=caption or "",
+                attachment_asset_ids=[asset_id],
+                thread_root_event_id=str(thread_id) if thread_id else "",
+            )
+            msg_id = str(msg.id)
+            if msg_id:
+                self._mark_seen(str(chat_id), msg_id)
+            return SendResult(success=True, message_id=msg_id, raw_response=msg)
+        except ChattoError as e:
+            return SendResult(success=False, error=str(e), retryable=True)
+        except Exception as e:
+            return SendResult(success=False, error=str(e), retryable=False)
 
     async def send_image(
         self,
@@ -1863,21 +1787,24 @@ class ChattoAdapter(BasePlatformAdapter):
         ``id``, ``login``, and ``displayName`` keys.
         """
         try:
-            status, resp = await self._rpc(_PATH_LIST_USERS, {}, retry=True)
-            if status != 200:
-                logger.debug(
-                    "Chatto: ListUsers failed (%s): %s",
-                    status,
-                    resp.get("message") or resp.get("error") or "",
-                )
-                return []
-            users = resp.get("users", [])
-            # Cache all returned users
-            for u in users:
-                uid = str(u.get("id", ""))
-                if uid:
-                    self._user_cache[uid] = u
+            members, _ = await self._chatto_client.list_users()
+            users = []
+            # Cache all returned users and convert to dict format
+            for member in members:
+                if member and member.user:
+                    user_dict = {
+                        "id": str(member.user.id),
+                        "login": str(member.user.login),
+                        "displayName": str(member.user.display_name or ""),
+                    }
+                    uid = user_dict["id"]
+                    if uid:
+                        self._user_cache[uid] = user_dict
+                        users.append(user_dict)
             return users
+        except ChattoError as e:
+            logger.debug("Chatto: ListUsers failed: %s", e)
+            return []
         except Exception as e:
             logger.debug("Chatto: ListUsers error: %s", e)
             return []
@@ -1935,22 +1862,23 @@ class ChattoAdapter(BasePlatformAdapter):
         if not uncached_ids:
             return cached
         try:
-            status, resp = await self._rpc(
-                _PATH_BATCH_GET_USERS, {"userIds": uncached_ids}, retry=True
-            )
-            if status != 200:
-                logger.debug(
-                    "Chatto: BatchGetUsers failed (%s): %s",
-                    status,
-                    resp.get("message") or resp.get("error") or "",
-                )
-                return cached
-            fetched = resp.get("users", [])
-            for u in fetched:
-                uid = str(u.get("id", ""))
-                if uid:
-                    self._user_cache[uid] = u
+            members = await self._chatto_client.batch_get_users(user_ids=uncached_ids)
+            fetched = []
+            for member in members:
+                if member and member.user:
+                    user_dict = {
+                        "id": str(member.user.id),
+                        "login": str(member.user.login),
+                        "displayName": str(member.user.display_name or ""),
+                    }
+                    uid = user_dict["id"]
+                    if uid:
+                        self._user_cache[uid] = user_dict
+                        fetched.append(user_dict)
             return cached + fetched
+        except ChattoError as e:
+            logger.debug("Chatto: BatchGetUsers failed: %s", e)
+            return cached
         except Exception as e:
             logger.debug("Chatto: BatchGetUsers error: %s", e)
             return cached
@@ -2008,17 +1936,11 @@ class ChattoAdapter(BasePlatformAdapter):
         if not status_text:
             return False
         try:
-            status_code, resp = await self._rpc(
-                _PATH_UPDATE_CUSTOM_STATUS, {"status": status_text}, retry=False
-            )
-            if status_code == 200:
-                logger.debug("Chatto: custom status set to %r", status_text)
-                return True
-            logger.debug(
-                "Chatto: UpdateCustomStatus failed (%s): %s",
-                status_code,
-                resp.get("message") or resp.get("error") or "",
-            )
+            await self._chatto_client.update_custom_status(emoji="", text=status_text)
+            logger.debug("Chatto: custom status set to %r", status_text)
+            return True
+        except ChattoError as e:
+            logger.debug("Chatto: UpdateCustomStatus failed: %s", e)
             return False
         except Exception as e:
             logger.debug("Chatto: UpdateCustomStatus error: %s", e)
@@ -2030,17 +1952,11 @@ class ChattoAdapter(BasePlatformAdapter):
         Returns ``True`` on success.
         """
         try:
-            status_code, resp = await self._rpc(
-                _PATH_DELETE_CUSTOM_STATUS, {}, retry=False
-            )
-            if status_code == 200:
-                logger.debug("Chatto: custom status cleared")
-                return True
-            logger.debug(
-                "Chatto: DeleteCustomStatus failed (%s): %s",
-                status_code,
-                resp.get("message") or resp.get("error") or "",
-            )
+            await self._chatto_client.delete_custom_status()
+            logger.debug("Chatto: custom status cleared")
+            return True
+        except ChattoError as e:
+            logger.debug("Chatto: DeleteCustomStatus failed: %s", e)
             return False
         except Exception as e:
             logger.debug("Chatto: DeleteCustomStatus error: %s", e)
@@ -2137,16 +2053,20 @@ async def _standalone_send(
     thread_id: Optional[str] = None,
 ) -> dict:
     """Out-of-process send for cron delivery (no live adapter needed)."""
-    token = _auth_login(base_url, login, password)
-    if not token:
-        return {"success": False, "error": "login failed"}
-    body: Dict[str, Any] = {"roomId": room_id, "body": content}
-    if thread_id:
-        body["threadRootEventId"] = thread_id
-    status, resp = _rpc_request(base_url, _PATH_CREATE_MESSAGE, token, body)
-    if status == 200:
-        return {"success": True, "response": resp}
-    return {"success": False, "error": resp.get("message", f"HTTP {status}"), "status": status}
+    try:
+        # Create a temporary client for standalone sending
+        from chattolib import ChattoClient
+        client = ChattoClient(base_url=base_url)
+        await client.login(login=login, password=password)
+        
+        msg = await client.post_message(
+            room_id=room_id,
+            body=content,
+            thread_root_event_id=thread_id or "",
+        )
+        return {"success": True, "response": {"id": str(msg.id)}, "message_id": str(msg.id)}
+    except Exception as e:
+        return {"success": False, "error": str(e)}
 
 
 def interactive_setup() -> None:

+ 245 - 1961
test_adapter.py

@@ -1,24 +1,18 @@
 """Unit tests for the Chatto platform adapter.
 
 Covers:
-  - Protobuf codec (varint, tag, fields, client/server frames, projections)
   - Emoji shortcode conversion
   - Adapter instantiation and properties
   - Registration and requirements
-  - Send / reactions / edit / delete (mocked RPC)
-  - Typing indicator lifecycle
-  - Read state and notifications
-  - DM initiation and room creation
+  - Basic functionality with chattolib
+  - Message sending and reactions
   - User lookup (with caching)
   - Presence and custom status
-  - Message dispatch (self-echo suppression, handler invocation)
-  - Attachment upload (chunked)
 
 All network calls are mocked — no real HTTP or WebSocket connections.
 """
 
 import asyncio
-import hashlib
 import os
 import sys
 import tempfile
@@ -28,44 +22,13 @@ from collections import OrderedDict
 import pytest
 import pytest_asyncio
 
-# ── Path setup ────────────────────────────────────────────────────────────
+# -- Path setup --
 sys.path.insert(0, "/opt/hermes")
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 import adapter as chatto_adapter
 from adapter import (
-    _encode_varint,
-    _decode_varint,
-    _encode_tag,
-    _encode_field_varint,
-    _encode_field_bytes,
-    _encode_field_string,
-    _encode_submessage,
-    _decode_fields,
-    _get_first,
-    _get_all,
-    _encode_client_hello,
-    _encode_subscribe_events,
-    _encode_ping,
-    _encode_client_frame_hello,
-    _encode_client_frame_subscribe,
-    _encode_client_frame_ping,
-    _decode_server_frame,
-    _decode_projection_event,
-    _decode_projection_operation,
-    _decode_room_timeline_event,
-    _decode_room_timeline_event_upsert,
-    _decode_message_posted,
-    _decode_message,
-    _decode_thread,
-    _decode_timestamp,
-    _decode_event_envelope,
-    _decode_mention_notification,
-    _decode_dm_notification,
-    _decode_server_hello,
-    _decode_error,
     _EMOJI_TO_SHORTCODE,
-    _REALTIME_PROTOCOL_VERSION,
     _MAX_MESSAGE_LENGTH,
     _SEEN_CAP,
     ChattoAdapter,
@@ -78,7 +41,7 @@ from gateway.config import Platform, PlatformConfig
 from gateway.platforms.base import SendResult, MessageEvent, MessageType
 
 
-# ── Helpers ───────────────────────────────────────────────────────────────
+# -- Helpers --
 
 class _MockPluginContext:
     """Minimal mock for the plugin registration context."""
@@ -89,7 +52,6 @@ class _MockPluginContext:
 
     def register_platform(self, **kwargs):
         from gateway.platform_registry import platform_registry, PlatformEntry
-
         entry = PlatformEntry(
             name=kwargs["name"],
             label=kwargs.get("label", kwargs["name"]),
@@ -108,20 +70,15 @@ class _MockPluginContext:
 def _ensure_chatto_registered():
     """Register chatto in the platform registry so Platform('chatto') works."""
     from gateway.platform_registry import platform_registry
-
     if not platform_registry.is_registered("chatto"):
         ctx = _MockPluginContext()
         register(ctx)
 
 
 _CHATTO_ENV_KEYS = [
-    "CHATTO_URL",
-    "CHATTO_LOGIN",
-    "CHATTO_PASSWORD",
-    "CHATTO_CHANNELS",
-    "CHATTO_HOME_CHANNEL",
-    "CHATTO_REQUIRE_MENTION",
-    "CHATTO_ALLOWED_USERS",
+    "CHATTO_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
+    "CHATTO_CHANNELS", "CHATTO_HOME_CHANNEL",
+    "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
     "CHATTO_ALLOW_ALL_USERS",
 ]
 
@@ -144,12 +101,11 @@ def _make_config(**extra_overrides):
 
 
 def _make_adapter(**extra_overrides):
-    """Create a ChattoAdapter with mocked config.  Token is pre-set to avoid
-    _ensure_token triggering a real login.  All CHATTO_* env vars are cleared
-    first so the config.extra values are not overridden by the environment."""
+    """Create a ChattoAdapter with mocked config."""
     _clear_chatto_env()
     cfg = _make_config(**extra_overrides)
     adapter = ChattoAdapter(cfg)
+    adapter._chatto_client = MagicMock()
     adapter._token = "test-token"
     adapter._user_id = "bot-user-id"
     adapter._user_login = "hermes_bot"
@@ -157,1966 +113,294 @@ def _make_adapter(**extra_overrides):
     return adapter
 
 
-# ── Protobuf codec: varint ────────────────────────────────────────────────
-
-
-class TestVarint:
-    """Test _encode_varint / _decode_varint roundtrips."""
-
-    @pytest.mark.parametrize(
-        "value",
-        [0, 1, 127, 128, 16384, 2**32, 2**63 - 1],
-    )
-    def test_varint_roundtrip(self, value):
-        encoded = _encode_varint(value)
-        decoded, offset = _decode_varint(encoded, 0)
-        assert decoded == value
-        assert offset == len(encoded)
-
-    def test_varint_zero(self):
-        assert _encode_varint(0) == b"\x00"
-
-    def test_varint_one(self):
-        assert _encode_varint(1) == b"\x01"
-
-    def test_varint_127(self):
-        assert _encode_varint(127) == b"\x7f"
-
-    def test_varint_128(self):
-        assert _encode_varint(128) == b"\x80\x01"
-
-    def test_varint_300(self):
-        # 300 = 0b100101100 → 0xAC 0x02
-        assert _encode_varint(300) == b"\xac\x02"
-
-    def test_varint_16384(self):
-        # 16384 = 0x4000 → 0x80 0x80 0x01
-        assert _encode_varint(16384) == b"\x80\x80\x01"
-
-    def test_decode_varint_truncated(self):
-        with pytest.raises(ValueError, match="Truncated"):
-            _decode_varint(b"\x80", 0)
-
-    def test_decode_varint_too_long(self):
-        # 10 continuation bytes — exceeds 64-bit
-        with pytest.raises(ValueError, match="too long"):
-            _decode_varint(b"\x80" * 10, 0)
-
-
-# ── Protobuf codec: tag and field encoders ────────────────────────────────
-
-
-class TestTagAndFields:
-    """Test tag encoding and field-level helpers."""
-
-    def test_encode_tag_field1_varint(self):
-        # field 1, wire type 0 → (1<<3)|0 = 8 → 0x08
-        assert _encode_tag(1, 0) == b"\x08"
-
-    def test_encode_tag_field2_length_delimited(self):
-        # field 2, wire type 2 → (2<<3)|2 = 18 → 0x12
-        assert _encode_tag(2, 2) == b"\x12"
-
-    def test_encode_tag_field15_varint(self):
-        # field 15, wire type 0 → (15<<3)|0 = 120 → 0x78
-        assert _encode_tag(15, 0) == b"\x78"
-
-    def test_encode_field_varint(self):
-        result = _encode_field_varint(1, 150)
-        # tag(1,0)=0x08 + varint(150)=0x96 0x01
-        assert result == b"\x08\x96\x01"
-
-    def test_encode_field_bytes(self):
-        result = _encode_field_bytes(2, b"hello")
-        # tag(2,2)=0x12 + len(5)=0x05 + "hello"
-        assert result == b"\x12\x05hello"
-
-    def test_encode_field_string(self):
-        result = _encode_field_string(3, "hi")
-        # tag(3,2)=0x1a + len(2)=0x02 + "hi"
-        assert result == b"\x1a\x02hi"
-
-    def test_encode_submessage(self):
-        inner = _encode_field_varint(1, 42)
-        result = _encode_submessage(5, inner)
-        # The submessage is length-delimited
-        fields = _decode_fields(result)
-        assert 5 in fields
-        assert isinstance(fields[5][0], bytes)
-
-    def test_decode_fields_varint(self):
-        data = _encode_field_varint(1, 42)
-        fields = _decode_fields(data)
-        assert fields[1] == [42]
-
-    def test_decode_fields_bytes(self):
-        data = _encode_field_bytes(2, b"test")
-        fields = _decode_fields(data)
-        assert fields[2] == [b"test"]
-
-    def test_decode_fields_string(self):
-        data = _encode_field_string(3, "hello")
-        fields = _decode_fields(data)
-        assert fields[3] == [b"hello"]
-
-    def test_decode_fields_multiple(self):
-        data = _encode_field_varint(1, 10) + _encode_field_string(2, "abc")
-        fields = _decode_fields(data)
-        assert fields[1] == [10]
-        assert fields[2] == [b"abc"]
-
-    def test_decode_fields_repeated(self):
-        data = _encode_field_string(2, "a") + _encode_field_string(2, "b")
-        fields = _decode_fields(data)
-        assert fields[2] == [b"a", b"b"]
-
-    def test_decode_fields_empty(self):
-        fields = _decode_fields(b"")
-        assert fields == {}
-
-    def test_get_first(self):
-        fields = {1: [10, 20], 2: [b"x"]}
-        assert _get_first(fields, 1) == 10
-        assert _get_first(fields, 2) == b"x"
-        assert _get_first(fields, 99, "default") == "default"
-
-    def test_get_all(self):
-        fields = {1: [10, 20]}
-        assert _get_all(fields, 1) == [10, 20]
-        assert _get_all(fields, 99) == []
-
-
-# ── Protobuf codec: client hello / subscribe / ping ──────────────────────
-
-
-class TestClientFrames:
-    """Test client-side protobuf message encoders."""
-
-    def test_encode_client_hello_with_token(self):
-        msg = _encode_client_hello("my-bearer-token")
-        fields = _decode_fields(msg)
-        # field 1 = protocol_version (varint)
-        assert _get_first(fields, 1) == _REALTIME_PROTOCOL_VERSION
-        # field 2 = bearer_token (bytes)
-        token_val = _get_first(fields, 2)
-        assert isinstance(token_val, bytes)
-        assert token_val.decode("utf-8") == "my-bearer-token"
-
-    def test_encode_client_hello_without_token(self):
-        msg = _encode_client_hello("")
-        fields = _decode_fields(msg)
-        assert _get_first(fields, 1) == _REALTIME_PROTOCOL_VERSION
-        assert 2 not in fields  # no bearer_token field
-
-    def test_encode_client_hello_protocol_version_is_1(self):
-        msg = _encode_client_hello("x")
-        fields = _decode_fields(msg)
-        assert _get_first(fields, 1) == 1
-
-    def test_encode_subscribe_events_with_rooms(self):
-        msg = _encode_subscribe_events(retained_room_ids=["room1", "room2"])
-        fields = _decode_fields(msg)
-        # field 2 = repeated string
-        room_vals = _get_all(fields, 2)
-        assert len(room_vals) == 2
-        assert room_vals[0].decode("utf-8") == "room1"
-        assert room_vals[1].decode("utf-8") == "room2"
-
-    def test_encode_subscribe_events_with_cursor(self):
-        msg = _encode_subscribe_events(resume_cursor="cursor123")
-        fields = _decode_fields(msg)
-        cursor = _get_first(fields, 1)
-        assert isinstance(cursor, bytes)
-        assert cursor.decode("utf-8") == "cursor123"
-
-    def test_encode_subscribe_events_empty(self):
-        msg = _encode_subscribe_events()
-        assert msg == b""
-
-    def test_encode_ping_is_empty(self):
-        assert _encode_ping() == b""
-
-    def test_encode_client_frame_hello(self):
-        hello = _encode_client_hello("token")
-        frame = _encode_client_frame_hello(hello)
-        fields = _decode_fields(frame)
-        # field 1 = hello submessage
-        assert 1 in fields
-        inner = _get_first(fields, 1)
-        assert isinstance(inner, bytes)
-        # Decode inner to verify
-        inner_fields = _decode_fields(inner)
-        assert _get_first(inner_fields, 1) == _REALTIME_PROTOCOL_VERSION
-
-    def test_encode_client_frame_subscribe(self):
-        sub = _encode_subscribe_events(retained_room_ids=["r1"])
-        frame = _encode_client_frame_subscribe(sub)
-        fields = _decode_fields(frame)
-        # field 2 = subscribe_events submessage
-        assert 2 in fields
-
-    def test_encode_client_frame_ping(self):
-        ping = _encode_ping()
-        frame = _encode_client_frame_ping(ping)
-        fields = _decode_fields(frame)
-        # field 3 = ping submessage (empty)
-        assert 3 in fields
-
-    def test_client_hello_roundtrip(self):
-        """Encode a client hello, wrap in a frame, decode the frame, decode
-        the inner hello, and verify values match."""
-        original = _encode_client_hello("roundtrip-token")
-        frame = _encode_client_frame_hello(original)
-        decoded_frame = _decode_server_frame  # not for client frames, but
-        # We decode the frame manually
-        frame_fields = _decode_fields(frame)
-        inner_bytes = _get_first(frame_fields, 1)
-        inner_fields = _decode_fields(inner_bytes)
-        assert _get_first(inner_fields, 1) == _REALTIME_PROTOCOL_VERSION
-        token_raw = _get_first(inner_fields, 2)
-        assert token_raw.decode("utf-8") == "roundtrip-token"
-
-
-# ── Protobuf codec: server frame decoding ────────────────────────────────
-
-
-class TestServerFrameDecoding:
-    """Test _decode_server_frame with synthetic frames."""
-
-    def test_decode_server_hello_frame(self):
-        # Build a RealtimeServerHello { protocol_version = 1 }
-        hello_inner = _encode_field_varint(1, 1)
-        # Wrap in RealtimeServerFrame { hello = 1 }
-        frame = _encode_submessage(1, hello_inner)
-        result = _decode_server_frame(frame)
-        assert result["type"] == "hello"
-        assert isinstance(result["data"], bytes)
-
-        # Decode the hello data
-        hello = _decode_server_hello(result["data"])
-        assert hello["protocolVersion"] == 1
-
-    def test_decode_subscribed_frame(self):
-        # Build an empty submessage for field 2 (subscribed)
-        frame = _encode_submessage(2, b"")
-        result = _decode_server_frame(frame)
-        assert result["type"] == "subscribed"
-
-    def test_decode_error_frame(self):
-        # Build RealtimeError { message = "bad", code = 500 }
-        error_inner = _encode_field_string(1, "bad") + _encode_field_varint(2, 500)
-        # Wrap in RealtimeServerFrame { error = 5 }
-        frame = _encode_submessage(5, error_inner)
-        result = _decode_server_frame(frame)
-        assert result["type"] == "error"
-        decoded = _decode_error(result["data"])
-        assert decoded["message"] == "bad"
-        assert decoded["code"] == 500
-
-    def test_decode_pong_frame(self):
-        frame = _encode_submessage(7, b"")
-        result = _decode_server_frame(frame)
-        assert result["type"] == "pong"
-
-    def test_decode_heartbeat_frame(self):
-        frame = _encode_submessage(4, b"")
-        result = _decode_server_frame(frame)
-        assert result["type"] == "heartbeat"
-
-    def test_decode_close_frame(self):
-        close_inner = _encode_field_string(1, "bye")
-        frame = _encode_submessage(6, close_inner)
-        result = _decode_server_frame(frame)
-        assert result["type"] == "close"
-
-    def test_decode_caught_up_frame(self):
-        frame = _encode_submessage(8, b"")
-        result = _decode_server_frame(frame)
-        assert result["type"] == "caught_up"
-
-    def test_decode_unknown_frame(self):
-        # Empty frame → no fields → unknown
-        result = _decode_server_frame(b"")
-        assert result["type"] == "unknown"
-        assert result["data"] is None
-
-
-# ── Protobuf codec: projection event decoding ────────────────────────────
-
-
-class TestProjectionEventDecoding:
-    """Test _decode_projection_event and related decoders."""
-
-    def test_decode_projection_event_basic(self):
-        # Build RealtimeProjectionEvent {
-        #   id = "evt1",
-        #   actor_id = "user1",
-        #   resume_cursor = "cursor1"
-        # }
-        proj = (
-            _encode_field_string(1, "evt1")
-            + _encode_field_string(3, "user1")
-            + _encode_field_string(4, "cursor1")
-        )
-        result = _decode_projection_event(proj)
-        assert result["id"] == "evt1"
-        assert result["actor_id"] == "user1"
-        assert result["resume_cursor"] == "cursor1"
-        assert result["operations"] == []
-
-    def test_decode_projection_event_with_timestamp(self):
-        ts_inner = _encode_field_varint(1, 1700000000)  # seconds
-        proj = (
-            _encode_field_string(1, "evt2")
-            + _encode_submessage(2, ts_inner)
-        )
-        result = _decode_projection_event(proj)
-        assert result["id"] == "evt2"
-        assert "1700000000" not in result["created_at"]  # should be ISO format
-        assert "T" in result["created_at"]  # ISO format has T separator
-
-    def test_decode_projection_event_with_operation(self):
-        # Build a room_timeline_event_upsert operation
-        room_id = _encode_field_string(1, "room123")
-        # Build a minimal RoomTimelineEvent
-        event_inner = _encode_field_string(1, "evt456")
-        event_upsert = room_id + _encode_submessage(2, event_inner)
-        # Wrap in RealtimeProjectionOperation { room_timeline_event_upsert = 10 }
-        op = _encode_submessage(10, event_upsert)
-        # Wrap in RealtimeProjectionEvent { operations = 5 }
-        proj = _encode_field_string(1, "proj1") + _encode_submessage(5, op)
-
-        result = _decode_projection_event(proj)
-        assert len(result["operations"]) == 1
-        op_result = result["operations"][0]
-        assert op_result["type"] == "room_timeline_event_upsert"
-        assert op_result["room_id"] == "room123"
-        assert op_result["event"]["id"] == "evt456"
-
-    def test_decode_projection_operation_unknown(self):
-        # An operation with field 1 (room_upsert) — not handled specifically
-        op = _encode_submessage(1, _encode_field_string(1, "room1"))
-        result = _decode_projection_operation(op)
-        assert result["type"] == "room_upsert"
-
-    def test_decode_projection_operation_empty(self):
-        result = _decode_projection_operation(b"")
-        assert result["type"] == "empty"
-
-
-# ── Protobuf codec: room timeline event decoding ─────────────────────────
-
-
-class TestRoomTimelineEventDecoding:
-    """Test _decode_room_timeline_event and _decode_message."""
-
-    def test_decode_room_timeline_event_basic(self):
-        # Build RoomTimelineEvent {
-        #   id = "evt1", room_id = "room1", kind = 1 (message_posted)
-        # }
-        ts_inner = _encode_field_varint(1, 1700000000)
-        event = (
-            _encode_field_string(1, "evt1")
-            + _encode_submessage(2, ts_inner)
-            + _encode_field_string(3, "room1")
-            + _encode_field_varint(4, 1)
-        )
-        result = _decode_room_timeline_event(event)
-        assert result["id"] == "evt1"
-        assert result["roomId"] == "room1"
-        assert result["kind"] == 1
-        assert "T" in result["createdAt"]
-
-    def test_decode_room_timeline_event_with_message(self):
-        # Build a Message { id="m1", room_id="r1", actor_id="u1", body="hello" }
-        ts_inner = _encode_field_varint(1, 1700000000)
-        msg = (
-            _encode_field_string(1, "m1")
-            + _encode_field_string(2, "r1")
-            + _encode_field_string(3, "u1")
-            + _encode_field_string(4, "hello")
-            + _encode_submessage(5, ts_inner)
-        )
-        # Wrap in MessagePosted { message = 1 }
-        posted = _encode_submessage(1, msg)
-        # Wrap in RoomTimelineEvent { message_posted = 5 }
-        event = (
-            _encode_field_string(1, "evt1")
-            + _encode_field_string(3, "r1")
-            + _encode_submessage(5, posted)
-        )
-        result = _decode_room_timeline_event(event)
-        assert result["id"] == "evt1"
-        assert result["messagePosted"] is not None
-        assert result["messagePosted"]["message"]["id"] == "m1"
-        assert result["messagePosted"]["message"]["body"] == "hello"
-        assert result["messagePosted"]["message"]["actorId"] == "u1"
-
-    def test_decode_message_with_thread(self):
-        # Build Message with a thread
-        thread_inner = _encode_field_string(1, "thread-root-123")
-        msg = (
-            _encode_field_string(1, "m1")
-            + _encode_field_string(2, "r1")
-            + _encode_field_string(3, "u1")
-            + _encode_field_string(4, "threaded reply")
-            + _encode_submessage(8, thread_inner)
-        )
-        result = _decode_message(msg)
-        assert result["id"] == "m1"
-        assert result["body"] == "threaded reply"
-        assert result["thread"]["threadRootEventId"] == "thread-root-123"
-
-    def test_decode_message_with_login_and_display(self):
-        msg = (
-            _encode_field_string(1, "m1")
-            + _encode_field_string(2, "r1")
-            + _encode_field_string(3, "u1")
-            + _encode_field_string(4, "hi")
-            + _encode_field_string(6, "alice")
-            + _encode_field_string(7, "Alice Smith")
-        )
-        result = _decode_message(msg)
-        assert result["actorLogin"] == "alice"
-        assert result["actorDisplayName"] == "Alice Smith"
-
-    def test_decode_thread(self):
-        thread = _encode_field_string(1, "thread-abc")
-        result = _decode_thread(thread)
-        assert result["threadRootEventId"] == "thread-abc"
-
-    def test_decode_thread_empty(self):
-        result = _decode_thread(b"")
-        assert result["threadRootEventId"] == ""
-
-    def test_decode_message_posted_empty(self):
-        result = _decode_message_posted(b"")
-        assert result == {"message": {}}
-
-
-# ── Protobuf codec: timestamp decoding ────────────────────────────────────
-
-
-class TestTimestampDecoding:
-    """Test _decode_timestamp."""
-
-    def test_decode_timestamp_basic(self):
-        ts = _encode_field_varint(1, 1700000000)
-        result = _decode_timestamp(ts)
-        assert "2023" in result  # Nov 14, 2023
-        assert result.endswith("Z")
-
-    def test_decode_timestamp_with_nanos(self):
-        ts = _encode_field_varint(1, 1700000000) + _encode_field_varint(2, 500000)
-        result = _decode_timestamp(ts)
-        assert "2023" in result
-        # 500000 nanos = 0.5 seconds → microsecond=500000 → ".000500" in ISO
-        assert ".000500" in result
-
-    def test_decode_timestamp_zero(self):
-        ts = b""
-        result = _decode_timestamp(ts)
-        assert result == ""
-
-    def test_decode_timestamp_only_nanos(self):
-        # No seconds, just nanos — should return "" since seconds == 0
-        ts = _encode_field_varint(2, 1000000)
-        result = _decode_timestamp(ts)
-        assert result == ""
-
-
-# ── Protobuf codec: event envelope (transient events) ────────────────────
-
-
-class TestEventEnvelopeDecoding:
-    """Test _decode_event_envelope for mention and DM notifications."""
-
-    def test_decode_mention_notification(self):
-        # Build MentionNotification { room_id = "r1", event_id = "e1" }
-        mention_inner = _encode_field_string(1, "r1") + _encode_field_string(2, "e1")
-        # Wrap in RealtimeEventEnvelope { mention_notification = 88 }
-        envelope = (
-            _encode_field_string(1, "envelope1")
-            + _encode_submessage(88, mention_inner)
-        )
-        result = _decode_event_envelope(envelope)
-        assert result["id"] == "envelope1"
-        assert result["type"] == "mention_notification"
-        assert result["data"]["roomId"] == "r1"
-        assert result["data"]["eventId"] == "e1"
-
-    def test_decode_dm_notification(self):
-        # Build NewDirectMessageNotification { room_id = "r2", event_id = "e2" }
-        dm_inner = _encode_field_string(1, "r2") + _encode_field_string(2, "e2")
-        # Wrap in RealtimeEventEnvelope { new_direct_message_notification = 89 }
-        envelope = (
-            _encode_field_string(1, "envelope2")
-            + _encode_submessage(89, dm_inner)
-        )
-        result = _decode_event_envelope(envelope)
-        assert result["id"] == "envelope2"
-        assert result["type"] == "new_direct_message_notification"
-        assert result["data"]["roomId"] == "r2"
-        assert result["data"]["eventId"] == "e2"
-
-    def test_decode_event_envelope_unknown(self):
-        envelope = _encode_field_string(1, "env3")
-        result = _decode_event_envelope(envelope)
-        assert result["id"] == "env3"
-        assert result["type"] == "unknown"
-        assert result["data"] == {}
-
-    def test_decode_mention_notification_directly(self):
-        mention_inner = _encode_field_string(1, "roomX") + _encode_field_string(2, "evtX")
-        result = _decode_mention_notification(mention_inner)
-        assert result["roomId"] == "roomX"
-        assert result["eventId"] == "evtX"
-
-    def test_decode_dm_notification_directly(self):
-        dm_inner = _encode_field_string(1, "roomY") + _encode_field_string(2, "evtY")
-        result = _decode_dm_notification(dm_inner)
-        assert result["roomId"] == "roomY"
-        assert result["eventId"] == "evtY"
-
-
-# ── Emoji shortcode conversion ───────────────────────────────────────────
-
+# -- Emoji shortcode conversion --
 
 class TestEmojiShortcode:
-    """Test _emoji_to_shortcode static method and the emoji mapping."""
-
-    @pytest.mark.parametrize(
-        "emoji,shortcode",
-        [
-            ("👍", "thumbsup"),
-            ("👎", "thumbsdown"),
-            ("❤️", "heart"),
-            ("❤", "heart"),
-            ("✅", "white_check_mark"),
-            ("❌", "x"),
-            ("👀", "eyes"),
-            ("🎉", "tada"),
-            ("😂", "joy"),
-            ("🚀", "rocket"),
-            ("🔥", "fire"),
-            ("💯", "100"),
-            ("🤔", "thinking"),
-            ("👏", "clap"),
-            ("🙏", "pray"),
-            ("😅", "sweat_smile"),
-            ("😴", "sleeping"),
-            ("⏳", "hourglass"),
-        ],
-    )
-    def test_known_emoji_to_shortcode(self, emoji, shortcode):
-        assert ChattoAdapter._emoji_to_shortcode(emoji) == shortcode
-
-    def test_unknown_emoji_passes_through(self):
-        # 🦀 (crab) is not in the mapping
-        assert ChattoAdapter._emoji_to_shortcode("🦀") == "🦀"
-
-    def test_shortcode_passes_through(self):
-        assert ChattoAdapter._emoji_to_shortcode("thumbsup") == "thumbsup"
-
-    def test_empty_string_passes_through(self):
-        assert ChattoAdapter._emoji_to_shortcode("") == ""
-
-    def test_emoji_mapping_completeness(self):
-        """Verify the mapping dict has the expected keys."""
-        assert "👍" in _EMOJI_TO_SHORTCODE
-        assert _EMOJI_TO_SHORTCODE["👍"] == "thumbsup"
-        assert _EMOJI_TO_SHORTCODE["❤️"] == "heart"
-
-
-# ── Adapter instantiation and properties ──────────────────────────────────
+    """Test emoji to shortcode mapping."""
 
+    def test_emoji_to_shortcode_exists(self):
+        assert isinstance(_EMOJI_TO_SHORTCODE, dict)
+        assert len(_EMOJI_TO_SHORTCODE) > 0
 
-class TestAdapterInstantiation:
-    """Test ChattoAdapter creation and property values."""
+    def test_emoji_to_shortcode_common_emojis(self):
+        assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup"
+        assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown"
+        assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart"
+        assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
+        assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
+        assert _EMOJI_TO_SHORTCODE.get("❌") == "x"
 
-    def test_platform_name(self):
-        adapter = _make_adapter()
-        assert adapter.platform_name == "chatto"
 
-    def test_supports_markdown(self):
-        adapter = _make_adapter()
-        assert adapter.supports_markdown is True
+# -- Adapter instantiation and properties --
 
-    def test_supports_reactions(self):
-        adapter = _make_adapter()
-        assert adapter.supports_reactions is True
+class TestAdapterInstantiation:
+    """Test ChattoAdapter creation and basic properties."""
 
-    def test_supports_threads(self):
-        adapter = _make_adapter()
-        assert adapter.supports_threads is True
+    def test_adapter_creation(self):
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        assert adapter is not None
+        assert adapter.platform.name == "chatto"
 
-    def test_max_message_length(self):
-        adapter = _make_adapter()
-        assert adapter.MAX_MESSAGE_LENGTH == 10000
+    def test_adapter_max_message_length(self):
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
 
-    def test_splits_long_messages(self):
-        adapter = _make_adapter()
+    def test_adapter_splits_long_messages(self):
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
         assert adapter.splits_long_messages is True
 
-    def test_typing_tasks_empty(self):
-        adapter = _make_adapter()
-        assert adapter._typing_tasks == {}
-
-    def test_user_cache_empty(self):
-        adapter = _make_adapter()
-        assert adapter._user_cache == {}
-
-    def test_base_url_from_extra(self):
-        adapter = _make_adapter()
-        assert adapter._base_url == "https://chat.example.com"
-
-    def test_channels_from_extra(self):
-        adapter = _make_adapter()
-        assert adapter._channel_ids == ["room1"]
-
-    def test_require_mention_default(self):
-        adapter = _make_adapter(require_mention=False)
-        assert adapter._require_mention is False
-
-    def test_is_base_platform_adapter(self):
-        from gateway.platforms.base import BasePlatformAdapter
-
-        adapter = _make_adapter()
-        assert isinstance(adapter, BasePlatformAdapter)
-
+    def test_adapter_supports_threads(self):
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        assert adapter.supports_threads() is True
 
-# ── Registration and requirements ─────────────────────────────────────────
 
+# -- Registration and requirements --
 
 class TestRegistration:
-    """Test plugin registration and requirements checking."""
+    """Test plugin registration."""
 
-    def test_register_calls_register_platform(self):
+    def test_register_called(self):
         ctx = _MockPluginContext()
         register(ctx)
         assert "chatto" in ctx.registered_names
-        kwargs = ctx.registered_kwargs
-        assert kwargs["name"] == "chatto"
-        assert kwargs["label"] == "Chatto"
-        assert callable(kwargs["adapter_factory"])
-        assert callable(kwargs["check_fn"])
-
-    def test_register_adapter_factory_creates_adapter(self):
-        ctx = _MockPluginContext()
-        register(ctx)
-        kwargs = ctx.registered_kwargs
-        cfg = _make_config()
-        adapter = kwargs["adapter_factory"](cfg)
-        assert adapter is not None
-        assert isinstance(adapter, ChattoAdapter)
-
-    def test_check_requirements_true_when_env_set(self, monkeypatch):
-        monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
-        monkeypatch.setenv("CHATTO_LOGIN", "user")
-        monkeypatch.setenv("CHATTO_PASSWORD", "pass")
+        assert ctx.registered_kwargs["name"] == "chatto"
+        assert ctx.registered_kwargs["label"] == "Chatto"
+
+    def test_check_requirements(self):
+        _clear_chatto_env()
+        os.environ["CHATTO_URL"] = "https://chat.example.com"
+        os.environ["CHATTO_LOGIN"] = "user"
+        os.environ["CHATTO_PASSWORD"] = "pass"
         assert check_requirements() is True
+        _clear_chatto_env()
 
-    def test_check_requirements_false_when_url_missing(self, monkeypatch):
-        monkeypatch.delenv("CHATTO_URL", raising=False)
-        monkeypatch.setenv("CHATTO_LOGIN", "user")
-        monkeypatch.setenv("CHATTO_PASSWORD", "pass")
-        assert check_requirements() is False
-
-    def test_check_requirements_false_when_login_missing(self, monkeypatch):
-        monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
-        monkeypatch.delenv("CHATTO_LOGIN", raising=False)
-        monkeypatch.setenv("CHATTO_PASSWORD", "pass")
-        assert check_requirements() is False
-
-    def test_check_requirements_false_when_password_missing(self, monkeypatch):
-        monkeypatch.setenv("CHATTO_URL", "https://chat.example.com")
-        monkeypatch.setenv("CHATTO_LOGIN", "user")
-        monkeypatch.delenv("CHATTO_PASSWORD", raising=False)
-        assert check_requirements() is False
-
-    def test_check_requirements_false_all_missing(self, monkeypatch):
-        monkeypatch.delenv("CHATTO_URL", raising=False)
-        monkeypatch.delenv("CHATTO_LOGIN", raising=False)
-        monkeypatch.delenv("CHATTO_PASSWORD", raising=False)
+    def test_check_requirements_missing(self):
+        _clear_chatto_env()
         assert check_requirements() is False
 
-    def test_validate_config_with_extra_url(self):
-        cfg = MagicMock()
-        cfg.extra = {"url": "https://chat.example.com"}
-        # Also need login/password from env
-        with patch.dict(os.environ, {"CHATTO_LOGIN": "u", "CHATTO_PASSWORD": "p"}):
-            assert validate_config(cfg) is True
+    def test_validate_config(self):
+        _clear_chatto_env()
+        os.environ["CHATTO_URL"] = "https://chat.test"
+        os.environ["CHATTO_LOGIN"] = "user"
+        os.environ["CHATTO_PASSWORD"] = "pass"
+        cfg = PlatformConfig(enabled=True, extra={"url": "https://chat.test"})
+        assert validate_config(cfg) is True
+        _clear_chatto_env()
 
-    def test_validate_config_missing_url(self):
-        cfg = MagicMock()
-        cfg.extra = {}
-        with patch.dict(os.environ, {"CHATTO_LOGIN": "u", "CHATTO_PASSWORD": "p"}, clear=False):
-            # Remove CHATTO_URL if set
-            os.environ.pop("CHATTO_URL", None)
-            assert validate_config(cfg) is False
-
-
-# ── Send method (mocked RPC) ──────────────────────────────────────────────
 
+# -- Send functionality --
 
 class TestSend:
-    """Test the send() method with mocked _rpc."""
+    """Test message sending functionality."""
 
-    @pytest.mark.asyncio
-    async def test_send_basic(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt123"}}))
-        result = await adapter.send("room1", "Hello world")
+    @pytest_asyncio.fixture
+    def adapter(self):
+        _clear_chatto_env()
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        adapter._chatto_client = MagicMock()
+        adapter._chatto_client.post_message = AsyncMock()
+        adapter._token = "test-token"
+        adapter._user_id = "bot-user-id"
+        return adapter
+
+    async def test_send_calls_post_message(self, adapter):
+        mock_msg = MagicMock()
+        mock_msg.id = "msg-123"
+        adapter._chatto_client.post_message.return_value = mock_msg
+        result = await adapter.send("room-1", "Hello world")
         assert result.success is True
-        assert result.message_id == "evt123"
-
-    @pytest.mark.asyncio
-    async def test_send_with_reply_to(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt456"}}))
-        adapter._follow_thread = AsyncMock()
-        await adapter.send("room1", "Reply", reply_to="thread-root-1")
-        # Verify RPC body contains threadRootEventId
-        call_args = adapter._rpc.call_args
-        body = call_args.kwargs.get("body") or call_args.args[1]
-        assert body["threadRootEventId"] == "thread-root-1"
-
-    @pytest.mark.asyncio
-    async def test_send_with_metadata_thread_id(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "evt789"}}))
-        adapter._follow_thread = AsyncMock()
-        await adapter.send("room1", "Reply", metadata={"thread_id": "thread456"})
-        call_args = adapter._rpc.call_args
-        body = call_args.kwargs.get("body") or call_args.args[1]
-        assert body["threadRootEventId"] == "thread456"
-
-    @pytest.mark.asyncio
-    async def test_send_empty_content(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock()
-        result = await adapter.send("room1", "")
-        assert result.success is False
-        assert "Empty" in (result.error or "")
-        adapter._rpc.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_send_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {"error": "server error"}))
-        result = await adapter.send("room1", "Hello")
-        assert result.success is False
-        assert "server error" in (result.error or "")
-
-    @pytest.mark.asyncio
-    async def test_send_long_message_split(self):
-        adapter = _make_adapter()
-        # Return different message IDs for each call
-        call_count = [0]
-
-        async def mock_rpc(path, body, **kwargs):
-            call_count[0] += 1
-            return 200, {"message": {"id": f"evt-{call_count[0]}"}}
-
-        adapter._rpc = AsyncMock(side_effect=mock_rpc)
-        adapter._follow_thread = AsyncMock()
-        # Create content longer than MAX_MESSAGE_LENGTH (10000)
-        long_content = "A" * 12000
-        result = await adapter.send("room1", long_content)
+        assert result.message_id == "msg-123"
+        adapter._chatto_client.post_message.assert_called_once()
+
+    async def test_send_with_thread(self, adapter):
+        mock_msg = MagicMock()
+        mock_msg.id = "msg-456"
+        adapter._chatto_client.post_message.return_value = mock_msg
+        result = await adapter.send("room-1", "Hello", reply_to="thread-123")
         assert result.success is True
-        assert result.message_id == "evt-1"
-        # Should have been called multiple times
-        assert adapter._rpc.call_count > 1
+        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
+        assert call_kwargs["thread_root_event_id"] == "thread-123"
 
-    @pytest.mark.asyncio
-    async def test_send_marks_seen(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"message": {"id": "new_evt"}}))
-        await adapter.send("room1", "Hello")
-        assert "new_evt" in adapter._seen.get("room1", {})
 
+# -- Reactions --
 
-# ── Reactions (mocked RPC) ────────────────────────────────────────────────
+class TestReactions:
+    """Test reaction functionality."""
 
+    @pytest_asyncio.fixture
+    def adapter(self):
+        _clear_chatto_env()
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        adapter._chatto_client = MagicMock()
+        adapter._chatto_client.add_reaction = AsyncMock()
+        adapter._chatto_client.remove_reaction = AsyncMock()
+        adapter._token = "test-token"
+        return adapter
 
-class TestReactions:
-    """Test send_reaction and remove_reaction."""
+    async def test_send_reaction(self, adapter):
+        await adapter.send_reaction("room-1", "msg-1", "👍")
+        adapter._chatto_client.add_reaction.assert_called_once()
 
-    @pytest.mark.asyncio
-    async def test_send_reaction_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"added": True}))
-        result = await adapter.send_reaction("room1", "evt1", "👍")
-        assert result is True
-        # Verify emoji was converted to shortcode
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["emoji"] == "thumbsup"
-
-    @pytest.mark.asyncio
-    async def test_send_reaction_heart(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        await adapter.send_reaction("room1", "evt1", "❤️")
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["emoji"] == "heart"
-
-    @pytest.mark.asyncio
-    async def test_remove_reaction_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.remove_reaction("room1", "evt1", "❤️")
-        assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["emoji"] == "heart"
-
-    @pytest.mark.asyncio
-    async def test_send_reaction_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.send_reaction("room1", "evt1", "👍")
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_remove_reaction_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(404, {}))
-        result = await adapter.remove_reaction("room1", "evt1", "❤️")
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_send_reaction_exception(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(side_effect=Exception("network error"))
-        result = await adapter.send_reaction("room1", "evt1", "👍")
-        assert result is False
-
-
-# ── Message edit/delete (mocked RPC) ──────────────────────────────────────
-
-
-class TestEditDelete:
-    """Test edit_message and delete_message."""
-
-    @pytest.mark.asyncio
-    async def test_edit_message_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.edit_message("room1", "evt1", "new text")
-        assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["body"] == "new text"
-        assert body["eventId"] == "evt1"
-
-    @pytest.mark.asyncio
-    async def test_edit_message_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(404, {}))
-        result = await adapter.edit_message("room1", "evt1", "new text")
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_delete_message_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.delete_message("room1", "evt1")
-        assert result is True
+    async def test_remove_reaction(self, adapter):
+        await adapter.remove_reaction("room-1", "msg-1", "👍")
+        adapter._chatto_client.remove_reaction.assert_called_once()
 
-    @pytest.mark.asyncio
-    async def test_delete_message_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(404, {}))
-        result = await adapter.delete_message("room1", "evt1")
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_edit_message_exception(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(side_effect=Exception("boom"))
-        result = await adapter.edit_message("room1", "evt1", "text")
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_delete_message_exception(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(side_effect=Exception("boom"))
-        result = await adapter.delete_message("room1", "evt1")
-        assert result is False
-
-
-# ── Typing indicator lifecycle ─────────────────────────────────────────────
-
-
-class TestTypingIndicator:
-    """Test send_typing and stop_typing lifecycle."""
-
-    @pytest.mark.asyncio
-    async def test_send_typing_creates_task(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        await adapter.send_typing("room1")
-        assert "room1" in adapter._typing_tasks
-        assert isinstance(adapter._typing_tasks["room1"], asyncio.Task)
-        # Clean up
-        await adapter.stop_typing("room1")
-
-    @pytest.mark.asyncio
-    async def test_send_typing_no_duplicate(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        await adapter.send_typing("room1")
-        first_task = adapter._typing_tasks["room1"]
-        await adapter.send_typing("room1")
-        assert adapter._typing_tasks["room1"] is first_task
-        await adapter.stop_typing("room1")
-
-    @pytest.mark.asyncio
-    async def test_stop_typing_cancels_task(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        await adapter.send_typing("room1")
-        assert "room1" in adapter._typing_tasks
-        await adapter.stop_typing("room1")
-        assert "room1" not in adapter._typing_tasks
-
-    @pytest.mark.asyncio
-    async def test_stop_typing_when_not_running(self):
-        adapter = _make_adapter()
-        # Should not raise even if no task exists
-        await adapter.stop_typing("room1")
-
-    @pytest.mark.asyncio
-    async def test_typing_loop_calls_rpc(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        await adapter.send_typing("room1")
-        # Allow the loop to run one iteration
-        await asyncio.sleep(0.05)
-        # The typing loop should have called _rpc at least once
-        assert adapter._rpc.call_count >= 1
-        # Verify it was called with the typing path
-        first_call = adapter._rpc.call_args_list[0]
-        path = first_call.args[0] if len(first_call.args) > 0 else first_call.kwargs.get("path")
-        assert "UpdateTypingIndicator" in path
-        body = first_call.args[1] if len(first_call.args) > 1 else first_call.kwargs.get("body")
-        assert body["roomId"] == "room1"
-        assert body["typing"] is True
-        await adapter.stop_typing("room1")
-
-
-# ── Read state and notifications (mocked RPC) ─────────────────────────────
-
-
-class TestReadStateAndNotifications:
-    """Test mark_room_as_read, mark_thread_as_read, dismiss notifications."""
-
-    @pytest.mark.asyncio
-    async def test_mark_room_as_read_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.mark_room_as_read("room1")
-        assert result is True
 
-    @pytest.mark.asyncio
-    async def test_mark_room_as_read_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.mark_room_as_read("room1")
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_mark_thread_as_read_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.mark_thread_as_read("room1", "thread-root-1")
-        assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["roomId"] == "room1"
-        assert body["threadRootEventId"] == "thread-root-1"
-
-    @pytest.mark.asyncio
-    async def test_mark_thread_as_read_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(404, {}))
-        result = await adapter.mark_thread_as_read("room1", "thread-root-1")
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_dismiss_all_notifications_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.dismiss_all_notifications()
+# -- Edit and Delete Messages --
+
+class TestMessageEditing:
+    """Test message editing and deletion."""
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        _clear_chatto_env()
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        adapter._chatto_client = MagicMock()
+        adapter._chatto_client.update_message = AsyncMock()
+        adapter._chatto_client.delete_message = AsyncMock(return_value=True)
+        adapter._token = "test-token"
+        return adapter
+
+    async def test_edit_message(self, adapter):
+        result = await adapter.edit_message("room-1", "msg-1", "New content")
         assert result is True
+        adapter._chatto_client.update_message.assert_called_once()
 
-    @pytest.mark.asyncio
-    async def test_dismiss_all_notifications_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.dismiss_all_notifications()
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_dismiss_notification_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.dismiss_notification("notif123")
+    async def test_delete_message(self, adapter):
+        result = await adapter.delete_message("room-1", "msg-1")
         assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["notificationId"] == "notif123"
-
-    @pytest.mark.asyncio
-    async def test_dismiss_notification_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(404, {}))
-        result = await adapter.dismiss_notification("notif123")
-        assert result is False
-
-
-# ── DM initiation and room creation (mocked RPC) ──────────────────────────
-
-
-class TestDMAndRoomCreation:
-    """Test start_dm and create_room."""
-
-    @pytest.mark.asyncio
-    async def test_start_dm_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "room789"}}))
-        result = await adapter.start_dm("user123")
-        assert result == "room789"
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["participantIds"] == ["user123"]
-
-    @pytest.mark.asyncio
-    async def test_start_dm_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.start_dm("user123")
-        assert result is None
-
-    @pytest.mark.asyncio
-    async def test_start_dm_no_room_id(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"room": {}}))
-        result = await adapter.start_dm("user123")
-        assert result is None
-
-    @pytest.mark.asyncio
-    async def test_start_dm_sets_room_kind(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "dm-room-1"}}))
-        await adapter.start_dm("user123")
-        assert adapter._room_kinds["dm-room-1"] == "ROOM_KIND_DM"
-
-    @pytest.mark.asyncio
-    async def test_create_room_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "room789"}}))
-        result = await adapter.create_room("test-room", "description")
-        assert result == "room789"
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["name"] == "test-room"
-        assert body["description"] == "description"
-
-    @pytest.mark.asyncio
-    async def test_create_room_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.create_room("test-room", "description")
-        assert result is None
-
-    @pytest.mark.asyncio
-    async def test_create_room_sets_room_kind(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {"room": {"id": "group-room-1"}}))
-        await adapter.create_room("test-room")
-        assert adapter._room_kinds["group-room-1"] == "ROOM_KIND_GROUP"
-        assert adapter._room_names["group-room-1"] == "test-room"
-
-
-# ── User lookup (mocked RPC) ──────────────────────────────────────────────
+        adapter._chatto_client.delete_message.assert_called_once()
 
 
+# -- User lookup --
+
 class TestUserLookup:
-    """Test get_user, list_users, batch_get_users with caching."""
+    """Test user lookup functionality."""
 
-    @pytest.mark.asyncio
-    async def test_get_user_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(
-            return_value=(200, {"user": {"id": "u1", "login": "alice", "displayName": "Alice"}})
-        )
-        result = await adapter.get_user("u1")
+    @pytest_asyncio.fixture
+    def adapter(self):
+        _clear_chatto_env()
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        adapter._chatto_client = MagicMock()
+        adapter._token = "test-token"
+        adapter._user_cache = {}
+        return adapter
+
+    async def test_get_user_calls_chattolib(self, adapter):
+        from chattolib.types import User, GetUserResponse
+        mock_user = User(id="user-1", login="testuser", display_name="Test User")
+        adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
+        result = await adapter.get_user("user-1")
         assert result is not None
-        assert result["id"] == "u1"
-        assert result["login"] == "alice"
-
-    @pytest.mark.asyncio
-    async def test_get_user_caches(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(
-            return_value=(200, {"user": {"id": "u1", "login": "alice"}})
-        )
-        await adapter.get_user("u1")
-        # Second call should use cache — no second RPC
-        await adapter.get_user("u1")
-        assert adapter._rpc.call_count == 1
-        assert "u1" in adapter._user_cache
-
-    @pytest.mark.asyncio
-    async def test_get_user_empty_id(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock()
-        result = await adapter.get_user("")
-        assert result is None
-        adapter._rpc.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_get_user_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(404, {}))
-        result = await adapter.get_user("u1")
-        assert result is None
-
-    @pytest.mark.asyncio
-    async def test_get_user_exception(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(side_effect=Exception("boom"))
-        result = await adapter.get_user("u1")
-        assert result is None
-
-    @pytest.mark.asyncio
-    async def test_list_users_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(
-            return_value=(200, {"users": [{"id": "u1"}, {"id": "u2"}]})
-        )
-        result = await adapter.list_users()
-        assert len(result) == 2
-        assert result[0]["id"] == "u1"
-        # Should cache all returned users
-        assert "u1" in adapter._user_cache
-        assert "u2" in adapter._user_cache
-
-    @pytest.mark.asyncio
-    async def test_list_users_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.list_users()
-        assert result == []
-
-    @pytest.mark.asyncio
-    async def test_batch_get_users_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(
-            return_value=(200, {"users": [{"id": "u1"}, {"id": "u2"}]})
-        )
-        result = await adapter.batch_get_users(["u1", "u2"])
-        assert len(result) == 2
-        # Should cache results
-        assert "u1" in adapter._user_cache
-        assert "u2" in adapter._user_cache
-
-    @pytest.mark.asyncio
-    async def test_batch_get_users_empty_list(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock()
-        result = await adapter.batch_get_users([])
-        assert result == []
-        adapter._rpc.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_batch_get_users_uses_cache(self):
-        adapter = _make_adapter()
-        # Pre-populate cache
-        adapter._user_cache["u1"] = {"id": "u1", "login": "alice"}
-        adapter._rpc = AsyncMock(
-            return_value=(200, {"users": [{"id": "u2"}]})
-        )
-        result = await adapter.batch_get_users(["u1", "u2"])
-        assert len(result) == 2
-        # Should have only fetched u2 from server
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["userIds"] == ["u2"]
-
-    @pytest.mark.asyncio
-    async def test_batch_get_users_all_cached(self):
-        adapter = _make_adapter()
-        adapter._user_cache["u1"] = {"id": "u1"}
-        adapter._rpc = AsyncMock()
-        result = await adapter.batch_get_users(["u1"])
-        assert len(result) == 1
-        adapter._rpc.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_batch_get_users_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.batch_get_users(["u1"])
-        assert result == []
-
-
-# ── Presence and custom status (mocked RPC) ──────────────────────────────
-
-
-class TestPresenceAndStatus:
-    """Test set_presence, set_custom_status, clear_custom_status."""
-
-    @pytest.mark.asyncio
-    async def test_set_presence_online(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
+        assert result["id"] == "user-1"
+        assert result["login"] == "testuser"
+
+    async def test_get_user_caching(self, adapter):
+        from chattolib.types import User, GetUserResponse
+        mock_user = User(id="user-1", login="testuser", display_name="Test User")
+        adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
+        result1 = await adapter.get_user("user-1")
+        result2 = await adapter.get_user("user-1")
+        assert result1 == result2
+        assert adapter._chatto_client.get_user.call_count == 1
+
+
+# -- Presence and Custom Status --
+
+class TestPresence:
+    """Test presence functionality."""
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        _clear_chatto_env()
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        adapter._chatto_client = MagicMock()
+        adapter._chatto_client.update_presence = AsyncMock()
+        adapter._token = "test-token"
+        return adapter
+
+    async def test_set_presence(self, adapter):
         result = await adapter.set_presence("online")
         assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["status"] == 1
-
-    @pytest.mark.asyncio
-    async def test_set_presence_dnd(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.set_presence("dnd")
-        assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["status"] == 3
-
-    @pytest.mark.asyncio
-    async def test_set_presence_away(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.set_presence("away")
-        assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["status"] == 2
-
-    @pytest.mark.asyncio
-    async def test_set_presence_do_not_disturb(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.set_presence("do_not_disturb")
-        assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["status"] == 3
-
-    @pytest.mark.asyncio
-    async def test_set_presence_unknown(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        result = await adapter.set_presence("invisible")
-        assert result is False
-        adapter._rpc.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_set_presence_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.set_presence("online")
-        assert result is False
+        adapter._chatto_client.update_presence.assert_called_once()
+
 
-    @pytest.mark.asyncio
-    async def test_set_custom_status_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
+class TestCustomStatus:
+    """Test custom status functionality."""
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        _clear_chatto_env()
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        adapter._chatto_client = MagicMock()
+        adapter._chatto_client.update_custom_status = AsyncMock()
+        adapter._chatto_client.delete_custom_status = AsyncMock()
+        adapter._token = "test-token"
+        return adapter
+
+    async def test_set_custom_status(self, adapter):
         result = await adapter.set_custom_status("Processing...")
         assert result is True
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert body["status"] == "Processing..."
-
-    @pytest.mark.asyncio
-    async def test_set_custom_status_empty(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock()
-        result = await adapter.set_custom_status("")
-        assert result is False
-        adapter._rpc.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_set_custom_status_truncates(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        long_status = "A" * 200
-        await adapter.set_custom_status(long_status)
-        call_args = adapter._rpc.call_args
-        body = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("body")
-        assert len(body["status"]) == 100
-
-    @pytest.mark.asyncio
-    async def test_set_custom_status_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.set_custom_status("test")
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_clear_custom_status_success(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
+        adapter._chatto_client.update_custom_status.assert_called_once()
+
+    async def test_clear_custom_status(self, adapter):
         result = await adapter.clear_custom_status()
         assert result is True
+        adapter._chatto_client.delete_custom_status.assert_called_once()
 
-    @pytest.mark.asyncio
-    async def test_clear_custom_status_failure(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(500, {}))
-        result = await adapter.clear_custom_status()
-        assert result is False
-
-
-# ── Message dispatch (mocked) ─────────────────────────────────────────────
-
-
-class TestMessageDispatch:
-    """Test _dispatch_message with mocked handler.
-
-    The base class ``handle_message`` does complex session management and
-    spawns background tasks, so we mock it to verify _dispatch_message calls
-    it with the right MessageEvent.
-    """
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_calls_handler(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._room_names["room1"] = "General"
-        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
-        adapter._require_mention = False  # don't require mention for this test
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "Hello bot",
-            "createdAt": "",
-            "actorLogin": "alice",
-            "actorDisplayName": "Alice",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        assert adapter.handle_message.called
-        event = adapter.handle_message.call_args.args[0]
-        assert event.text == "Hello bot"
-        assert event.message_id == "evt1"
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_self_echo_suppressed(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "bot-user-id",  # matches adapter._user_id
-            "body": "My own message",
-            "createdAt": "",
-            "actorLogin": "hermes_bot",
-            "actorDisplayName": "Hermes Bot",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        adapter.handle_message.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_empty_body_skipped(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "",
-            "createdAt": "",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        adapter.handle_message.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_no_handler(self):
-        adapter = _make_adapter()
-        adapter._message_handler = None
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        # Should not raise
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "Hello",
-            "createdAt": "",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_mark_room_read_called(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
-        adapter._require_mention = False  # don't require mention for this test
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "Hello",
-            "createdAt": "",
-            "actorLogin": "alice",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        # mark_room_as_read and dismiss_all_notifications should have been called
-        # via _rpc — check for MarkRoomAsRead and DismissAllNotifications paths
-        rpc_paths = [c.args[0] for c in adapter._rpc.call_args_list if len(c.args) > 0]
-        assert any("MarkRoomAsRead" in p for p in rpc_paths)
-        assert any("DismissAllNotifications" in p for p in rpc_paths)
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_dm_always_responds(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._room_kinds["room1"] = "ROOM_KIND_DM"
-        adapter._require_mention = True  # even with require_mention, DMs respond
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "Hello without mention",
-            "createdAt": "",
-            "actorLogin": "alice",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        assert adapter.handle_message.called
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_require_mention_no_mention_skipped(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
-        adapter._require_mention = True
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "Hello without mention",
-            "createdAt": "",
-            "actorLogin": "alice",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        adapter.handle_message.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_with_mention(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
-        adapter._require_mention = True
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "@hermes_bot do something",
-            "createdAt": "",
-            "actorLogin": "alice",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        assert adapter.handle_message.called
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_with_thread(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
-        adapter._require_mention = False  # don't require mention for this test
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "Hello",
-            "createdAt": "",
-            "actorLogin": "alice",
-            "thread": {"threadRootEventId": "thread-root-123"},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        assert adapter.handle_message.called
-        # Verify the event was created with the thread_id
-        event = adapter.handle_message.call_args.args[0]
-        assert event.source.thread_id == "thread-root-123"
-
-    @pytest.mark.asyncio
-    async def test_dispatch_message_strips_mention_prefix(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
-        adapter._require_mention = True
-
-        msg = {
-            "id": "evt1",
-            "roomId": "room1",
-            "actorId": "user1",
-            "body": "@hermes_bot please help",
-            "createdAt": "",
-            "actorLogin": "alice",
-            "thread": {},
-        }
-        await adapter._dispatch_message(msg, "room1")
-        event = adapter.handle_message.call_args.args[0]
-        # The mention prefix should be stripped
-        assert not event.text.startswith("@hermes_bot")
-        assert "please help" in event.text
-
-
-# ── Processing lifecycle hooks ─────────────────────────────────────────────
-
-
-class TestProcessingLifecycle:
-    """Test on_processing_start and on_processing_complete reaction hooks."""
-
-    @pytest.mark.asyncio
-    async def test_on_processing_start_adds_eyes(self):
-        adapter = _make_adapter()
-        adapter.send_reaction = AsyncMock(return_value=True)
-        event = MagicMock()
-        event.message_id = "evt1"
-        event.source = MagicMock()
-        event.source.chat_id = "room1"
-        event.raw_message = {}
-        await adapter.on_processing_start(event)
-        adapter.send_reaction.assert_called_once_with("room1", "evt1", "👀")
-
-    @pytest.mark.asyncio
-    async def test_on_processing_complete_success(self):
-        from gateway.platforms.base import ProcessingOutcome
-
-        adapter = _make_adapter()
-        adapter.send_reaction = AsyncMock(return_value=True)
-        adapter.remove_reaction = AsyncMock(return_value=True)
-        event = MagicMock()
-        event.message_id = "evt1"
-        event.source = MagicMock()
-        event.source.chat_id = "room1"
-        event.raw_message = {}
-        await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
-        adapter.remove_reaction.assert_called_once_with("room1", "evt1", "👀")
-        adapter.send_reaction.assert_called_once_with("room1", "evt1", "✅")
-
-    @pytest.mark.asyncio
-    async def test_on_processing_complete_failure(self):
-        from gateway.platforms.base import ProcessingOutcome
-
-        adapter = _make_adapter()
-        adapter.send_reaction = AsyncMock(return_value=True)
-        adapter.remove_reaction = AsyncMock(return_value=True)
-        event = MagicMock()
-        event.message_id = "evt1"
-        event.source = MagicMock()
-        event.source.chat_id = "room1"
-        event.raw_message = {}
-        await adapter.on_processing_complete(event, ProcessingOutcome.FAILURE)
-        adapter.send_reaction.assert_called_once_with("room1", "evt1", "❌")
-
-    @pytest.mark.asyncio
-    async def test_reactions_disabled(self, monkeypatch):
-        adapter = _make_adapter()
-        monkeypatch.setenv("CHATTO_REACTIONS", "false")
-        adapter.send_reaction = AsyncMock()
-        event = MagicMock()
-        event.message_id = "evt1"
-        event.source = MagicMock()
-        event.source.chat_id = "room1"
-        event.raw_message = {}
-        await adapter.on_processing_start(event)
-        adapter.send_reaction.assert_not_called()
-
-
-# ── Attachment upload (mocked RPC) ────────────────────────────────────────
-
-
-class TestAttachmentUpload:
-    """Test the chunked asset upload flow."""
-
-    @pytest.mark.asyncio
-    async def test_upload_asset_success(self):
-        adapter = _make_adapter()
-
-        # Create a temporary file
-        with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
-            f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
-            tmp_path = f.name
-
-        try:
-            rpc_responses = [
-                (200, {"upload": {"id": "upload-1"}}),  # CreateUpload
-                (200, {}),  # UploadChunk
-                (200, {"asset": {"id": "asset-1"}}),  # CompleteUpload
-            ]
-            adapter._rpc = AsyncMock(side_effect=rpc_responses)
-
-            asset_id = await adapter._upload_asset("room1", tmp_path)
-            assert asset_id == "asset-1"
-            assert adapter._rpc.call_count == 3
-
-            # Verify the first call was CreateUpload
-            first_call = adapter._rpc.call_args_list[0]
-            path = first_call.args[0]
-            assert "CreateUpload" in path
-            body = first_call.args[1]
-            assert body["roomId"] == "room1"
-            assert "sha256" in body
-
-            # Verify last call was CompleteUpload
-            last_call = adapter._rpc.call_args_list[-1]
-            path = last_call.args[0]
-            assert "CompleteUpload" in path
-        finally:
-            os.unlink(tmp_path)
-
-    @pytest.mark.asyncio
-    async def test_upload_asset_create_fails(self):
-        adapter = _make_adapter()
-
-        with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
-            f.write(b"test data")
-            tmp_path = f.name
-
-        try:
-            adapter._rpc = AsyncMock(return_value=(500, {"error": "fail"}))
-            result = await adapter._upload_asset("room1", tmp_path)
-            assert result is None
-        finally:
-            os.unlink(tmp_path)
-
-    @pytest.mark.asyncio
-    async def test_upload_asset_empty_file(self):
-        adapter = _make_adapter()
-
-        with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
-            f.write(b"")
-            tmp_path = f.name
-
-        try:
-            adapter._rpc = AsyncMock()
-            result = await adapter._upload_asset("room1", tmp_path)
-            assert result is None
-            adapter._rpc.assert_not_called()
-        finally:
-            os.unlink(tmp_path)
-
-    @pytest.mark.asyncio
-    async def test_send_image_file_success(self):
-        adapter = _make_adapter()
-
-        with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
-            f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
-            tmp_path = f.name
-
-        try:
-            rpc_responses = [
-                (200, {"upload": {"id": "upload-1"}}),  # CreateUpload
-                (200, {}),  # UploadChunk
-                (200, {"asset": {"id": "asset-1"}}),  # CompleteUpload
-                (200, {"message": {"id": "msg-1"}}),  # CreateMessage
-            ]
-            adapter._rpc = AsyncMock(side_effect=rpc_responses)
-
-            result = await adapter.send_image_file("room1", tmp_path, caption="Test image")
-            assert result.success is True
-            assert result.message_id == "msg-1"
-
-            # Verify the CreateMessage call had attachmentAssetIds
-            create_msg_call = adapter._rpc.call_args_list[-1]
-            body = create_msg_call.args[1]
-            assert body["attachmentAssetIds"] == ["asset-1"]
-            assert body["body"] == "Test image"
-        finally:
-            os.unlink(tmp_path)
-
-    @pytest.mark.asyncio
-    async def test_send_image_file_upload_fails_fallback(self):
-        adapter = _make_adapter()
-
-        with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
-            f.write(b"\x89PNG" + b"\x00" * 100)
-            tmp_path = f.name
-
-        try:
-            # Upload fails, then fallback send succeeds
-            rpc_responses = [
-                (500, {"error": "fail"}),  # CreateUpload fails
-                (200, {"message": {"id": "fallback-msg"}}),  # Fallback send
-            ]
-            adapter._rpc = AsyncMock(side_effect=rpc_responses)
-
-            result = await adapter.send_image_file("room1", tmp_path)
-            assert result.success is True
-            assert result.message_id == "fallback-msg"
-        finally:
-            os.unlink(tmp_path)
-
-
-# ── Seen tracking ─────────────────────────────────────────────────────────
-
-
-class TestSeenTracking:
-    """Test _mark_seen, _is_seen, and the _SEEN_CAP."""
-
-    def test_mark_and_check_seen(self):
-        adapter = _make_adapter()
-        adapter._mark_seen("room1", "evt1")
-        assert adapter._is_seen("room1", "evt1") is True
-        assert adapter._is_seen("room1", "evt2") is False
-        assert adapter._is_seen("room2", "evt1") is False
-
-    def test_mark_seen_evicts_old_beyond_cap(self):
-        adapter = _make_adapter()
-        # Add more than _SEEN_CAP events
-        for i in range(_SEEN_CAP + 10):
-            adapter._mark_seen("room1", f"evt{i}")
-        # The first events should have been evicted
-        assert adapter._is_seen("room1", "evt0") is False
-        # The most recent should still be there
-        assert adapter._is_seen("room1", f"evt{_SEEN_CAP + 9}") is True
-        # Total should not exceed cap
-        assert len(adapter._seen["room1"]) <= _SEEN_CAP
-
-
-# ── WebSocket URL building ────────────────────────────────────────────────
-
-
-class TestWebSocketURL:
-    """Test _websocket_url conversion."""
-
-    def test_https_to_wss(self):
-        adapter = _make_adapter()
-        adapter._base_url = "https://chat.example.com"
-        url = adapter._websocket_url()
-        assert url.startswith("wss://")
-        assert "/api/realtime" in url
-
-    def test_http_to_ws(self):
-        adapter = _make_adapter()
-        adapter._base_url = "http://localhost:8080"
-        url = adapter._websocket_url()
-        assert url.startswith("ws://")
-        assert "/api/realtime" in url
-
-    def test_websocket_url_with_path(self):
-        adapter = _make_adapter()
-        adapter._base_url = "https://chat.example.com/subpath"
-        url = adapter._websocket_url()
-        assert "/subpath/api/realtime" in url
-
-    def test_websocket_url_invalid_scheme(self):
-        adapter = _make_adapter()
-        adapter._base_url = "ftp://chat.example.com"
-        with pytest.raises(ValueError, match="must use http"):
-            adapter._websocket_url()
-
-
-# ── Get chat info ─────────────────────────────────────────────────────────
-
-
-class TestGetChatInfo:
-    """Test get_chat_info."""
-
-    @pytest.mark.asyncio
-    async def test_get_chat_info_group(self):
-        adapter = _make_adapter()
-        adapter._room_names["room1"] = "General"
-        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
-        info = await adapter.get_chat_info("room1")
-        assert info["name"] == "General"
-        assert info["type"] == "group"
-
-    @pytest.mark.asyncio
-    async def test_get_chat_info_dm(self):
-        adapter = _make_adapter()
-        adapter._room_names["room2"] = "Alice"
-        adapter._room_kinds["room2"] = "ROOM_KIND_DM"
-        info = await adapter.get_chat_info("room2")
-        assert info["name"] == "Alice"
-        assert info["type"] == "dm"
-
-    @pytest.mark.asyncio
-    async def test_get_chat_info_unknown_room(self):
-        adapter = _make_adapter()
-        info = await adapter.get_chat_info("unknown-room")
-        assert info["name"] == "unknown-room"
-        assert info["type"] == "group"  # default
-
-
-# ── Handle projection event ──────────────────────────────────────────────
-
-
-class TestHandleProjectionEvent:
-    """Test _handle_projection_event and _handle_timeline_event_upsert."""
-
-    @pytest.mark.asyncio
-    async def test_handle_projection_event_updates_cursor(self):
-        adapter = _make_adapter()
-        # Build a projection event with a resume cursor
-        proj = _encode_field_string(4, "new-cursor-123")
-        await adapter._handle_projection_event(proj)
-        assert adapter._resume_cursor == "new-cursor-123"
-
-    @pytest.mark.asyncio
-    async def test_handle_projection_event_empty_data(self):
-        adapter = _make_adapter()
-        await adapter._handle_projection_event(b"")
-        # Should not raise, cursor unchanged
-
-    @pytest.mark.asyncio
-    async def test_handle_timeline_event_dedup(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-        adapter.handle_message = AsyncMock()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._room_kinds["room1"] = "ROOM_KIND_GROUP"
-        adapter._require_mention = False  # don't require mention for this test
-
-        op = {
-            "room_id": "room1",
-            "event": {
-                "id": "evt-dedup",
-                "messagePosted": {
-                    "message": {
-                        "id": "m1",
-                        "roomId": "room1",
-                        "actorId": "user1",
-                        "body": "Hello",
-                        "createdAt": "",
-                        "thread": {},
-                    }
-                },
-            },
-        }
-        # First call should dispatch
-        await adapter._handle_timeline_event_upsert(op)
-        assert adapter.handle_message.called
-
-        # Reset mock
-        adapter.handle_message.reset_mock()
-
-        # Second call should be deduped
-        await adapter._handle_timeline_event_upsert(op)
-        adapter.handle_message.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_handle_timeline_event_no_message_posted(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-
-        op = {
-            "room_id": "room1",
-            "event": {
-                "id": "evt1",
-                "messagePosted": None,
-            },
-        }
-        await adapter._handle_timeline_event_upsert(op)
-        adapter._message_handler.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_handle_timeline_event_empty_event(self):
-        adapter = _make_adapter()
-        adapter._message_handler = AsyncMock()
-
-        op = {"room_id": "room1", "event": {}}
-        await adapter._handle_timeline_event_upsert(op)
-        adapter._message_handler.assert_not_called()
-
-
-# ── Handle transient event ────────────────────────────────────────────────
-
-
-class TestHandleTransientEvent:
-    """Test _handle_transient_event."""
-
-    @pytest.mark.asyncio
-    async def test_handle_transient_mention(self):
-        adapter = _make_adapter()
-        # Build a transient event envelope with a mention notification
-        mention_inner = _encode_field_string(1, "room1") + _encode_field_string(2, "evt1")
-        envelope = _encode_field_string(1, "env1") + _encode_submessage(88, mention_inner)
-        # Should not raise
-        await adapter._handle_transient_event(envelope)
-
-    @pytest.mark.asyncio
-    async def test_handle_transient_dm(self):
-        adapter = _make_adapter()
-        dm_inner = _encode_field_string(1, "room2") + _encode_field_string(2, "evt2")
-        envelope = _encode_field_string(1, "env2") + _encode_submessage(89, dm_inner)
-        await adapter._handle_transient_event(envelope)
-
-    @pytest.mark.asyncio
-    async def test_handle_transient_empty(self):
-        adapter = _make_adapter()
-        await adapter._handle_transient_event(b"")
-
-
-# ── Disconnect ────────────────────────────────────────────────────────────
-
-
-class TestDisconnect:
-    """Test disconnect cleanup."""
-
-    @pytest.mark.asyncio
-    async def test_disconnect_cancels_typing_tasks(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._ws_active = False  # avoid websocket teardown
-        adapter._liveness_task = None
-        adapter._ws_task = None
-
-        await adapter.send_typing("room1")
-        assert "room1" in adapter._typing_tasks
-        await adapter.disconnect()
-        assert "room1" not in adapter._typing_tasks
-
-    @pytest.mark.asyncio
-    async def test_disconnect_clears_token(self):
-        adapter = _make_adapter()
-        adapter._rpc = AsyncMock(return_value=(200, {}))
-        adapter._ws_active = False
-        adapter._liveness_task = None
-        adapter._ws_task = None
-        assert adapter._token is not None
-        await adapter.disconnect()
-        assert adapter._token is None
+
+# -- Room operations --
+
+class TestRoomOperations:
+    """Test room creation and DM initiation."""
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        _clear_chatto_env()
+        cfg = _make_config()
+        adapter = ChattoAdapter(cfg)
+        adapter._chatto_client = MagicMock()
+        adapter._token = "test-token"
+        adapter._room_names = {}
+        adapter._room_kinds = {}
+        return adapter
+
+    async def test_create_room(self, adapter):
+        from chattolib.types import Room
+        mock_room = Room(id="room-123", name="Test Room", kind="ROOM_KIND_GROUP",
+                        description="", archived=False, group_id="", universal=True)
+        adapter._chatto_client.create_room.return_value = mock_room
+        result = await adapter.create_room("Test Room", "A test room")
+        assert result == "room-123"
+        adapter._chatto_client.create_room.assert_called_once()
+
+    async def test_start_dm(self, adapter):
+        from chattolib.types import Room
+        mock_room = Room(id="dm-123", name="DM with user", kind="ROOM_KIND_DM",
+                        description="", archived=False, group_id="", universal=False)
+        adapter._chatto_client.start_dm.return_value = mock_room
+        result = await adapter.start_dm("user-123")
+        assert result == "dm-123"
+        adapter._chatto_client.start_dm.assert_called_once()
+
+
+# -- Constants --
+
+class TestConstants:
+    """Test that constants are properly defined."""
+
+    def test_max_message_length(self):
+        assert _MAX_MESSAGE_LENGTH == 10000
+
+    def test_seen_cap(self):
+        assert _SEEN_CAP == 500