Parcourir la source

Give the read tools one answer per question, not one per copy

Review of the read tools found four things worth settling before they
reach main.

The thread-root check existed twice — once in chatto_send_room, once in
chatto_get_history — with a comment claiming that was how the tools could
not disagree about what a root is. Duplication is the one mechanism that
lets them drift, so both now go through _resolve_thread_root.

follow_reply_chain with event_ids walked whichever message the server
happened to return first, and chatto_get_message answered a batch in the
server's order. A chain belongs to one message and a list comes back in the
order it was written, so the combination is refused and the batch is
ordered by the request. The single-event_id form now uses the single-message
RPC it always meant to call, instead of a batch that missed and had to be
retried.

The oldest-first sort was the third copy of itself; _oldest_first serves
the thread seeding and both read paths, so a session and a tool read agree
on ordering. A thread page that omits its root gets it put back — the root
is already in hand from the check, and the adapter's own seeding has made
the same repair for the same reason.

Numeric arguments came from a model, so _int_arg refuses what it cannot
read instead of raising out of the handler: "twenty" raised ValueError past
a wrapper that promises no path escapes unhandled, and limit=0 fell
through `or default` into a page wider than the caller asked for.

_attachment_out now reports a video's duration, thumbnail and transcoded
variants. Without them a video arrived as an attachment with no size, no
length and no URL, which is the same class of gap as the upload_id
incident this repo documents.
Paul Klumpp il y a 1 semaine
Parent
commit
e780e5a4f0
2 fichiers modifiés avec 339 ajouts et 80 suppressions
  1. 170 67
      adapter.py
  2. 169 13
      test_adapter.py

+ 170 - 67
adapter.py

@@ -94,6 +94,7 @@ try:
         RoomKind,
         RoomWithViewerState,
         User,
+        format_datetime,
     )
 
 except ImportError as e:
@@ -263,6 +264,24 @@ def _normalise_outbound_text(content: str) -> str:
     return normalised
 
 
+def _oldest_first(messages: list[Message]) -> list[Message]:
+    """Sort messages by the timestamp they carry, oldest first.
+
+    The server's ordering is not a contract anything here builds on — it is a
+    page, and a page's order depends on which end it was fetched from — so
+    every timeline is read the way a conversation is spoken. A message with no
+    timestamp sorts first: it is the one whose position we know least about,
+    and putting it at the end would claim an order the data does not support.
+
+    Shared by the thread seeding and the read tools so the block an agent reads
+    in a session and the block it gets from ``chatto_get_history`` are ordered
+    by one rule.
+    """
+    return sorted(
+        messages, key=lambda m: m.created_at or datetime.min.replace(tzinfo=UTC)
+    )
+
+
 # --------------------------------------------------------------------------- #
 # Adapter
 # --------------------------------------------------------------------------- #
@@ -1492,12 +1511,7 @@ class ChattoAdapter(BasePlatformAdapter):
                 )
                 + messages
             )
