Prechádzať zdrojové kódy

Add chatto_get_message and chatto_get_history read tools

A referenced event ID — a reply_to, a thread root pasted into a prompt —
was opaque to the agent: no tool could turn it back into its message, and
the thread seeding built context only for messages the session had
already seen. These two tools close that gap with the reads chattolib
already offers (get_message, batch_get_messages, get_room_events,
get_thread_events), in the established tool pattern (config tiers,
_tool_client, refusal JSON).

Reading is deliberately NOT gated on the mention lists: the read-only
promise is about posting and answering, and the server's own visibility
rules decide what the bot may read — a tool that refused to read an
unlisted room would make 'resolve this reply_to' unanswerable in the
rooms the bot merely watches.
Liv Klumpp 1 týždeň pred
rodič
commit
857a9ad645
4 zmenil súbory, kde vykonal 655 pridanie a 6 odobranie
  1. 6 4
      README.md
  2. 387 0
      adapter.py
  3. 2 0
      plugin.yaml
  4. 260 2
      test_adapter.py

+ 6 - 4
README.md

@@ -379,20 +379,22 @@ Two refusals by design: direct messages cannot be left, and leaving the configur
 
 ## Plugin tools
 
-Beyond answering inbound messages, the agent can act on Chatto on its own initiative. The plugin registers three tools, available to the agent in the gateway and, with `hermes tools`, in the CLI:
+Beyond answering inbound messages, the agent can act on Chatto on its own initiative. The plugin registers five tools, available to the agent in the gateway and, with `hermes tools`, in the CLI:
 
 | Tool | What it does |
 |------|--------------|
 | `chatto_list_rooms` | Lists the rooms visible to the bot with their IDs, kinds and membership. Optional `name` filters case-insensitively by substring. Useful for turning a channel name into the `platform:chat_id` address a cron `deliver` target needs. |
 | `chatto_send_room` | Posts a message into a channel or DM room, by `room_id` or `room_name`. Can post inside an existing thread (`thread_root_event_id`) or open a new one (`create_thread`). Either way the bot follows the thread afterwards, so later replies in it reach the agent. |
 | `chatto_send_dm` | Posts a message into the DM room you already have with a person, addressed by `user_id` or `user_login`. |
+| `chatto_get_message` | Reads one message (or several, via `event_ids`) with all its data: body, author, timestamps, reply and thread references, attachments, reactions, pinned and deleted status. `follow_reply_chain` (0-10) additionally resolves the messages it replies to, oldest-first — use it to resolve a `reply_to=` reference. |
+| `chatto_get_history` | Reads the most recent messages of a room (or of one thread, via `thread_root_event_id`), oldest-first, up to `limit` messages (1-100, default 20). `has_older` says whether older history exists. |
 
 Four boundaries are deliberate, and each tool reports them as an error instead of half-doing the job:
 
-- **A channel must be opted in.** `chatto_send_room` only posts into a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` or `CHATTO_OPTIONAL_MENTION_ROOMS` (DM rooms are never gated). A room on neither list stays read-only — a human message there would never have produced output either. The refusal names both settings so you can paste the room ID into `~/.hermes/.env`.
+- **A channel must be opted in.** `chatto_send_room` only posts into a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` or `CHATTO_OPTIONAL_MENTION_ROOMS` (DM rooms are never gated). A room on neither list stays read-only — a human message there would never have produced output either. The refusal names both settings so you can paste the room ID into `~/.hermes/.env`. The read tools are exempt: `chatto_get_message` and `chatto_get_history` read any room the server lets the bot see, because reading answers nothing and posts nothing — the server's own visibility rules are the only gate.
 - **The bot cannot open a DM.** Only a person can start a direct message on Chatto, so `chatto_send_dm` needs a recipient who has written to the bot at least once. To reach someone else, mention them in a channel message.
