فهرست منبع

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 1 هفته پیش
والد
کامیت
e780e5a4f0
2فایلهای تغییر یافته به همراه339 افزوده شده و 80 حذف شده
  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"}