-        # The server's ordering is not a contract we build on — sort by the
-        # timestamp each message carries, so the block reads like the thread.
-        return sorted(
-            messages,
-            key=lambda m: m.created_at or datetime.min.replace(tzinfo=UTC),
-        )
+        return _oldest_first(messages)
 
     async def _thread_root_message(
         self, client: ChattoClient, *, room_id: str, thread_root_event_id: str
@@ -3847,6 +3861,31 @@ def _refuse(error: str, **extra: Any) -> _ToolRefusal:
     return _ToolRefusal(json.dumps({"success": False, "error": error, **extra}))
 
 
+def _int_arg(args: dict, key: str, default: int, *, low: int, high: int) -> int:
+    """An integer tool argument, validated — a refusal rather than a guess.
+
+    The value comes from a model, so "twenty" and a float are both reachable,
+    and an unvalidated ``int()`` would raise out of the handler instead of
+    answering. An absent argument takes the default; a *wrong* one is refused,
+    which also keeps a falsy ``0`` from silently becoming the default and
+    quietly widening the answer.
+    """
+    raw = args.get(key)
+    if raw is None or raw == "":
+        return default
+    not_a_number = f"{key} must be a whole number between {low} and {high}"
+    try:
+        value = int(raw)
+    except (TypeError, ValueError) as exc:
+        raise _refuse(not_a_number) from exc
+    if isinstance(raw, float) and not raw.is_integer():
+        # int() would truncate 3.5 to 3, answering a question nobody asked.
+        raise _refuse(not_a_number)
+    if value < low or value > high:
+        raise _refuse(f"{key} must be between {low} and {high}")
+    return value
+
+
 @asynccontextmanager
 async def _tool_client(cfg: ChattoConfiguration | None) -> AsyncIterator[ChattoClient]:
     """A short-lived client for one tool call, closed however the call ends.
@@ -3876,6 +3915,16 @@ async def _tool_client(cfg: ChattoConfiguration | None) -> AsyncIterator[ChattoC
             logger.debug("Chatto tools: client close failed: %s", exc)
 
 
+# Read-tool bounds, named so the schemas and the validation cannot disagree.
+# A chain is walked hop by hop, so its ceiling is a cost bound: each hop is a
+# round trip, and a caller asking for the whole conversation has
+# chatto_get_history to page it. The page size is bounded for the opposite
+# reason — one read must stay inside a model context.
+FOLLOW_REPLY_CHAIN_MAX = 10
+HISTORY_PAGE_SIZE = 20
+HISTORY_PAGE_MAX = 100
+
+
 CHATTO_LIST_ROOMS_SCHEMA = {
     "name": "chatto_list_rooms",
     "description": (
@@ -4002,8 +4051,10 @@ CHATTO_GET_MESSAGE_SCHEMA = {
         "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)."
+        "backwards and returns the ancestor messages oldest-first; it applies to "
+        "a single `event_id` and is refused with `event_ids`. `event_ids` "
+        "fetches several messages in one call, answered in the order asked for "
+        "(then `event_id` is refused)."
     ),
     "parameters": {
         "type": "object",
@@ -4043,7 +4094,7 @@ CHATTO_GET_MESSAGE_SCHEMA = {
                 "type": "integer",
                 "description": (
                     "How many in_reply_to ancestors to resolve, 0-10 "
-                    "(default 0 = do not follow)."
+                    "(default 0 = do not follow). Single `event_id` only."
                 ),
             },
         },
@@ -4494,6 +4545,35 @@ async def _resolve_room(
     return matches[0]
 
 
+async def _resolve_thread_root(
+    client: ChattoClient, room: Room, event_id: str
+) -> Message:
+    """The thread root a tool was pointed at, or a refusal saying why not.
+
+    Both tools that take a ``thread_root_event_id`` — posting into one and
+    reading one — resolve it here, because two copies of "is this a root?" is
+    one too many: they drift, and the drift shows up as two tools disagreeing
+    about the same event ID.
+
+    Two things have to hold. The message has to exist in this room (a root from
+    another room would attach a thread to the wrong conversation), and it has
+    to BE a root (a reply's ID names no thread, so following it lands nowhere).
+    """
+    root = await client.get_message(room.id, event_id)
+    if root is None:
+        raise _refuse(
+            f"No message {event_id} in room {room.id}. It has to be an event ID "
+            f"from this room — chatto_get_history lists them."
+        )
+    if root.thread_root_event_id:
+        raise _refuse(
+            f"Message {event_id} is a reply inside thread "
+            f"{root.thread_root_event_id}, not a thread root. Pass that "
+            "thread's root ID instead."
+        )
+    return root
+
+
 async def _send_dm(args: dict) -> str:
     """Resolve the recipient, find the DM room, post. See _handle_chatto_send_dm."""
     user_login = args.get("user_login") or ""
@@ -4575,22 +4655,12 @@ async def _send_room(args: dict) -> str:
         if not resolved.is_member:
             raise _not_joined_refusal(room)
 
-        # A thread root has to belong to the room we post into, or the message
-        # attaches to a thread somewhere else entirely. And it has to BE a root:
-        # passing a reply's ID would attach to a thread nobody can follow.
+        # A thread root has to belong to this room and has to BE a root, or
+        # the message attaches to a thread somewhere else entirely — or to
+        # none that can be followed. One shared check, so this tool and
+        # chatto_get_history cannot answer differently about the same ID.
         if thread_root_event_id:
-            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}. "
-                    "Send without thread_root_event_id to post to 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, or send without thread_root_event_id."
-                )
+            await _resolve_thread_root(client, room, thread_root_event_id)
 
         message_ids, root_event_id = await _post_chunks(
             client,
@@ -4669,10 +4739,15 @@ 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."""
+    """A Message timestamp as ISO-8601, or None when the server sent none.
+
+    chattolib's own formatter does the work: it pins a naive timestamp to UTC
+    instead of letting it print without an offset, so two timestamps from
+    different sources compare the way a reader expects.
+    """
     if moment is None:
         return None
-    return moment.isoformat()
+    return format_datetime(moment)
 
 
 async def _batch_author_logins(
@@ -4717,6 +4792,33 @@ def _attachment_out(attachment: MessageAttachment) -> dict[str, Any]:
         out["asset_url"] = attachment.asset_url.url
     if attachment.thumbnail_asset_url is not None:
         out["thumbnail_asset_url"] = attachment.thumbnail_asset_url.url
+    if attachment.video_processing is not None:
+        # A video carries its own dimensions, duration and transcoded
+        # variants, and its own URL only once the server has produced one —
+        # without this block the tool reports a video as an attachment with no
+        # size, no length and nothing to fetch.
+        video = attachment.video_processing
+        out["video"] = {
+            "duration_ms": video.duration_ms,
+            "status": video.status.name,
+            "thumbnail_url": (
+                video.thumbnail_asset_url.url
+                if video.thumbnail_asset_url is not None
+                else None
+            ),
+            "variants": [
+                {
+                    "quality": variant.quality,
+                    "width": variant.width,
+                    "height": variant.height,
+                    "size": variant.size,
+                    "url": (
+                        variant.asset_url.url if variant.asset_url is not None else None
+                    ),
+                }
+                for variant in video.variants
+            ],
+        }
     return out
 
 
@@ -4768,7 +4870,9 @@ async def _get_message(args: dict) -> str:
     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)
+    follow_reply_chain = _int_arg(
+        args, "follow_reply_chain", 0, low=0, high=FOLLOW_REPLY_CHAIN_MAX
+    )
 
     if not room_id and not room_name:
         raise _refuse("Exactly one of room_id or room_name is required")
@@ -4776,42 +4880,49 @@ async def _get_message(args: dict) -> str:
         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")
+    if event_ids and follow_reply_chain:
+        # A chain belongs to one message. With several requested there is no
+        # single answer, and picking one would hand back a conversation the
+        # caller did not ask for.
+        raise _refuse(
+            "follow_reply_chain applies to a single event_id — read one message "
+            "per call to follow its chain"
+        )
 
     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.
+        if event_id:
+            # One message is one lookup: the single-message RPC is the
+            # authoritative one, so there is no batch to fall back from.
             single = await client.get_message(room.id, event_id)
-            if single is not None:
-                found[single.id] = single
-                missing = []
+            found = {single.id: single} if single is not None else {}
+            missing = [] if single is not None else [event_id]
+        else:
+            fetched = await client.batch_get_messages(room.id, event_ids)
+            by_id = {m.id: m for m in fetched if m.id}
+            # Reported in the order they were asked for, not the order the
+            # server happened to answer in.
+            found = {eid: by_id[eid] for eid in event_ids if eid in by_id}
+            missing = [eid for eid in event_ids if eid not in found]
+
         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.
+        # The reply chain walks in_reply_to backwards, oldest ancestor first,
+        # one hop per call — a chain is a path, so it cannot be batched. A hop
+        # the server cannot resolve ends the chain: the ancestors above it are
+        # unreachable, and stopping beats reporting a hole as the top.
         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
+                ancestor = await client.get_message(room.id, frontier_id)
                 if ancestor is None:
                     break
                 chain.append(ancestor)
@@ -4839,33 +4950,18 @@ async def _get_history(args: dict) -> str:
     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)
+    limit = _int_arg(args, "limit", HISTORY_PAGE_SIZE, low=1, high=HISTORY_PAGE_MAX)
 
     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
 
+        root: Message | None = None
         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."
-                )
+            root = await _resolve_thread_root(client, room, thread_root_event_id)
             page = await client.get_thread_events(
                 room.id, thread_root_event_id, limit=limit
             )