-- **A name is not an identity.** If several rooms share the name you passed, `chatto_send_room` returns their IDs and asks for `room_id` instead of picking one. Same rule as `/join #name`.
-- **Visible is not joined.** `chatto_list_rooms` lists rooms the bot can see, which includes channels it has not joined; check `is_member` before posting into one. `chatto_send_room` refuses an unjoined room and says how to get in, because the server would reject the post with an error naming neither the room nor the fix.
+- **A name is not an identity.** If several rooms share the name you passed, `chatto_send_room` returns their IDs and asks for `room_id` instead of picking one. Same rule as `/join #name`. The read tools resolve names by the same rule.
+- **Visible is not joined.** `chatto_list_rooms` lists rooms the bot can see, which includes channels it has not joined; check `is_member` before posting into one. `chatto_send_room` refuses an unjoined room and says how to get in, because the server would reject the post with an error naming neither the room nor the fix. Reading needs no membership beyond what the server allows — a room the bot merely sees stays readable if the server permits the timeline.
 
 Credentials never come from the model: no tool takes a server URL or a token as an argument, so the configured bot token cannot be pointed at another host. Inside a channel, mention people in the message text (`@login, …`) — there is no separate mention parameter.
 

+ 387 - 0
adapter.py

@@ -3983,6 +3983,115 @@ CHATTO_SEND_ROOM_SCHEMA = {
 }
 
 
+CHATTO_GET_MESSAGE_SCHEMA = {
+    "name": "chatto_get_message",
+    "description": (
+        "Read one or more Chatto messages by event ID, with all their data: "
+        "body, author (user ID + login), timestamps, reply/thread references "
+        "(in_reply_to, thread_root_event_id), attachments, reactions, pinned "
+        "and deleted status. Use it to resolve a `reply_to=`/`in_reply_to` "
+        "reference or any other event ID mentioned in a message. "
+        "`follow_reply_chain` (0-10) additionally walks the in_reply_to chain "
+        "backwards and returns the ancestor messages oldest-first. `event_ids` "
+        "fetches several messages in one call (then `event_id` is refused)."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": (
+                    "The Chatto room ID the message lives in "
+                    "(e.g. 'RbmwiHk4vs9a3Ql'). Exactly one of `room_id` or "
+                    "`room_name` is required."
+                ),
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "The room's display name (case-insensitive). Exactly one of "
+                    "`room_id` or `room_name` is required; ambiguous names are "
+                    "refused with the candidate IDs."
+                ),
+            },
+            "event_id": {
+                "type": "string",
+                "description": (
+                    "The event ID of the message to read (e.g. 'EM0D7tFdvd14EXS'). "
+                    "Exactly one of `event_id` or `event_ids` is required."
+                ),
+            },
+            "event_ids": {
+                "type": "array",
+                "items": {"type": "string"},
+                "description": (
+                    "Several event IDs to read in one call. Mutually exclusive "
+                    "with `event_id`."
+                ),
+            },
+            "follow_reply_chain": {
+                "type": "integer",
+                "description": (
+                    "How many in_reply_to ancestors to resolve, 0-10 "
+                    "(default 0 = do not follow)."
+                ),
+            },
+        },
+        "required": [],
+        "additionalProperties": False,
+    },
+}
+
+
+CHATTO_GET_HISTORY_SCHEMA = {
+    "name": "chatto_get_history",
+    "description": (
+        "Read the most recent messages of a Chatto room, or of one thread in "
+        "it, oldest-first. Use it for context: what was said before a message "
+        "the bot is about to answer or reference. With `thread_root_event_id` "
+        "the thread's events are read instead of the room timeline; the root "
+        "has to belong to the room and BE a root, like in chatto_send_room. "
+        "`limit` (1-100, default 20) bounds the page; `has_older` says whether "
+        "older history exists."
+    ),
+    "parameters": {
+        "type": "object",
+        "properties": {
+            "room_id": {
+                "type": "string",
+                "description": (
+                    "The Chatto room ID to read (e.g. 'RbmwiHk4vs9a3Ql'). "
+                    "Exactly one of `room_id` or `room_name` is required."
+                ),
+            },
+            "room_name": {
+                "type": "string",
+                "description": (
+                    "The room's display name (case-insensitive). Exactly one of "
+                    "`room_id` or `room_name` is required; ambiguous names are "
+                    "refused with the candidate IDs."
+                ),
+            },
+            "thread_root_event_id": {
+                "type": "string",
+                "description": (
+                    "Optional: read this thread's events instead of the room "
+                    "timeline. The ID must be a thread ROOT in this room."
+                ),
+            },
+            "limit": {
+                "type": "integer",
+                "description": (
+                    "Maximum number of messages to return, 1-100 (default 20)."
+                ),
+            },
+        },
+        "required": [],
+        "additionalProperties": False,
+    },
+}
+
+
 def _chatto_config_for_tools() -> ChattoConfiguration | None:
     """Resolve the Chatto configuration for plugin tool handlers.
 
@@ -4549,6 +4658,246 @@ async def _list_rooms(args: dict) -> str:
         )
 
 
+def _iso_or_none(moment: datetime | None) -> str | None:
+    """A Message timestamp as ISO-8601, or None when the server sent none."""
+    if moment is None:
+        return None
+    return moment.isoformat()
+
+
+async def _batch_author_logins(
+    client: ChattoClient, room_id: str, actor_ids: list[str]
+) -> dict[str, str]:
+    """Map actor IDs to logins, using the room directory the bot may read.
+
+    ``batch_get_room_members`` needs no admin rights and answers member
+    profiles for the caller's own rooms, so it is the one sanctioned way to
+    turn an ``actor_id`` into a name here. Members who left (or were never
+    members) come back missing — their IDs stay as their only label, which
+    is still unambiguous for the caller. A directory that refuses the call
+    costs the names, not the messages: the logins are enrichment, and an
+    unreadable roster must not make a readable history unreadable.
+    """
+    unique_ids = list({actor_id for actor_id in actor_ids if actor_id})
+    if not unique_ids:
+        return {}
+    try:
+        members = await client.batch_get_room_members(room_id, unique_ids)
+    except ChattoError as exc:
+        logger.debug("Chatto tools: members of room %s unreadable: %s", room_id, exc)
+        return {}
+    logins: dict[str, str] = {}
+    for member in members:
+        if member.user is not None and member.user.id:
+            logins[member.user.id] = member.user.login
+    return logins
+
+
+def _attachment_out(attachment: MessageAttachment) -> dict[str, Any]:
+    """One attachment as the tool's JSON shape."""
+    out: dict[str, Any] = {
+        "id": attachment.id,
+        "filename": attachment.filename,
+        "content_type": attachment.content_type,
+        "width": attachment.width,
+        "height": attachment.height,
+        "description": attachment.description,
+    }
+    if attachment.asset_url is not None:
+        out["asset_url"] = attachment.asset_url.url
+    if attachment.thumbnail_asset_url is not None:
+        out["thumbnail_asset_url"] = attachment.thumbnail_asset_url.url
+    return out
+
+
+def _message_out(message: Message, author_logins: dict[str, str]) -> dict[str, Any]:
+    """A Message as the tool's JSON shape — every field the caller can act on."""
+    return {
+        "event_id": message.id,
+        "room_id": message.room_id,
+        "author_user_id": message.actor_id,
+        "author_login": author_logins.get(message.actor_id),
+        "created_at": _iso_or_none(message.created_at),
+        "updated_at": _iso_or_none(message.updated_at),
+        "deleted_at": _iso_or_none(message.deleted_at),
+        "body": message.body,
+        "in_reply_to": message.in_reply_to or None,
+        "thread_root_event_id": message.thread_root_event_id or None,
+        "pinned": message.pinned,
+        "attachments": [
+            _attachment_out(attachment) for attachment in message.attachments
+        ],
+        "reactions": [
+            {
+                "emoji": reaction.emoji,
+                "count": reaction.count,
+                "has_reacted": reaction.has_reacted,
+            }
+            for reaction in message.reactions
+        ],
+        "link_preview": (
+            {"url": message.link_preview.url, "title": message.link_preview.title}
+            if message.link_preview is not None
+            else None
+        ),
+        "thread_summary": (
+            {
+                "thread_root_event_id": message.thread.thread_root_event_id,
+                "reply_count": message.thread.reply_count,
+                "participant_count": message.thread.participant_count,
+            }
+            if message.thread is not None
+            else None
+        ),
+    }
+
+
+async def _get_message(args: dict) -> str:
+    """Resolve the room, fetch the message(s). See _handle_chatto_get_message."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    event_id = args.get("event_id") or ""
+    event_ids = [e for e in (args.get("event_ids") or []) if e]
+    follow_reply_chain = int(args.get("follow_reply_chain") or 0)
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+    if not event_id and not event_ids:
+        raise _refuse("Exactly one of event_id or event_ids is required")
+    if event_id and event_ids:
+        raise _refuse("event_id and event_ids are mutually exclusive")
+    if follow_reply_chain < 0 or follow_reply_chain > 10:
+        raise _refuse("follow_reply_chain must be between 0 and 10")
+
+    async with _tool_client(_chatto_config_for_tools()) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        target_ids = [event_id] if event_id else event_ids
+        messages = await client.batch_get_messages(room.id, target_ids)
+        found: dict[str, Message] = {m.id: m for m in messages if m.id}
+        missing = [eid for eid in target_ids if eid not in found]
+        if missing and not event_ids:
+            # A single miss has no batch to fall back on — get_message is the
+            # authoritative lookup, so a None there is a miss here too.
+            single = await client.get_message(room.id, event_id)
+            if single is not None:
+                found[single.id] = single
+                missing = []
+        if not found:
+            raise _refuse(
+                f"No message {', '.join(missing)} found in room {room.id}. "
+                "Check the event ID and the room."
+            )
+
+        # The reply chain walks in_reply_to backwards, oldest ancestor first.
+        # Batching keeps it at one RPC for the common short chain.
+        chain: list[Message] = []
+        if follow_reply_chain > 0:
+            frontier_id = next(iter(found.values())).in_reply_to
+            hops = follow_reply_chain
+            while frontier_id and hops > 0:
+                ancestors = await client.batch_get_messages(room.id, [frontier_id])
+                ancestor = next((m for m in ancestors if m.id == frontier_id), None)
+                if ancestor is None:
+                    single = await client.get_message(room.id, frontier_id)
+                    ancestor = single if single is not None else ancestor
+                if ancestor is None:
+                    break
+                chain.append(ancestor)
+                frontier_id = ancestor.in_reply_to
+                hops -= 1
+            chain.reverse()
+
+        author_ids = [m.actor_id for m in found.values()] + [m.actor_id for m in chain]
+        author_logins = await _batch_author_logins(client, room.id, author_ids)
+
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "room_name": room.name,
+                "messages": [_message_out(m, author_logins) for m in found.values()],
+                "missing_event_ids": missing,
+                "reply_chain": [_message_out(m, author_logins) for m in chain],
+            }
+        )
+
+
+async def _get_history(args: dict) -> str:
+    """Resolve the room, read the timeline. See _handle_chatto_get_history."""
+    room_id = args.get("room_id") or ""
+    room_name = args.get("room_name") or ""
+    thread_root_event_id = args.get("thread_root_event_id") or ""
+    limit = int(args.get("limit") or 20)
+
+    if not room_id and not room_name:
+        raise _refuse("Exactly one of room_id or room_name is required")
+    if limit < 1 or limit > 100:
+        raise _refuse("limit must be between 1 and 100")
+
+    async with _tool_client(_chatto_config_for_tools()) as client:
+        resolved = await _resolve_room(client, room_id, room_name)
+        room = resolved.room
+
+        if thread_root_event_id:
+            # A thread root has to belong to this room, and it has to BE a
+            # root — the same two checks chatto_send_room makes before
+            # posting, so the tools cannot disagree about what a root is.
+            root = await client.get_message(room.id, thread_root_event_id)
+            if root is None:
+                raise _refuse(
+                    f"No message {thread_root_event_id} in room {room.id}. "
+                    "Drop thread_root_event_id to read the room itself."
+                )
+            if root.thread_root_event_id:
+                raise _refuse(
+                    f"Message {thread_root_event_id} is a reply inside thread "
+                    f"{root.thread_root_event_id}, not a thread root. Pass that "
+                    "thread's root ID instead."
+                )
+            page = await client.get_thread_events(
+                room.id, thread_root_event_id, limit=limit
+            )
+        else:
+            page = await client.get_room_events(room.id, limit=limit)
+
+        messages = [ev.message for ev in page.events if ev.message is not None]
+        # The server's ordering is not a contract we build on — sort by the
+        # timestamp each message carries, so the block reads oldest-first.
+        messages.sort(key=lambda m: m.created_at or datetime.min.replace(tzinfo=UTC))
+
+        # The timeline page carries its own user directory (``users_by_id``),
+        # which answers author names without a member-list call. Fall back to
+        # the room directory for actors the page did not include.
+        author_logins = {
+            user_id: user.login
+            for user_id, user in page.users_by_id.items()
+            if user.login
+        }
+        unresolved = [
+            m.actor_id
+            for m in messages
+            if m.actor_id and m.actor_id not in author_logins
+        ]
+        if unresolved:
+            author_logins.update(
+                await _batch_author_logins(client, room.id, unresolved)
+            )
+
+        return json.dumps(
+            {
+                "success": True,
+                "room_id": room.id,
+                "room_name": room.name,
+                "thread_root_event_id": thread_root_event_id or None,
+                "count": len(messages),
+                "has_older": page.has_older,
+                "messages": [_message_out(m, author_logins) for m in messages],
+            }
+        )
+
+
 async def _handle_chatto_send_dm(args: dict) -> str:
     """Tool handler for chatto_send_dm.
 