@@ -4873,9 +4969,14 @@ async def _get_history(args: dict) -> str:
             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))
+        if root is not None and all(m.id != root.id for m in messages):
+            # Whether the server lists the root back as the thread's first
+            # event is not something the client signature promises, and a
+            # thread read without its root is a conversation whose opening
+            # line is missing. The root is already in hand, so putting it back
+            # costs nothing.
+            messages.insert(0, root)
+        messages = _oldest_first(messages)
 
         # The timeline page carries its own user directory (``users_by_id``),
         # which answers author names without a member-list call. Fall back to
@@ -5107,5 +5208,7 @@ def register(ctx) -> None:
             "chatto_send_room and chatto_send_dm post to a channel or a person's DM on your "
             "behalf. Only send into a channel that the bot answers in, and reach a person by DM "
             "only once they have written to the bot themselves. "
+            "chatto_get_history and chatto_get_message read instead of posting: use them to "
+            "look up what was said in a room or thread, and to resolve an event ID you were given. "
         ),
     )

+ 169 - 13
test_adapter.py

@@ -48,17 +48,23 @@ from chattolib.types import (
     AssetUpload,
     AssetUrl,
     DirectoryMember,
+    LinkPreview,
     Message,
     MessageAttachment,
+    MessageReaction,
     Page,
     PresenceStatus,
     Room,
     RoomKind,
     RoomViewerState,
     RoomWithViewerState,
+    ThreadSummary,
     TimelineEvent,
     TimelinePage,
     User,
+    VideoProcessing,
+    VideoProcessingStatus,
+    VideoVariant,
 )
 from gateway.config import Platform, PlatformConfig
 from gateway.platforms.base import (
@@ -5076,7 +5082,7 @@ class TestPluginToolHandlers:
         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.get_message = AsyncMock(return_value=target)
         client.batch_get_room_members = AsyncMock(
             return_value=[DirectoryMember(user=_make_user("user-1", "alice"))]
         )
@@ -5095,6 +5101,75 @@ class TestPluginToolHandlers:
         assert result["missing_event_ids"] == []
         assert result["reply_chain"] == []
 
+    async def test_get_message_carries_attachments_reactions_and_status(self):
+        """The enrichment fields are the reason to read a message rather than
+        quote it: what was attached, how it was received, whether it is still
+        there. ``_attachment_out`` reaches into two nested URL objects, which is
+        the shape that once cost us a field name that did not exist."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        video = _make_attachment("clip.mp4", "video/mp4", url="")
+        video.width, video.height = 1280, 720
+        video.video_processing = VideoProcessing(
+            status=VideoProcessingStatus.COMPLETED,
+            duration_ms=4200,
+            source_available=True,
+            thumbnail_asset_url=AssetUrl(url="https://cdn.example.com/thumb.jpg"),
+            variants=[
+                VideoVariant(
+                    quality="720p",
+                    width=1280,
+                    height=720,
+                    size=900_000,
+                    asset_url=AssetUrl(url="https://cdn.example.com/clip-720.mp4"),
+                )
+            ],
+        )
+        target = _make_message(
+            message_id="msg-9",
+            body="look",
+            attachments=[_make_attachment("a.png", "image/png"), video],
+        )
+        target.reactions = [
+            MessageReaction(
+                emoji="👀", count=2, has_reacted=True, preview_user_ids=["u1"]
+            )
+        ]
+        target.link_preview = LinkPreview(url="https://example.com", title="Example")
+        target.thread = ThreadSummary(
+            thread_root_event_id="msg-9", reply_count=3, participant_count=2
+        )
+        target.deleted_at = datetime(2026, 10, 18, 12, 0, tzinfo=UTC)
+        client.get_message = AsyncMock(return_value=target)
+
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {"room_id": "room-1", "event_id": "msg-9"},
+            client=client,
+        )
+        (message,) = result["messages"]
+        image, clip = message["attachments"]
+        assert image["asset_url"] == "https://cdn.example.com/a"
+        assert clip["video"]["duration_ms"] == 4200
+        assert clip["video"]["thumbnail_url"] == "https://cdn.example.com/thumb.jpg"
+        assert clip["video"]["variants"][0]["url"] == (
+            "https://cdn.example.com/clip-720.mp4"
+        )
+        assert message["reactions"] == [
+            {"emoji": "👀", "count": 2, "has_reacted": True}
+        ]
+        assert message["link_preview"] == {
+            "url": "https://example.com",
+            "title": "Example",
+        }
+        assert message["thread_summary"] == {
+            "thread_root_event_id": "msg-9",
+            "reply_count": 3,
+            "participant_count": 2,
+        }
+        assert message["deleted_at"] == "2026-10-18T12:00:00Z"
+
     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
@@ -5107,15 +5182,13 @@ class TestPluginToolHandlers:
         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)
+        client.get_message = AsyncMock(
+            side_effect=lambda room_id, event_id: {
+                "msg-3": newest,
+                "msg-2": middle,
+                "msg-1": oldest,
+            }[event_id]
+        )
         result, _, _ = await self._run(
             _handle_chatto_get_message,
             {"room_id": "room-1", "event_id": "msg-3", "follow_reply_chain": 5},
@@ -5124,6 +5197,50 @@ class TestPluginToolHandlers:
         assert result["success"] is True
         assert [m["event_id"] for m in result["reply_chain"]] == ["msg-1", "msg-2"]
 
+    async def test_get_message_answers_event_ids_in_the_order_asked(self):
+        """A batch is answered in the order it was requested, not the order the
+        server happened to return — the caller reads the list it wrote."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        by_id = {
+            eid: _make_message(message_id=eid, body=eid) for eid in ("msg-1", "msg-2")
+        }
+        client.batch_get_messages = AsyncMock(
+            side_effect=lambda room_id, event_ids: [
+                by_id[eid] for eid in reversed(event_ids)
+            ]
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {"room_id": "room-1", "event_ids": ["msg-1", "msg-2"]},
+            client=client,
+        )
+        assert [m["event_id"] for m in result["messages"]] == ["msg-1", "msg-2"]
+
+    async def test_get_message_refuses_a_chain_over_several_messages(self):
+        """A chain belongs to one message. With several requested there is no
+        single chain, so the tool says so instead of picking one."""
+        result, _, _ = await self._run(
+            _handle_chatto_get_message,
+            {
+                "room_id": "room-1",
+                "event_ids": ["msg-1", "msg-2"],
+                "follow_reply_chain": 3,
+            },
+        )
+        assert result["success"] is False
+        assert "follow_reply_chain" in result["error"]
+
+    async def test_get_message_refuses_a_chain_depth_out_of_range(self):
+        for bad in (11, -1, "twenty"):
+            result, _, _ = await self._run(
+                _handle_chatto_get_message,
+                {"room_id": "room-1", "event_id": "msg-1", "follow_reply_chain": bad},
+            )
+            assert result["success"] is False, bad
+            assert "follow_reply_chain" in result["error"], bad
+
     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."""
@@ -5145,7 +5262,6 @@ class TestPluginToolHandlers:
         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,
@@ -5161,8 +5277,8 @@ class TestPluginToolHandlers:
         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.get_message = AsyncMock(
+            return_value=_make_message(message_id="msg-1", body="hi")
         )
         client.batch_get_room_members = AsyncMock(
             side_effect=ChattoError("directory refused")
@@ -5276,6 +5392,34 @@ class TestPluginToolHandlers:
         assert "not a thread root" in result["error"]
         client.get_thread_events.assert_not_awaited()
 
+    async def test_get_history_puts_back_a_thread_root_the_page_omits(self):
+        """Whether the server lists the root back as the thread's first event is
+        not something the client signature promises, and a thread read whose
+        opening line is missing is not a thread read. The adapter's own thread
+        seeding makes the same repair."""
+        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",
+            created_at=datetime(2026, 10, 18, 10, 0, tzinfo=UTC),
+        )
+        reply = _make_message(
+            message_id="msg-2",
+            body="reply",
+            thread_root_event_id="msg-1",
+            created_at=datetime(2026, 10, 18, 11, 0, tzinfo=UTC),
+        )
+        client.get_message = AsyncMock(return_value=root)
+        client.get_thread_events = AsyncMock(return_value=self._timeline_page([reply]))
+        result, _, _ = await self._run(
+            _handle_chatto_get_history,
+            {"room_id": "room-1", "thread_root_event_id": "msg-1"},
+            client=client,
+        )
+        assert [m["event_id"] for m in result["messages"]] == ["msg-1", "msg-2"]
+
     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
@@ -5300,6 +5444,18 @@ class TestPluginToolHandlers:
         assert result["success"] is False
         assert "limit" in result["error"]
 
+    async def test_get_history_refuses_a_limit_it_cannot_read(self):
+        """The limit comes from a model, so 0 and "twenty" both arrive. 0 must
+        not quietly become the default page — a wider answer than the caller
+        asked for is its own answer — and a word must be refused rather than
+        raised out of the handler."""
+        for bad in (0, -3, "twenty", 3.5):
+            result, _, _ = await self._run(
+                _handle_chatto_get_history, {"room_id": "room-1", "limit": bad}
+            )
+            assert result["success"] is False, bad
+            assert "limit" in result["error"], bad
+
     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"}