@@ -4602,6 +4951,42 @@ async def _handle_chatto_list_rooms(args: dict) -> str:
         return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
 
 
+async def _handle_chatto_get_message(args: dict) -> str:
+    """Tool handler for chatto_get_message.
+
+    Returns a JSON string: Hermes' tool contract accepts no other type, and a
+    dict (or an unflagged coroutine) fails at dispatch with "unsupported result
+    type". Every early exit arrives here as a _ToolRefusal and leaves as that
+    refusal's own JSON, so no path escapes as an unhandled error.
+    """
+    try:
+        return await _get_message(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
+async def _handle_chatto_get_history(args: dict) -> str:
+    """Tool handler for chatto_get_history.
+
+    Returns a JSON string: Hermes' tool contract accepts no other type, and a
+    dict (or an unflagged coroutine) fails at dispatch with "unsupported result
+    type". Every early exit arrives here as a _ToolRefusal and leaves as that
+    refusal's own JSON, so no path escapes as an unhandled error.
+    """
+    try:
+        return await _get_history(args)
+    except _ToolRefusal as refusal:
+        return refusal.result_json
+    except ChattoAuthError as exc:
+        return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
+    except ChattoError as exc:
+        return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
+
+
 def _register_bundled_skills(ctx) -> None:
     """Register the skills shipped in ``skills/``, each with its own description.
 
@@ -4638,6 +5023,8 @@ _PLUGIN_TOOLS = (
     ("chatto_send_dm", CHATTO_SEND_DM_SCHEMA, _handle_chatto_send_dm),
     ("chatto_send_room", CHATTO_SEND_ROOM_SCHEMA, _handle_chatto_send_room),
     ("chatto_list_rooms", CHATTO_LIST_ROOMS_SCHEMA, _handle_chatto_list_rooms),
+    ("chatto_get_message", CHATTO_GET_MESSAGE_SCHEMA, _handle_chatto_get_message),
+    ("chatto_get_history", CHATTO_GET_HISTORY_SCHEMA, _handle_chatto_get_history),
 )
 
 

+ 2 - 0
plugin.yaml

@@ -14,6 +14,8 @@ provides_tools:
   - chatto_send_dm
   - chatto_send_room
   - chatto_list_rooms
+  - chatto_get_message
+  - chatto_get_history
 requires_env:
   - name: CHATTO_BASE_URL
     description: "Base URL of the Chatto server (e.g. https://chat.example.com) leave blank for ChattoHQ (https://chat.chatto.run)"

+ 260 - 2
test_adapter.py

@@ -71,6 +71,8 @@ from gateway.platforms.base import (
 )
 
 from adapter import (
+    CHATTO_GET_HISTORY_SCHEMA,
+    CHATTO_GET_MESSAGE_SCHEMA,
     CHATTO_LIST_ROOMS_SCHEMA,
     CHATTO_SEND_DM_SCHEMA,
     CHATTO_SEND_ROOM_SCHEMA,
@@ -79,6 +81,8 @@ from adapter import (
     RoomPolicy,
     _capabilities,
     _chatto_config_for_tools,
+    _handle_chatto_get_history,
+    _handle_chatto_get_message,
     _handle_chatto_list_rooms,
     _handle_chatto_send_dm,
     _handle_chatto_send_room,
@@ -4399,8 +4403,14 @@ class TestPluginToolSchemas:
 
     @pytest.mark.parametrize(
         "schema",
-        [CHATTO_LIST_ROOMS_SCHEMA, CHATTO_SEND_DM_SCHEMA, CHATTO_SEND_ROOM_SCHEMA],
-        ids=["list_rooms", "send_dm", "send_room"],
+        [
+            CHATTO_LIST_ROOMS_SCHEMA,
+            CHATTO_SEND_DM_SCHEMA,
+            CHATTO_SEND_ROOM_SCHEMA,
+            CHATTO_GET_MESSAGE_SCHEMA,
+            CHATTO_GET_HISTORY_SCHEMA,
+        ],
+        ids=["list_rooms", "send_dm", "send_room", "get_message", "get_history"],
     )
     def test_schema_advertises_no_credentials(self, schema):
         """A base_url argument would send the configured bot token to a host
@@ -4418,6 +4428,8 @@ class TestPluginToolSchemas:
             "chatto_send_dm",
             "chatto_send_room",
             "chatto_list_rooms",
+            "chatto_get_message",
+            "chatto_get_history",
         ]
         for tool in ctx.registered_tools:
             assert tool["is_async"] is True, tool["name"]
@@ -5017,6 +5029,252 @@ class TestPluginToolHandlers:
         )
         assert [room["room_id"] for room in result["rooms"]] == ["room-1", "room-2"]
 
+    # -- chatto_get_message --
+
+    def _readable_room(self):
+        """A channel the get tools may read, resolved by room_id."""
+        return _make_room("room-1", "Ops", RoomKind.CHANNEL)
+
+    async def test_get_message_returns_every_referenceable_field(self):
+        """The tool exists to resolve references, so the fields a message can
+        be referenced BY — in_reply_to, thread root, author, attachments —
+        all have to come back."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        target = _make_message(body="the answer", message_id="msg-2", actor_id="user-1")
+        target.in_reply_to = "msg-1"
+        client.batch_get_messages = AsyncMock(return_value=[target])
+        client.batch_get_room_members = AsyncMock(
+            return_value=[DirectoryMember(user=_make_user("user-1", "alice"))]
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {"room_id": "room-1", "event_id": "msg-2"},
+            client=client,
+        )
+        assert result["success"] is True
+        (message,) = result["messages"]
+        assert message["event_id"] == "msg-2"
+        assert message["body"] == "the answer"
+        assert message["in_reply_to"] == "msg-1"
+        assert message["author_login"] == "alice"
+        assert message["author_user_id"] == "user-1"
+        assert result["missing_event_ids"] == []
+        assert result["reply_chain"] == []
+
+    async def test_get_message_follows_the_reply_chain_oldest_first(self):
+        """follow_reply_chain walks in_reply_to backwards and hands the
+        ancestors over oldest-first, so the caller reads the conversation in
+        order."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        newest = _make_message(message_id="msg-3", body="c")
+        newest.in_reply_to = "msg-2"
+        middle = _make_message(message_id="msg-2", body="b")
+        middle.in_reply_to = "msg-1"
+        oldest = _make_message(message_id="msg-1", body="a")
+
+        async def batch_get(room_id, event_ids):
+            return {
+                "msg-3": [newest],
+                "msg-2": [middle],
+                "msg-1": [oldest],
+            }[event_ids[0]]
+
+        client.batch_get_messages = AsyncMock(side_effect=batch_get)
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {"room_id": "room-1", "event_id": "msg-3", "follow_reply_chain": 5},
+            client=client,
+        )
+        assert result["success"] is True
+        assert [m["event_id"] for m in result["reply_chain"]] == ["msg-1", "msg-2"]
+
+    async def test_get_message_reports_unknown_ids_instead_of_guessing(self):
+        """A batch may miss some IDs; the answer says which ones rather than
+        pretending the call found everything."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        found = _make_message(message_id="msg-1", body="hi")
+        client.batch_get_messages = AsyncMock(return_value=[found])
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {"room_id": "room-1", "event_ids": ["msg-1", "msg-x"]},
+            client=client,
+        )
+        assert result["success"] is True
+        assert result["missing_event_ids"] == ["msg-x"]
+        assert [m["event_id"] for m in result["messages"]] == ["msg-1"]
+
+    async def test_get_message_refuses_an_unknown_single_id(self):
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.batch_get_messages = AsyncMock(return_value=[])
+        client.get_message = AsyncMock(return_value=None)
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {"room_id": "room-1", "event_id": "msg-x"},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "msg-x" in result["error"]
+
+    async def test_get_message_survives_an_unreadable_member_directory(self):
+        """Logins are enrichment, not a precondition: a directory the bot
+        cannot read must cost the names, not the message."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.batch_get_messages = AsyncMock(
+            return_value=[_make_message(message_id="msg-1", body="hi")]
+        )
+        client.batch_get_room_members = AsyncMock(
+            side_effect=ChattoError("directory refused")
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {"room_id": "room-1", "event_id": "msg-1"},
+            client=client,
+        )
+        assert result["success"] is True
+        assert result["messages"][0]["author_login"] is None
+
+    # -- chatto_get_history --
+
+    @staticmethod
+    def _timeline_page(messages):
+        """A TimelinePage whose events carry the given messages, plus a user
+        directory the server includes in real pages."""
+        events = [
+            TimelineEvent(
+                id=m.id, created_at=m.created_at, actor_id=m.actor_id, message=m
+            )
+            for m in messages
+        ]
+        return TimelinePage(events=events, users_by_id={})
+
+    async def test_get_history_reads_the_room_oldest_first(self):
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        old = _make_message(
+            message_id="msg-1",
+            body="first",
+            created_at=datetime(2026, 10, 18, 10, 0, tzinfo=UTC),
+        )
+        new = _make_message(
+            message_id="msg-2",
+            body="second",
+            created_at=datetime(2026, 10, 18, 11, 0, tzinfo=UTC),
+        )
+        client.get_room_events = AsyncMock(return_value=self._timeline_page([new, old]))
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-1", "limit": 10},
+            client=client,
+        )
+        assert result["success"] is True
+        assert [m["event_id"] for m in result["messages"]] == ["msg-1", "msg-2"]
+        assert result["has_older"] is False
+
+    async def test_get_history_hydrates_authors_from_the_page_directory(self):
+        """The timeline page carries its own users_by_id — the first-choice
+        author source, before the room directory is asked at all."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        page = self._timeline_page(
+            [_make_message(message_id="msg-1", actor_id="user-7")]
+        )
+        page.users_by_id["user-7"] = _make_user("user-7", "seven")
+        client.get_room_events = AsyncMock(return_value=page)
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-1"},
+            client=client,
+        )
+        assert result["messages"][0]["author_login"] == "seven"
+        client.batch_get_room_members.assert_not_awaited()
+
+    async def test_get_history_reads_a_thread_when_given_a_root(self):
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        root = _make_message(message_id="msg-1", body="root")
+        reply = _make_message(
+            message_id="msg-2", body="reply", thread_root_event_id="msg-1"
+        )
+        client.get_message = AsyncMock(return_value=root)
+        client.get_thread_events = AsyncMock(
+            return_value=self._timeline_page([root, reply])
+        )
+        # The stub auto-creates plain Mocks for unassigned methods, and a
+        # plain Mock has no assert_not_awaited — assign it explicitly.
+        client.get_room_events = AsyncMock()
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-1", "thread_root_event_id": "msg-1"},
+            client=client,
+        )
+        assert result["success"] is True
+        assert result["thread_root_event_id"] == "msg-1"
+        assert [m["event_id"] for m in result["messages"]] == ["msg-1", "msg-2"]
+        client.get_room_events.assert_not_awaited()
+
+    async def test_get_history_refuses_a_reply_as_a_thread_root(self):
+        """The same root rule as chatto_send_room: a reply inside a thread is
+        not a root, and the two tools must not disagree about that."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.get_message = AsyncMock(
+            return_value=_make_message(message_id="msg-2", thread_root_event_id="msg-1")
+        )
+        client.get_thread_events = AsyncMock()
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-1", "thread_root_event_id": "msg-2"},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "not a thread root" in result["error"]
+        client.get_thread_events.assert_not_awaited()
+
+    async def test_get_history_reads_a_room_the_send_tool_would_refuse(self):
+        """Reading breaks no read-only promise — the send gate exists for
+        posting, and a visible room's history stays readable without the
+        mention lists."""
+        room = _make_room("room-9", "Lurk", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.get_room_events = AsyncMock(
+            return_value=self._timeline_page([_make_message(message_id="msg-1")])
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-9"},
+            client=client,
+        )
+        assert result["success"] is True
+
+    async def test_get_history_refuses_an_out_of_range_limit(self):
+        result, _, _ = await self._run(
+            _handle_chatto_get_history, {"room_id": "room-1", "limit": 101}
+        )
+        assert result["success"] is False
+        assert "limit" in result["error"]
+
+    async def test_get_message_refuses_a_room_and_a_name_gap(self):
+        result, _, _ = await self._run(
+            _handle_chatto_get_message, {"event_id": "msg-1"}
+        )
+        assert result["success"] is False
+        assert "room_id or room_name" in result["error"]
+
     async def test_tool_config_falls_back_to_config_yaml(self):
         """A cron session never runs hermes_validate_config, so the fallback
         has to read config.yaml's own extra block — not an empty one."""