瀏覽代碼

Merge chatto-tools-toolsets-and-resume: tools, per-room toolsets, restart resume, room context, search

Brings in the branch that extended the plugin's surface and fixed the
report about fresh room messages starting context-free:

- read and pin tools, ephemeral notices, custom status, link preview
- per-room toolsets, replaced rather than subtracted
- realtime cursor persisted across a clean restart, bounded by a window
- fresh room turns seed the room behind them and name what they reply to
- chatto_search_messages, with search states reported instead of "no
  matches"
Paul Klumpp 1 周之前
父節點
當前提交
e5f7875ad2
共有 9 個文件被更改,包括 2400 次插入 和 51 次删除
  1. 53 5
      AGENTS.md
  2. 180 1
      CONCEPTS.md
  3. 49 11
      README.md
  4. 814 20
      adapter.py
  5. 3 11
      after-install.md
  6. 14 0
      platform_config.py
  7. 18 0
      plugin.yaml
  8. 12 1
      skills/chatto-platform-plugin/SKILL.md
  9. 1257 2
      test_adapter.py

+ 53 - 5
AGENTS.md

@@ -223,10 +223,13 @@ changes must keep them, or consciously renegotiate the docs.**
   unexpanded, so neither path needs a roster to recognise them. DMs answer
   regardless so `/join` stays reachable;
   `hermes_validate_config` rejects listing a room on both lists. The sending
-  tools obey the same lists: `chatto_send_room` refuses a channel on neither
-  (a DM room is never gated), because a tool that posted there would break the
-  read-only promise the same paragraph makes. The read tools are deliberately
-  exempt — `chatto_get_message` and `chatto_get_history` post nothing and
+  tools obey the same lists: `chatto_send_room` and `chatto_send_ephemeral`
+  refuse a channel on neither (a DM room is never gated), because a tool that
+  posted there would break the read-only promise the same paragraph makes, and
+  `chatto_pin_message` refuses it too — a pin changes what the room shows
+  everyone even though it posts nothing. The read tools are deliberately
+  exempt — `chatto_get_message`, `chatto_get_history` and
+  `chatto_search_messages` post nothing and
   answer nothing, so reading a room grants no privilege the bot's token did not
   already have and the server's own visibility rules are the only gate.
   `CHATTO_HOME_CHANNEL` is orthogonal: it is the default outbound target for
@@ -280,6 +283,26 @@ changes must keep them, or consciously renegotiate the docs.**
   is no session-reset path on this platform — `/join` and `/leave` are the
   only commands and they ride DMs — so an emptied session cannot strand a
   thread the way it would elsewhere; do not assume one exists.
+- **A fresh room turn opens with the room behind it.** Every fresh channel
+  message anchors a session of its own (`thread_id = message.id` when no
+  inbound thread root — unconditional, not gated on `auto_thread`, which only
+  rules whether the *outgoing* reply opens a server-side thread), so a
+  non-thread turn would otherwise start empty even when the room talked for
+  hours. The turn therefore carries an `[Earlier in this room]` block built
+  from `get_room_events_around`, filtered to messages older than the trigger
+  (`around` serves both directions; a session start is the past, not the
+  future), rendered by the same shared renderer as the thread block
+  (`_earlier_context_block`, same caps and labelling — two renderers would
+  drift until thread and room quoted differently). No cursor and no
+  `_seeded` bookkeeping: every fresh message roots a *new* session, so a
+  cursor written for one session's frontier would describe another session's
+  past; the one re-dispatch path that hits an existing session (an edit
+  restarting a turn) is covered by the `_session_exists` guard, which fails
+  open so a first turn always seeds. DMs never seed (one session per DM).
+  A reply also names what it answers: `reply_to_text`/`reply_to_is_own_message`
+  are resolved via `get_message` on the target — the gateway renders
+  `[Replying to …]` from them — best-effort, an unreadable target costs the
+  pointer, never the turn.
 - **Room roster rides on every channel turn.** Threads hold isolated sessions
   and the agent only sees `channel_context` per dispatch, so each channel turn
   carries the current member roster — repetition is the point, deduplicating
@@ -322,6 +345,18 @@ changes must keep them, or consciously renegotiate the docs.**
 - **Processing reactions.** 👀 while working, then ✅, ❌, or 🚫 (cancelled) —
   driven by `on_processing_start`/`on_processing_complete` and the `reactions`
   setting, never applied to the bot's own events.
+- **Reconnects resume, restarts resume inside a window.** The realtime loop hands
+  `stream_events` the cursor of the last event it finished handling, and advances
+  it only after the handler returns: a raising handler leaves the old cursor so
+  the event is redelivered, and an unreplayable event (`cursor is None`) leaves
+  the previous one intact. `_seen` still suppresses duplicates. A clean
+  `disconnect()` stores the cursor under the profile's home (`state/`), keyed by
+  `_profile_scope_key()`, and `connect()` restores it — but only while it is
+  younger than `CHATTO_RESUME_WINDOW` (default 900 s) and only for the profile
+  that wrote it. A crash stores nothing, and an expired or foreign cursor is
+  refused rather than trusted, so the stream falls back to the live boundary.
+  Do not write on every event: the file is written at shutdown on purpose, and
+  making it per-event needs a throttling decision recorded here first.
 - **Room membership is server-side, commands ride over DMs.** `/join` and
   `/leave` in a DM call RoomService/JoinRoom/LeaveRoom, so membership survives
   restarts; the joined list mirrors it on every `_refresh_rooms()`. Silent
@@ -337,6 +372,16 @@ changes must keep them, or consciously renegotiate the docs.**
   back to `send()` rather than receiving silent truncation. The standalone
   cron sender splits the same way, so out-of-process delivery cannot die on
   the per-message limit either.
+- **Per-room toolsets replace, never subtract.** `toolsets_for_source` answers
+  with exactly the toolsets `CHATTO_ROOM_TOOLSETS` names for the turn's room
+  (`room_id:toolset` entries, accumulated per room; a thread turn carries its
+  room's ID), because Hermes' contract for that hook is a *replacement* of
+  `platform_toolsets`, not a subtraction from it. A room without an entry
+  returns None so the platform list stays in charge. A malformed entry is
+  rejected at startup: ignoring it would leave a room that the user meant to
+  restrict on the default toolsets, which fails open. Toolset *names* are not
+  validated here — Hermes drops unknown ones, so a typo yields fewer tools,
+  which is the safe direction.
 
 ### Auth defaults to closed
 
@@ -534,7 +579,10 @@ Three rules already exist once each, and each answers for both callers:
   into a `SendResult` and `_post_chunks` into a refusal, because those result
   contracts genuinely differ; the *sending* is the same job and must not be.
   `on_posted` is how the adapter marks its own output seen — without it the
-  realtime echo of its own message would come back as input.
+  realtime echo of its own message would come back as input. `chatto_send_ephemeral`
+  is a third caller and posts through `_post_chunks`, so it inherits the split
+  and the partial-failure refusal; a posting tool that called `post_message`
+  directly would be the copy this section exists to prevent.
 - **`_resolve_thread_root`** — "this event ID is a root of a thread in *this*
   room", which both tools taking a `thread_root_event_id` need
   (`_send_room` posting into one, `_get_history` reading one). Two copies

+ 180 - 1
CONCEPTS.md

@@ -6,7 +6,9 @@ to honour. This file explains *why* a few of those rules look the way they do,
 for a reader who wants to understand the design before changing it.
 
 - [Thread sessions and the frontier](#thread-sessions-and-the-frontier)
+- [Realtime resume](#realtime-resume)
 - [Configuration resolution](#configuration-resolution)
+- [Per-room toolsets](#per-room-toolsets)
 - [One send path, two callers](#one-send-path-two-callers)
 
 ## Thread sessions and the frontier
@@ -20,7 +22,9 @@ Meanwhile two kinds of message never become a turn of their own:
   realtime connection, and the adapter drops it as a self-event — correctly, since
   the gateway is already running the turn that produced it.
 - **Anything the gateway did not dispatch**, chiefly a message that arrived while
-  it was down. README documents that there is no backfill.
+  the gateway was down. A reconnect inside a running process resumes from the
+  last applied event cursor, but a restart forgets it and leaves a gap (README
+  documents the boundary).
 
 So a thread can contain messages that no session holds. The reported case: a cron
 job posts a build report into a channel, its echo is filtered, and an hour later
@@ -133,6 +137,110 @@ multiplexed gateway into `agent:main:` and names a session nobody is running.
 - `[Earlier in this thread]` is reference text, not trusted input: only the
   trigger passes the auth gate.
 
+## Room sessions open blind, and why they do not carry a cursor
+
+### The second reported case
+
+The thread seed solved "reply to a cron message opens a thread the agent has
+never seen". The room case is the same failure one level up: a human replies in
+a channel *without* opening a thread, pointing at a message the bot posted. The
+reply roots a session of its own — the anchoring gives every fresh channel
+message `thread_id = message.id`, unconditionally — so the turn is answered by
+an agent that has never seen the room's conversation, nor the bot's own earlier
+answer, nor what the reply points at.
+
+### Two half-fixes, one contract
+
+The fix has two halves, and the user-visible contract is "a session that starts
+here knows what it is about":
+
+1. **The reply pointer.** `MessageEvent.reply_to_text` and
+   `reply_to_is_own_message` are resolved by fetching the target with
+   `get_message`. The gateway renders `[Replying to …]` from them — Signal does
+   the same for quotes — and without them the turn reads as a comment from
+   nowhere even when everything else is in place. One RPC per reply, skipped
+   for non-replies; a deleted or unreadable target costs the pointer, never
+   the turn.
+
+2. **The room block.** `[Earlier in this room]`, built from
+   `get_room_events_around`, filtered to messages older than the trigger.
+   `around` serves both directions of the anchor, and a session start is
+   answered with the past, not the future — a message someone typed while the
+   agent was thinking is not "earlier", and handing it over would answer a
+   question the room may not have asked yet.
+
+### Why no cursor, when the thread seed needs one
+
+The thread seed's whole machinery — `_seeded_threads`, the session copy under
+`chatto.thread_seed_cursor`, the blind pass — exists because a thread session
+*survives across turns*: turn two must know what turn one already delivered, or
+it repeats and skips. A room session does not survive across turns in the same
+sense. Every fresh channel message roots a *new* session, so there is no
+"session frontier" to track: the question each turn asks is "what did the room
+say before this message", and the trigger answers it on its own.
+
+A per-room cursor would be actively wrong here. It would record one session's
+frontier and hand the *next* message's session a block written for the first
+one's — delivering messages the second conversation never saw and skipping
+others. The one dispatch path where the same message *does* hit a session that
+already exists is an edit restarting a turn, and that is covered by the
+`_session_exists` guard (fail-open when no store is wired, so a first turn
+always seeds).
+
+### What this costs
+
+- One extra RPC per first turn into a room: `get_room_events_around`, capped at
+  20 events. The roster fetch already dominates the per-turn budget for a
+  channel turn, and the lookup is skipped entirely for DMs.
+- `[Earlier in this room]` is reference text, not trusted input: only the
+  trigger passes the auth gate, authors off the allowlist are labelled
+  `(unverified)` by the shared renderer, own output `(this bot)`.
+
+## Realtime resume
+
+### The gap
+
+A realtime connection can drop while the gateway keeps running. The server
+offers two ways to continue: resume after a cursor, or start at the live
+boundary. Starting at the boundary is silent data loss — events posted during
+the drop are never delivered, and nothing in the log says so.
+
+`chattolib` exposes the cursor as `RealtimeEvent.cursor`, and its docstring
+defines when a value is safe to retain: after that event has been fully applied,
+and never when the field is `None` (the event cannot be replayed). The adapter
+therefore advances `_realtime_cursor` only after the handler returns, and only
+when the event carried one. Two behaviours follow from that placement rather
+than from extra code: a handler that raises leaves the old cursor in place, so
+the event is redelivered, and an unreplayable event cannot wipe a good position.
+
+### What a restart keeps
+
+Persisting the cursor extends the resume across process restarts, and the piece
+is cheap — one value. The cost is in the *bounds*, which is why three of them
+ship with it:
+
+- **Only a clean stop writes.** `disconnect()` stores the last applied cursor;
+  a crash stores nothing, so the gap it leaves is the same gap that existed
+  before. Writing per event instead would buy crash coverage at the price of a
+  file write on every presence change — a trade this plugin does not make
+  silently (AGENTS.md says so, and the file is deliberately written in one
+  place).
+- **Only a fresh position is used.** `CHATTO_RESUME_WINDOW` (default 900 s)
+  decides how much of an outage may be replayed. Without it, a gateway that was
+  down overnight would answer yesterday's messages on startup; with it, an old
+  position is discarded and the stream starts at the live boundary. That is
+  also what the server would do anyway — chattolib requests
+  `INITIAL_STATE_LIVE_ONLY`, so a cursor it cannot resume degrades to the live
+  boundary rather than failing.
+- **Only the profile that wrote it reads it.** The file lives under the
+  profile's own home and carries `_profile_scope_key()`, the same key the
+  configuration cache and the platform registry use. Under multiplexing, a
+  position written by one profile must never be spent on another profile's
+  stream — the events differ even when the server is the same.
+
+A stored value that fails any of these checks is refused, not repaired: the
+adapter then behaves exactly as it did before the file existed.
+
 ## Configuration resolution
 
 AGENTS.md owns the rules here — one field, three coordinated names, every path
@@ -199,6 +307,44 @@ differently is exactly the drift risk this design usually avoids; the containmen
 that they converge on one type, and the only place that fills it differently is the
 one place where no better information exists.
 
+## Per-room toolsets
+
+### Why the hook exists at all
+
+Hermes has no per-chat tool policy of its own. The adapter hook
+`toolsets_for_source(source)` is the only door: the gateway asks the adapter,
+per turn, which toolsets to enable. The webhook adapter already uses it for
+per-route tool lists, which is the precedent this follows.
+
+### Replacement, not subtraction
+
+The hook's contract is that a returned list *replaces* `platform_toolsets` for
+that turn — the gateway copies the override into the platform's entry and
+resolves it through the same `_get_platform_tools` path. Subtraction would need
+a second decision ("minus these, from what?"), and a room entry that removed
+`terminal` would otherwise silently keep everything else. Replacement makes the
+entry self-contained: what is written is what the room gets. The cost is that a
+user must list every toolset the room keeps, including `chatto-platform` if the
+agent should keep its Chatto tools — which is why README says so in the tool
+documentation and in the variable table rather than leaving it to be discovered.
+
+An unlisted room returns `None`, the hook's "no opinion" answer, so a config
+with no entries changes nothing.
+
+### Keyed on the room, validated at startup
+
+The entry key is `chat_id`, which for this adapter is the room ID the realtime
+event carried — not model input, so it cannot be talked around. A thread turn
+keeps the room as `chat_id` and carries the thread separately, so one entry
+covers a room's threads without a second lookup.
+
+The entry *syntax* is validated at startup because a malformed entry would
+otherwise be skipped, leaving a room the operator meant to restrict on the
+default toolsets — the one failure direction that is invisible in logs and
+dangerous. The toolset *names* are deliberately not validated here: Hermes drops
+unknown ones when it resolves the list, so a typo yields fewer tools, which is
+the failure direction that hides nothing.
+
 ## One send path, two callers
 
 A user cannot tell whether the agent replied on its own or through a tool, so the
@@ -211,3 +357,36 @@ The callers differ only in what they do with the result: `send()` shapes it into
 `SendResult`, `_post_chunks` into a refusal that names which chunk failed and
 lists the IDs that went out — so a retry repeats only what is missing instead of
 duplicating a message the room already has.
+
+## Search: a state, not an empty list
+
+`chatto_search_messages` is the first tool whose feature can be absent on the
+server side while the API call itself still exists. Chatto returns
+`FAILED_PRECONDITION` for a server whose operator left `search.enabled` off and
+`UNAVAILABLE` for one whose provider is not reachable — both arrive at the tool
+as a failed RPC, and neither means "no message contains these words".
+
+Reporting them as an empty result list would be the one wrong answer: the agent
+would conclude the conversation never happened and say so, and the user would go
+looking for a missing message instead of a server setting. So the handler catches
+those two codes and calls `GetSearchStatus`, which stays available while search
+is disabled, and answers with the real state — disabled, starting, indexing
+(with the server's own `retry_after`), degraded, or no provider. The distinction
+that matters to the caller is whether it resolves by itself: indexing does,
+disabled never does.
+
+A hit is deliberately a pointer rather than a record. `MessageSearchResult`
+carries a whole `Message`, so returning `_message_out()` would repeat the
+reaction, attachment and link-preview detail for twenty hits — most of it
+irrelevant to "which of these is the one I meant", and all of it out of the
+agent's context budget. The leaner shape names where the hit is, who said it,
+when and what it says; `chatto_get_message` is the call that reads one in full.
+Thread grouping is the exception: there the row stands for a conversation, so
+the root, reply count and latest reply are part of identifying it.
+
+The tool also does not resolve `author_login` to a user ID. Chatto's own `from:`
+filter in the query does that job with the login the user already knows; a
+login→ID lookup on our side would have to scan member directories of rooms the
+search has not named yet, and `_find_user_by_login` deliberately only looks at
+DM rooms (a bot cannot read the server-wide directory). `author_id` is offered
+for the case where an ID is already in hand.

+ 49 - 11
README.md

@@ -16,6 +16,7 @@ Before setup, here's the part most people want to know: how Hermes behaves once
 | **DMs** | For such a user, Hermes answers every DM without an `@mention`. Each DM has its own session. |
 | **Rooms** | For such a user, opt-in per room: a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` gets answers only when Hermes is addressed (`@mention`, `@all`, `@here`); one listed in `CHATTO_OPTIONAL_MENTION_ROOMS` gets an answer for every message. A room on neither list stays silent — read-only. |
 | **Threads** | If you reply in a thread, Hermes keeps the thread context isolated from the parent room. The first turn into a thread also carries the messages that came before it — including ones the bot itself posted, such as a cron report nobody replied to — so the agent never answers a reply whose thread it cannot read. The bot auto-follows threads it participates in. |
+| **Fresh room messages** | A message posted in a room without a thread opens its own session, so it carries what the room said before it (an `[Earlier in this room]` block, same budget as the thread block), and a reply names what it answers (`[Replying to "…"]`). The agent never answers a room message as if the room had been silent. |
 | **Room awareness** | Every channel turn comes with the member roster attached (who is in the room, with live presence), so Hermes always knows his audience — in every thread, every turn. The roster is fetched once per room and kept fresh from presence events without extra directory lookups; the first 20 members are listed, larger rooms get an "and N more" note. DMs never carry one. |
 | **Processing indicators** | Hermes adds a 👀 reaction when it starts processing a message, and replaces it with ✅ on success, ❌ on failure, or 🚫 when processing was cancelled. |
 | **Editing your messages** | An edit within 5 minutes of posting counts as a correction: while Hermes is still working on the original, he restarts with the edited text (🚫 → 👀); if he had ignored or not yet answered the message, the gates are re-checked against the new text — adding a forgotten `@mention` this way works. Edits to messages Hermes has already answered change nothing. Disable with `CHATTO_EDIT_DISPATCH=false`. |
@@ -43,15 +44,17 @@ Capabilities natively implemented by the Chatto plugin adapter:
 | file attachments | yes (chunked upload) |
 | image / video / audio / documents | yes (native attachments) |
 | DM participation | only once the person has DMed the bot — a bot cannot start a DM on Chatto, see [DM Initiation](#dm-initiation) |
-| plugin tools | yes (`chatto_list_rooms`, `chatto_send_room`, `chatto_send_dm`, `chatto_get_message`, `chatto_get_history` — see [Plugin tools](#plugin-tools)) |
+| plugin tools | yes (fifteen tools covering sending, self-deleting notices, reading, searching, pinning, attachments, custom status, link previews and room groups — see [Plugin tools](#plugin-tools)) |
 | room creation | yes |
 | room join/leave over DM | yes (`/join`, `/leave`) |
 | member directory | yes (cached) |
 | room member context | yes (roster on every channel turn, presence kept live from a per-room cache) |
+| per-room toolsets | yes (`CHATTO_ROOM_TOOLSETS` replaces the platform toolsets for the rooms it lists) |
 | presence broadcasting | yes (online, refreshed) |
+| interactive prompts | yes (reaction-driven clarify + exec approval, see [Interactive Prompts](#interactive-prompts)) |
 | custom status | yes |
 | read state management | yes |
-| auto-reconnect | yes (with duplicate-event suppression) |
+| auto-reconnect | yes (resumes from the last applied event, duplicates suppressed) |
 
 ## Prerequisites
 
@@ -59,6 +62,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 2. **A Chatto bot account** — the adapter authenticates with a bot API key. Create one in Chatto under **Settings → Bot Accounts**.
 3. **Room membership** — the bot account must be a member of any room where you want it to respond; the adapter picks up every room the account has joined. Membership can be granted natively in Chatto (invite the bot account), or by DMing the bot `/join <room-id or #name>` (see [Managing Rooms over DM](#managing-rooms-over-dm)). For DMs, simply start a direct message with the bot.
 4. **Network access** — the Hermes host must reach the Chatto server URL over HTTPS (or HTTP) and establish a WebSocket connection to `/api/realtime`.
+5. **Message search (optional)** — `chatto_search_messages` needs the Chatto server's search feature, which is off by default. Server operators enable it with `[search] enabled = true` and `[search_provider] enabled = true` and restart Chatto; the index is then built from the existing message history, and the server reports **Indexing** until that finishes. Without search, the tool says which state the server is in — off, no provider reachable, or still indexing, with a retry hint — instead of answering "no matches". Every other tool works without it.
 
 > **Info:** The adapter speaks realtime protocol 4 (the revision chattolib sends in the subscribe handshake) and needs a Chatto server that accepts it. Ensure your Chatto server is up to date.
 
@@ -175,10 +179,12 @@ platforms:
 | `CHATTO_ALLOW_ALL_USERS` | No | `false` | Allow any Chatto user to talk to the agent (`true`/`false`) |
 | `CHATTO_REQUIRE_MENTION_ROOMS` | No | _(silent everywhere)_ | Comma-separated room IDs where Hermes answers only when addressed (`@name`, `@all`, `@here`). A room on neither mention list stays silent. |
 | `CHATTO_OPTIONAL_MENTION_ROOMS` | No | — | Comma-separated room IDs where Hermes answers every message, addressed or not. Must not overlap `CHATTO_REQUIRE_MENTION_ROOMS` (rejected at startup). |
+| `CHATTO_ROOM_TOOLSETS` | No | _(no room restricted)_ | Comma-separated `room_id:toolset` entries, e.g. `RbmwiHk4vs9a3Ql:web,RbmwiHk4vs9a3Ql:file`. Several entries for one room accumulate. A listed room **replaces** the platform's toolsets for its turns, so list every toolset it should keep; unlisted rooms keep the normal platform toolsets. A malformed entry is rejected at startup. |
 | `CHATTO_AUTO_THREAD` | No | `true` | Auto-create threads for replies in rooms (`true`/`false`) |
 | `CHATTO_REACTIONS` | No | `true` | 👀/✅/❌/🚫 processing reactions (`true`/`false`) |
 | `CHATTO_EDIT_DISPATCH` | No | `true` | Treat edits of chat messages as corrections: a message being processed restarts with the edited text, an ignored message is re-checked against its new text, already-answered messages stay answered (`true`/`false`) |
 | `CHATTO_EDIT_WINDOW` | No | `300` | How long after posting an edit may still land, in seconds; older edits are ignored |
+| `CHATTO_RESUME_WINDOW` | No | `900` | How long a stream position saved by a clean shutdown may still be used after a gateway restart, in seconds. Inside the window the bot receives what arrived while it was down; an older position is discarded. `0` disables it. A crash leaves no position to resume from. |
 
 ## Start the Gateway
 
@@ -225,6 +231,23 @@ Seeding happens once per session, not per message and not per restart: later tur
 How the plugin keeps track of that is a developer question:
 [CONCEPTS.md](CONCEPTS.md#thread-sessions-and-the-frontier).
 
+#### Room history on the first turn
+
+Every fresh channel message anchors a session of its own (the same anchoring that keeps thread sessions isolated), so a turn that is not inside a thread would otherwise start from nothing — even when the room has been talking for hours and even when the message replies to something the bot itself posted. Every such turn therefore carries what the room said before it, as an `[Earlier in this room]` block with the same shape and the same 20-message / 2000-character budget as the thread block, including messages the bot itself posted, which never enter a session any other way. Direct messages share one session per DM and never seed; messages inside a thread use the thread block instead.
+
+A message that is a reply to another one also names what it answers: the reply pointer `[Replying to "…"]` — `[Replying to your previous message: "…"]` when the answered message is the bot's own — is rendered ahead of the message text. An unreadable or deleted target costs the pointer, never the reply itself.
+
+### Interactive Prompts
+
+The plugin replaces the default text-based clarify and exec-approval
+prompts with reaction-driven ones. A clarify question is posted as a
+numbered list with one emoji reaction per option (1️⃣ … 9️⃣) plus ✏️
+for free-text input; the exec-approval prompt offers ✅ once, 🌀 session,
+♾️ always, ❌ deny. The prompt message is edited to a terminal state
+(e.g. "✅ Alice chose: …") after a reaction resolves it, and timed out
+prompts are retired with a notice. When more than 9 choices are offered
+the adapter falls back to the default text-based prompt.
+
 ### Room Member Roster
 
 Hermes cannot see who else is in a room: unaddressed channel messages are dropped by the mention gates long before they could teach him a name. So every turn in a channel carries the member roster alongside the message text — one entry per member as `@login (Display Name, presence)`, e.g. `@alice (Alice, online), @bob (Bob, offline)`. The bot's own account and deleted users are left out; rooms larger than 20 members end with `… and N more`. Presence is the Chatto status (`online`, `away`, `do not disturb`, `offline`).
@@ -274,12 +297,16 @@ Attachments sent *to* the bot are downloaded into the gateway's media cache (bou
 
 ### Auto-Reconnect
 
-If the realtime stream drops, the adapter reconnects automatically with exponential backoff (1 s → 30 s max, jittered) and honours server-provided retry hints. Events the Chatto server redelivers after a reconnect are recognised by an in-memory event-ID list and processed only once.
+If the realtime stream drops, the adapter reconnects automatically with exponential backoff (1 s → 30 s max, jittered) and honours server-provided retry hints. It hands the server the cursor of the last event it applied, so delivery picks up where it stopped instead of at the live boundary — messages posted during a short outage still arrive. A redelivered event is recognised by an in-memory event-ID list and processed only once. A clean shutdown stores that position, so a restart within `CHATTO_RESUME_WINDOW` (default 900 seconds) also receives what arrived while the gateway was down; a crash stores nothing, and a position older than the window is discarded instead of replaying hours of history.
 
 ### Read State Management
 
 After discovering rooms, the adapter marks joined rooms as read and dismisses pending notifications (`MarkRoomAsRead` / `DismissAllNotifications`), keeping the bot's unread indicators clean.
 
+### Per-Room Toolsets
+
+`CHATTO_ROOM_TOOLSETS` narrows what the agent may do in a named room — useful for a public or untrusted channel where the bot should answer but not reach for the shell. Each entry is `room_id:toolset`, several entries for one room accumulate, and a turn in a listed room gets exactly the toolsets its entries name. This is a **replacement**, not a subtraction: a room listed as `RbmwiHk4vs9a3Ql:web` loses every platform toolset that is normally enabled, including the `chatto-platform` tools, so list everything the room should keep. Rooms without an entry keep the normal platform toolsets. A thread turn belongs to its room and is restricted the same way. A malformed entry stops startup rather than being guessed at — silently leaving a restricted room unrestricted would fail open.
+
 ### DM Initiation
 
 On Chatto only a person can start a direct message — a bot cannot, not even with `message.post`. So a DM room exists only once the other user has written to the bot at least once; until then the bot has no room to post into, and no setting changes that. The adapter therefore has no way to open a DM at all: it joins *rooms* on request (`/join <room-id or #name>` in a DM, see [Managing Rooms over DM](#managing-rooms-over-dm)), and the `chatto_send_dm` plugin tool reports the precondition rather than pretending to deliver.
@@ -379,28 +406,39 @@ 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 five 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 fifteen tools, available to the agent in the gateway and, with `hermes tools`, in the CLI:
 
 | Tool | What it does |
 |------|--------------|
+| `chatto_send_ephemeral` | Posts a notice into a channel that deletes itself after `ttl_seconds` (default 60, range 5-3600), for progress or status updates that should not stay in the timeline. Same room rules as `chatto_send_room`. Deletion is best-effort: a gateway restart before the TTL expires leaves the notice behind. |
 | `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`, answered in the order you list them) 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 that message replies to, oldest-first — use it to resolve a `reply_to=` reference. It works with a single `event_id` only, because a chain belongs to one message. |
 | `chatto_get_history` | Reads the most recent messages of a room (or of one thread, via `thread_root_event_id`), oldest-first, in a page of 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`. 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.
+| `chatto_search_messages` | Full-text search over the messages the bot can read. Use it to find something said earlier when you know the words but not the room, thread or day. The query supports required words, `"exact phrases"`, `AND`, and the filters `in:`, `from:`, `before:`, `after:` and `has:attachment`; it can additionally be scoped by `room_id`/`room_name`, `author_id` and `created_after`/`created_before`. `group_by: thread` returns one row per matching thread with its root, reply count and latest reply; `scope: followed_threads` restricts the search to threads the bot follows. Pages of up to `limit` (1-100, default 20) with `next_cursor` for the next one. Requires the Chatto server operators to have enabled search — see [Prerequisites](#prerequisites). |
+| `chatto_list_room_attachments` | Lists the file attachments of a room with their ID, filename, MIME type and size, in pages of up to `limit` (default 40, offset-paged). `has_more` and `total_count` describe the rest of the list. |
+| `chatto_list_pinned_messages` | Lists a room's pinned messages with their event ID, body, author and posting time, in pages of up to `limit` (default 20, offset-paged). |
+| `chatto_pin_message` | Pins or unpins one message by event ID (`pin: true` or `false`). |
+| `chatto_set_custom_status` | Sets the status shown next to the bot's name in Chatto clients: an `emoji` and `text`, optionally clearing itself after `expires_in_seconds`. |
+| `chatto_delete_custom_status` | Clears the bot's custom status. |
+| `chatto_fetch_link_preview` | Fetches the link preview Chatto's server would show for a URL: title, description, image URL, site name. Fields are empty when the site provides no metadata — that is not an error. |
+| `chatto_mark_thread_as_read` | Marks a thread as read for the bot, by `thread_root_event_id`, and reports the previous read marker. |
+| `chatto_list_room_groups` | Lists the server's room groups with their ID, name, room IDs and sidebar links. |
+
+Five 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` and `chatto_send_ephemeral` only post 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`, `chatto_get_history`, `chatto_search_messages`, `chatto_list_room_attachments`, `chatto_list_pinned_messages` and `chatto_list_room_groups` 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.
+- **Pinning is a write.** `chatto_pin_message` changes what the room shows everyone, so it obeys the same opt-in rule as `chatto_send_room`: an unlisted channel refuses the pin and names both settings. A DM is never gated.
 - **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`. 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.
+- **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`, `chatto_send_ephemeral` and `chatto_pin_message` refuse an unjoined room and say how to get in, because the server would reject the write 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.
 
 Long messages are split at the same limit as every other send (see [Message Splitting](#message-splitting)). If a chunk fails after earlier ones were delivered, the tool reports what it already sent plus a `partial: true` marker, so a retry can repeat only the missing part.
 
-A message the tool posts is the bot's own, so Chatto echoes it back and the adapter drops it as a self-event — it never becomes a turn of its own. In a thread, the next human message still reaches the agent with what it said: each turn seeds the thread with whatever landed since the last turn, so a `chatto_send_room` reply is in the session the next time someone speaks there, and a gateway restart in between does not change that.
+A message the tool posts is the bot's own, so Chatto echoes it back and the adapter drops it as a self-event — it never becomes a turn of its own. In a thread, the next human message still reaches the agent with what it said: each turn seeds the thread with whatever landed since the last turn, so a `chatto_send_room` reply is in the session the next time someone speaks there, and a gateway restart in between does not change that. In a room without a thread, the same holds through the room block: the next message's `[Earlier in this room]` fetch reaches back over the tool's messages too, because they are part of what the room said before that message.
 
 ## Troubleshooting
 
@@ -489,7 +527,7 @@ grep -i "chatto\|websocket\|realtime" ~/.hermes/logs/gateway.log | tail -30
 
 - **Self-hosted friendly**: Works with any self-hosted Chatto instance. No cloud account or subscription required.
 - **Minimal dependencies**: Only requires `chattolib[realtime]==0.5.0b17`, which is vendored — nothing to install.
-- **No message backfill**: Events missed while the gateway is down are gone — Chatto keeps no replay for the bot. Redelivered events after a reconnect are recognised and processed only once; anything genuinely new starts from the moment of reconnection.
+- **Bounded backfill**: while the gateway process runs, a dropped realtime connection resumes from the last event it applied, so messages posted during the gap still arrive. A clean shutdown stores that position under the profile's home, so a restart within `CHATTO_RESUME_WINDOW` (default 900 seconds) also delivers what arrived while the gateway was down. A crash stores nothing, and a stored position older than the window is discarded rather than replaying hours of history. Nothing is handled twice: redelivered events are recognised and dropped.
 
 ## Acknowledgements
 

文件差異過大導致無法顯示
+ 814 - 20
adapter.py


+ 3 - 11
after-install.md

@@ -88,14 +88,6 @@ can live in either file. Environment variables take precedence over
    (answers every message). The `/join` reply prints both lines ready to
    paste, with the room ID filled in.
 
-   Editing works as a correction: fix a typo in your sent message within
-   5 minutes and Hermes restarts his answer with the corrected text. If he had
-   ignored the message because an `@mention` was missing, editing the mention
-   in makes him answer after all.
-
-   The bot can also talk first: ask it to post a summary into a channel it
-   answers in, or to DM someone who has already written to it. Those are the
-   `chatto_send_room` / `chatto_send_dm` tools, which obey the same room rules
-   as inbound messages — a channel listed in neither mention room list stays
-   read-only. A third tool, `chatto_list_rooms`, turns a channel name into the
-   room ID those tools want. `hermes tools` lists all three.
+   That is the first-run path done. Everything beyond answering a message —
+   corrections by editing, the plugin tools, and server-side prerequisites such
+   as message search — is in `README.md`.

+ 14 - 0
platform_config.py

@@ -428,6 +428,20 @@ class ChattoConfiguration:
     # only inbound gate; DMs always answer.
     require_mention_rooms = ConfigField("list")
     optional_mention_rooms = ConfigField("list")
+    # Per-room toolset restriction. Entries look like "room_id:toolset"; several
+    # entries may name the same room and accumulate in the order given. A room
+    # with at least one entry REPLACES the platform's toolset list for turns in
+    # that room (Hermes' contract for toolsets_for_source), so the entries have
+    # to name everything the room keeps. A malformed entry is rejected at
+    # startup rather than guessed at.
+    room_toolsets = ConfigField("list")
+    # How long a cursor persisted by a clean shutdown may still be used, in
+    # seconds. A restart within this window resumes the realtime stream and
+    # receives what arrived while the gateway was down; an older cursor is
+    # discarded so a long outage starts at the live boundary instead of
+    # replaying hours of history. 0 disables the persisted cursor (reconnects
+    # inside one process still resume from memory).
+    resume_window = ConfigField("int", default=900)
     # Auto-thread: by default, Chatto creates a thread for replies to room
     # messages (not DMs, not already in a thread). This keeps conversations
     # organized in the room. Can be disabled via extra.auto_thread=false.

+ 18 - 0
plugin.yaml

@@ -13,9 +13,19 @@ author: Paul Klumpp
 provides_tools:
   - chatto_send_dm
   - chatto_send_room
+  - chatto_send_ephemeral
   - chatto_list_rooms
   - chatto_get_message
   - chatto_get_history
+  - chatto_search_messages
+  - chatto_list_room_attachments
+  - chatto_list_pinned_messages
+  - chatto_pin_message
+  - chatto_set_custom_status
+  - chatto_delete_custom_status
+  - chatto_fetch_link_preview
+  - chatto_mark_thread_as_read
+  - chatto_list_room_groups
 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)"
@@ -52,6 +62,10 @@ optional_env:
     description: "Comma-separated room IDs where the bot answers every message, addressed or not; rooms on neither this nor CHATTO_REQUIRE_MENTION_ROOMS stay silent — read-only, never seeded into context, never answered. (default: none — the listed rooms answer everything)"
     prompt: "Free-response room IDs (comma-separated)"
     password: false
+  - name: CHATTO_ROOM_TOOLSETS
+    description: "Per-room toolset restriction as comma-separated room_id:toolset entries, e.g. RbmwiHk4vs9a3Ql:web,RbmwiHk4vs9a3Ql:file — several entries for one room accumulate. A room with an entry REPLACES the platform's toolset list for its turns, so list every toolset the room should keep; rooms without an entry keep the normal platform toolsets. (default: none — no room is restricted)"
+    prompt: "Per-room toolsets (room_id:toolset, comma-separated)"
+    password: false
   - name: CHATTO_REACTIONS
     description: "Add 👀/✅/❌/🚫 reactions to messages during processing (default: true)"
     prompt: "Enable message reactions? (true/false)"
@@ -63,4 +77,8 @@ optional_env:
   - name: CHATTO_EDIT_WINDOW
     description: "How long after posting an edit may still land, in seconds; older edits are ignored (default: 300)"
     prompt: "Edit window in seconds (or empty for 300)"
+    password: false
+  - name: CHATTO_RESUME_WINDOW
+    description: "How long a stream position saved by a clean shutdown may still be used after a gateway restart, in seconds. Within the window the bot receives what arrived while it was down; an older position is discarded so a long outage does not replay hours of history. 0 disables it, and a crash (no clean shutdown) leaves no position to resume from. (default: 900)"
+    prompt: "Restart resume window in seconds (or empty for 900, 0 disables)"
     password: false

+ 12 - 1
skills/chatto-platform-plugin/SKILL.md

@@ -23,7 +23,7 @@ When in doubt: a lesson written because of one bug is upstream-worthy only if an
 
 ## When to Use
 
-Using or developing/debugging the Chatto gateway plugin: its plugin tools (`chatto_send_dm`, `chatto_send_room`, `chatto_list_rooms`, `chatto_get_message`, `chatto_get_history`) or adding new tools to the plugin. Repo-internal rules (editing, linting, vendoring) live in the plugin root's `AGENTS.md` — see Development workflow.
+Using or developing/debugging the Chatto gateway plugin: its plugin tools (`chatto_send_dm`, `chatto_send_room`, `chatto_list_rooms`, `chatto_get_message`, `chatto_get_history`, `chatto_search_messages`) or adding new tools to the plugin. Repo-internal rules (editing, linting, vendoring) live in the plugin root's `AGENTS.md` — see Development workflow.
 
 ## The plugin tools and when to use them
 
@@ -56,6 +56,17 @@ Resolves the room (same `_resolve_room` as the send tools) and fetches the messa
 
 Reads the newest messages of a room (`client.get_room_events`) or of one thread (`client.get_thread_events`), returned oldest-first (`_oldest_first`, the same ordering the thread seeding uses — the server's own ordering is not a contract). `limit` 1-100 (default 20), `has_older` in the result says whether more history exists. Author logins come from the timeline page's own `users_by_id` directory first; actors missing there are hydrated via `batch_get_room_members`. Same room resolution and the same thread-root rule as `chatto_send_room` — both go through `_resolve_thread_root`, so the two tools cannot disagree about what a root is; a thread page that omits its root gets it prepended (the root is already in hand from that check). Not gated on the mention lists either — the server's own visibility rules decide what the bot may read.
 
+### chatto_search_messages(query, …) — find messages by their words
+
+Full-text search (`client.search_messages`). Use it when you remember the words but not the room, thread or day — `chatto_get_history` pages a window you already know, this finds a needle. Same room resolution as the other tools, and it is likewise not gated on the mention lists.
+
+- **Query syntax is the server's, not ours.** Words are required terms (all must match), `"quoted text"` is an exact phrase, `AND` joins terms, and the filters `in:room-name`, `from:user-login`, `before:DATE`, `after:DATE`, `has:attachment` may be combined with them — including alone.
+- **Structured scoping** via `room_id`/`room_name` (mutually exclusive, resolved through `_resolve_room`), `author_id`, `created_after`/`created_before` (ISO-8601, exclusive bounds) and `has_attachments`. A malformed date is refused rather than dropped: silently ignoring a bound answers a wider question than the one asked.
+- **`order`**: `relevance` (default), `newest`, `thread_activity`, `thread_created`. `thread_activity` requires `group_by: thread` and is refused without it, because that is the server's rule and naming both arguments beats relaying INVALID_ARGUMENT.
+- **`group_by: thread`** returns one row per matching thread with a `thread` block (root event/body, room name, reply count, participant count, last reply, latest reply event/body) instead of one row per message. **`scope: followed_threads`** narrows to threads the bot follows.
+- **Paging**: `limit` 1-100 (default 20), `next_cursor` in the result feeds the next call's `cursor`. The cursor is bound to the caller and to every other query field, and it walks a live index — a page may repeat or skip a hit that moved while paging. Do not reuse a cursor across different queries.
+- **Search is a server feature, off by default.** `FAILED_PRECONDITION` and `UNAVAILABLE` are translated into the real state via `get_search_status()`: disabled (operator has to enable `search.enabled`), starting, indexing (retry — the server gives `retry_after_seconds`), degraded, or no provider reachable. Never report "nothing found" in those cases; the answer says which of them it is. A hit is a pointer, not a record: fetch it with `chatto_get_message` for reactions, attachments and link preview.
+
 ### chatto_send_dm(user_login|user_id, message) — DM a user
 
 Looks up the user by login or ID and posts into the DM room.

+ 1257 - 2
test_adapter.py

@@ -42,7 +42,7 @@ setup_vendor_path()
 
 from chattolib._pb.chatto.realtime.v1.events_pb2 import MessageMention
 from chattolib.client import ChattoClient
-from chattolib.exceptions import ChattoError
+from chattolib.exceptions import ChattoConnectError, ChattoError
 from chattolib.types import (
     Asset,
     AssetUpload,
@@ -52,12 +52,24 @@ from chattolib.types import (
     Message,
     MessageAttachment,
     MessageReaction,
+    MessageSearchGroupBy,
+    MessageSearchOrder,
+    MessageSearchResult,
+    MessageSearchScope,
+    MessageSearchState,
     Page,
+    PinnedMessage,
+    PinnedMessagesPage,
     PresenceStatus,
     Room,
+    RoomGroup,
     RoomKind,
+    RoomSummary,
     RoomViewerState,
     RoomWithViewerState,
+    SearchResultsPage,
+    SearchStatus,
+    ThreadSearchContext,
     ThreadSummary,
     TimelineEvent,
     TimelinePage,
@@ -80,6 +92,7 @@ from adapter import (
     CHATTO_GET_HISTORY_SCHEMA,
     CHATTO_GET_MESSAGE_SCHEMA,
     CHATTO_LIST_ROOMS_SCHEMA,
+    CHATTO_SEARCH_MESSAGES_SCHEMA,
     CHATTO_SEND_DM_SCHEMA,
     CHATTO_SEND_ROOM_SCHEMA,
     ChattoAdapter,
@@ -87,11 +100,21 @@ from adapter import (
     RoomPolicy,
     _capabilities,
     _chatto_config_for_tools,
+    _handle_chatto_delete_custom_status,
+    _handle_chatto_fetch_link_preview,
     _handle_chatto_get_history,
     _handle_chatto_get_message,
+    _handle_chatto_list_pinned_messages,
+    _handle_chatto_list_room_attachments,
+    _handle_chatto_list_room_groups,
     _handle_chatto_list_rooms,
+    _handle_chatto_mark_thread_as_read,
+    _handle_chatto_pin_message,
+    _handle_chatto_search_messages,
     _handle_chatto_send_dm,
+    _handle_chatto_send_ephemeral,
     _handle_chatto_send_room,
+    _handle_chatto_set_custom_status,
     _Mentions,
     _room_answers_in,
     chat_type_for_room_kind,
@@ -482,6 +505,11 @@ class TestRegistration:
         with patch.object(ChattoAdapter, "send_video", BasePlatformAdapter.send_video):
             assert "video" not in _capabilities()
 
+    def test_per_room_toolsets_are_announced(self):
+        """The banner is the only place a startup tells an operator that a
+        per-room restriction exists at all."""
+        assert any("per-room toolsets" in line for line in _capabilities())
+
     def test_readme_yaml_example_is_shaped_like_a_config_hermes_reads(self):
         """The example is what users paste into ~/.hermes/config.yaml. Two ways
         it silently did nothing: `platforms:` nested under `gateway:` (only the
@@ -605,6 +633,16 @@ class TestValidateConfigGates:
         )
         assert validate_config(cfg) is True
 
+    def test_a_malformed_room_toolset_entry_is_rejected(self):
+        """An entry that names a restriction but cannot be read must not leave
+        the room silently on the platform default — that fails open."""
+        cfg = self._cfg(room_toolsets=["room-1:web", "room-2"])
+        assert validate_config(cfg) is False
+
+    def test_well_formed_room_toolsets_are_accepted(self):
+        cfg = self._cfg(room_toolsets=["room-1:web", "room-1:file"])
+        assert validate_config(cfg) is True
+
     @pytest.mark.parametrize("key", ["require_mention_rooms", "optional_mention_rooms"])
     def test_known_keys_do_not_warn(self, key, caplog):
         cfg = self._cfg(**{key: ["room-1"]})
@@ -650,6 +688,48 @@ class TestValidateConfigGates:
         assert not [m for m in caplog.messages if "matches no config field" in m]
 
 
+# -- Per-room toolsets --
+
+
+class TestRoomToolsets:
+    """``CHATTO_ROOM_TOOLSETS`` replaces the platform's toolset list for a room.
+
+    Hermes' contract for ``toolsets_for_source`` is *replacement*, not
+    subtraction, so the answer is exactly what the entries name.
+    """
+
+    def _source(self, chat_id="room-1", chat_type="channel"):
+        from gateway.session import SessionSource
+
+        return SessionSource(
+            platform=Platform(ChattoConstants.PLATFORM_NAME),
+            chat_id=chat_id,
+            chat_type=chat_type,
+        )
+
+    def test_a_listed_room_gets_exactly_the_listed_toolsets(self):
+        adapter = _make_adapter(room_toolsets=["room-1:web", "room-1:file"])
+        assert adapter.toolsets_for_source(self._source()) == ["web", "file"]
+
+    def test_an_unlisted_room_keeps_the_platform_default(self):
+        adapter = _make_adapter(room_toolsets=["room-1:web"])
+        assert adapter.toolsets_for_source(self._source(chat_id="room-2")) is None
+
+    def test_a_thread_turn_is_governed_by_its_room(self):
+        """A thread's source carries the room as ``chat_id`` and the thread
+        separately, so the room's restriction has to reach its threads."""
+        adapter = _make_adapter(room_toolsets=["room-1:web"])
+        assert adapter.toolsets_for_source(self._source(chat_type="thread")) == ["web"]
+
+    def test_a_duplicate_toolset_is_listed_once(self):
+        adapter = _make_adapter(room_toolsets=["room-1:web", "room-1:web"])
+        assert adapter.toolsets_for_source(self._source()) == ["web"]
+
+    def test_no_configuration_means_no_override(self):
+        adapter = _make_adapter()
+        assert adapter.toolsets_for_source(self._source()) is None
+
+
 # -- Send functionality --
 
 
@@ -1204,6 +1284,254 @@ class TestReactionForwarding:
         await adapter._handle_realtime_event(event)
 
 
+# -- Interactive prompt reactions --
+
+
+class TestInteractiveReactionRouting:
+    """A reaction on a prompt card resolves it; the bot's own echo does not.
+
+    Routing sits behind the envelope's self-event filter, so a reaction the
+    bot itself adds can never answer the prompt it decorates — the echo rule
+    and the prompt rule have to hold together, which is what these pin.
+    """
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        adapter = _make_adapter(allowed_users="human-1")
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter.edit_message = AsyncMock()
+        return adapter
+
+    def _event(self, emoji, *, actor_id="human-1", message_id="prompt-1"):
+        event = MagicMock()
+        event.id = f"evt-{emoji}-{actor_id}"
+        event.kind = "reaction_added"
+        event.actor_id = actor_id
+        event.payload = MagicMock(
+            room_id="room-1", message_event_id=message_id, emoji=emoji
+        )
+        return event
+
+    def _clarify(self, adapter):
+        from adapter import _ClarifyPrompt
+
+        prompt = _ClarifyPrompt(
+            clarify_id="clarify-1",
+            choices=["yes", "no"],
+            session_key="session-1",
+            chat_id="room-1",
+            message_id="prompt-1",
+            expires_at=float("inf"),
+        )
+        adapter._clarify_prompts["prompt-1"] = prompt
+        adapter._clarify_id_to_message["clarify-1"] = "prompt-1"
+        return prompt
+
+    async def test_a_numbered_reaction_resolves_the_clarify(self, adapter):
+        self._clarify(adapter)
+        resolve = MagicMock()
+        with patch("tools.clarify_gateway.resolve_gateway_clarify", resolve):
+            await adapter._handle_realtime_event(self._event("2️⃣"))
+
+        resolve.assert_called_once_with("clarify-1", "no")
+        assert "prompt-1" not in adapter._clarify_prompts
+        assert "clarify-1" not in adapter._clarify_id_to_message
+
+    async def test_the_bots_own_reaction_does_not_resolve_its_prompt(self, adapter):
+        """The self-event filter runs before routing, so an echo of the bot's
+        own marker cannot answer the prompt it decorates."""
+        self._clarify(adapter)
+        resolve = MagicMock()
+        with patch("tools.clarify_gateway.resolve_gateway_clarify", resolve):
+            await adapter._handle_realtime_event(
+                self._event("2️⃣", actor_id="bot-user-id")
+            )
+
+        resolve.assert_not_called()
+        assert "prompt-1" in adapter._clarify_prompts
+
+    async def test_an_unauthorized_reaction_leaves_the_prompt_live(self, adapter):
+        """Auth is fail-closed here too: a stranger's reaction is consumed but
+        answers nothing."""
+        self._clarify(adapter)
+        resolve = MagicMock()
+        with patch("tools.clarify_gateway.resolve_gateway_clarify", resolve):
+            await adapter._handle_realtime_event(
+                self._event("2️⃣", actor_id="stranger-9")
+            )
+
+        resolve.assert_not_called()
+        assert "prompt-1" in adapter._clarify_prompts
+
+    async def test_a_reaction_on_an_untracked_message_is_forwarded(self, adapter):
+        """Only prompt cards are consumed — every other reaction still reaches
+        the gateway's reaction hook."""
+        handler = AsyncMock()
+        adapter.set_reaction_handler(handler)
+
+        await adapter._handle_realtime_event(
+            self._event("👍", message_id="ordinary-msg")
+        )
+
+        handler.assert_called_once()
+
+
+# -- Realtime resume --
+
+
+class TestRealtimeResume:
+    """A reconnect resumes from the last applied cursor instead of starting at
+    the live boundary, which would silently skip everything that arrived while
+    the socket was down."""
+
+    def _event(self, cursor):
+        from chattolib.realtime import RealtimeEvent
+
+        return RealtimeEvent(
+            id=f"evt-{cursor}",
+            created_at=None,
+            actor_id="human-1",
+            cursor=cursor,
+            kind="reaction_added",
+            payload=MagicMock(),
+            raw=None,
+        )
+
+    async def _run_loop(self, adapter, passes):
+        """Drive the loop through ``passes`` stream connections, then stop it.
+
+        ``passes`` is a list of event lists, one per connection; the recorded
+        ``resume_cursor`` of each call comes back so the test can assert what
+        the second connection asked the server for.
+        """
+        adapter._closing = False
+        adapter._require_chatto_client = AsyncMock(return_value=adapter._chatto_client)
+        adapter._refresh_rooms = AsyncMock()
+        adapter._handle_realtime_event = AsyncMock()
+
+        resume_cursors: list[str | None] = []
+
+        async def fake_stream(client, *, resume_cursor=None):
+            resume_cursors.append(resume_cursor)
+            for event in passes[len(resume_cursors) - 1]:
+                yield event
+
+        async def stop_after_last_pass(seconds):
+            if len(resume_cursors) >= len(passes):
+                adapter._closing = True
+
+        with (
+            patch("adapter.stream_events", fake_stream),
+            patch.object(adapter, "_sleep_interruptible", stop_after_last_pass),
+        ):
+            await adapter._chattolib_event_loop()
+
+        return resume_cursors
+
+    async def test_reconnect_resumes_from_the_last_event_cursor(self):
+        adapter = _make_adapter()
+        resume_cursors = await self._run_loop(adapter, [[self._event("cur-1")], []])
+        assert resume_cursors == [None, "cur-1"]
+
+    async def test_an_unreplayable_event_keeps_the_previous_cursor(self):
+        """``cursor`` is None for events the server cannot replay; it must not
+        wipe the last good resume point."""
+        adapter = _make_adapter()
+        resume_cursors = await self._run_loop(
+            adapter, [[self._event("cur-1"), self._event(None)], []]
+        )
+        assert resume_cursors == [None, "cur-1"]
+
+
+class TestPersistedRealtimeCursor:
+    """A clean shutdown stores the stream position; a restart inside the window
+    resumes from it instead of starting at the live boundary."""
+
+    def _cfg(self, **extra):
+        return ChattoConfiguration(pconfig=_make_config(token="test-token", **extra))
+
+    def _store(self, monkeypatch, tmp_path, payload):
+        path = tmp_path / "chatto_realtime_cursor.json"
+        monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
+        path.write_text(json.dumps(payload), encoding="utf-8")
+        return path
+
+    def test_a_stored_cursor_survives_a_clean_restart(self, monkeypatch, tmp_path):
+        from adapter import _persist_realtime_cursor, _restore_realtime_cursor
+
+        path = tmp_path / "chatto_realtime_cursor.json"
+        monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
+        _persist_realtime_cursor(self._cfg(), "cur-42")
+        assert _restore_realtime_cursor(self._cfg()) == "cur-42"
+
+    def test_a_cursor_older_than_the_window_is_discarded(self, monkeypatch, tmp_path):
+        """Replaying an outage of hours is worse than losing it: the window is
+        what keeps a restart bounded."""
+        from adapter import _profile_scope_key, _restore_realtime_cursor
+
+        stale = datetime.now(UTC) - timedelta(seconds=1000)
+        self._store(
+            monkeypatch,
+            tmp_path,
+            {
+                "scope": _profile_scope_key(),
+                "cursor": "cur-old",
+                "saved_at": stale.isoformat(),
+            },
+        )
+        assert _restore_realtime_cursor(self._cfg(resume_window=900)) is None
+
+    def test_a_cursor_written_by_another_profile_is_ignored(
+        self, monkeypatch, tmp_path
+    ):
+        from adapter import _restore_realtime_cursor
+
+        self._store(
+            monkeypatch,
+            tmp_path,
+            {
+                "scope": "some-other-profile",
+                "cursor": "cur-other",
+                "saved_at": datetime.now(UTC).isoformat(),
+            },
+        )
+        assert _restore_realtime_cursor(self._cfg()) is None
+
+    def test_an_unreadable_file_means_no_resume(self, monkeypatch, tmp_path):
+        from adapter import _restore_realtime_cursor
+
+        path = tmp_path / "chatto_realtime_cursor.json"
+        monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
+        path.write_text("{not json", encoding="utf-8")
+        assert _restore_realtime_cursor(self._cfg()) is None
+
+    def test_window_zero_disables_the_stored_cursor(self, monkeypatch, tmp_path):
+        from adapter import _persist_realtime_cursor, _restore_realtime_cursor
+
+        path = tmp_path / "chatto_realtime_cursor.json"
+        monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
+        cfg = self._cfg(resume_window=0)
+        _persist_realtime_cursor(cfg, "cur-1")
+        assert not path.exists()
+        assert _restore_realtime_cursor(cfg) is None
+
+    async def test_disconnect_stores_the_last_applied_cursor(
+        self, monkeypatch, tmp_path
+    ):
+        """The shutdown path is the only writer — one small file instead of a
+        write on every presence change."""
+        path = tmp_path / "chatto_realtime_cursor.json"
+        monkeypatch.setattr("adapter._realtime_cursor_file", lambda: path)
+        adapter = _make_adapter()
+        adapter._chatto_client = None
+        adapter._realtime_cursor = "cur-final"
+
+        await adapter.disconnect()
+
+        payload = json.loads(path.read_text(encoding="utf-8"))
+        assert payload["cursor"] == "cur-final"
+
+
 # -- chat_type mapping --
 
 
@@ -2490,6 +2818,20 @@ def _serve_thread(adapter, messages, limit=None):
     )
 
 
+def _serve_room_timeline(adapter, messages):
+    """Point ``get_room_events_around`` at a room's timeline, oldest first.
+
+    Modelled on the server's own handler for ``GetRoomEventsAround``: it serves
+    the whole room timeline around the anchor, and the adapter's room seed
+    filters both ends (newer messages and the trigger itself) from it.
+    """
+
+    async def around(room_id, event_id, *, limit=None):
+        return TimelinePage(events=[_thread_event(m) for m in messages]), 0
+
+    adapter._chatto_client.get_room_events_around = AsyncMock(side_effect=around)
+
+
 class _FakeSessionStore:
     """An in-memory session store with the real store's shape.
 
@@ -2526,6 +2868,276 @@ class _FakeSessionStore:
         return True
 
 
+class TestRoomContext:
+    """A fresh room message roots its own session, so the first turn into a
+    room starts empty even when the room has been talking for hours.
+
+    The reported case: a human replies to the bot's message in a channel,
+    without opening a thread. The agent answers with no idea what the reply
+    refers to — the reply pointer names the message, but nothing carried what
+    that message said.
+    """
+
+    def _adapter(self):
+        adapter = _make_adapter()
+        adapter.chatto_config.allow_all_users.value = True
+        adapter.me = _make_user("bot-user-id", "hermes_bot")
+        adapter._user_cache["user-1"] = _roster_user("user-1", "alice")
+        # The roster rides on every channel turn; an AsyncMock without a
+        # roster shape would answer a list of IDs, not the (ids, Page) pair.
+        adapter._chatto_client.list_room_members = AsyncMock(
+            return_value=(["user-1"], Page())
+        )
+        adapter._chatto_client.batch_get_room_members = AsyncMock(
+            return_value=[DirectoryMember(user=_roster_user("user-1", "alice"))]
+        )
+        adapter.handle_message = AsyncMock()
+        return adapter
+
+    def _seed_channel(self, adapter, room_id="room-1"):
+        adapter.chatto_config.require_mention_rooms.value = [room_id]
+        adapter._room_kinds[room_id] = RoomKind.CHANNEL
+
+    async def _dispatch(
+        self,
+        adapter,
+        body,
+        *,
+        room_id="room-1",
+        message_id="msg-9",
+        created_at=None,
+        reply_to="",
+        reply_target=None,
+    ):
+        payload = _make_posted_payload(room_id=room_id, message_event_id=message_id)
+        message = _make_message(
+            body=body,
+            room_id=room_id,
+            message_id=message_id,
+            created_at=created_at,
+        )
+        message.in_reply_to = reply_to
+        if reply_target is not None:
+            # One get_message per call: the trigger first, then the reply
+            # target — the order the dispatch path asks in.
+            adapter._chatto_client.get_message = AsyncMock(
+                side_effect=[message, reply_target]
+            )
+        else:
+            _with_get_message(adapter, payload, message)
+        await adapter._dispatch_message_posted(payload, payload.message_event_id)
+
+    def _dispatched_event(self, adapter):
+        return adapter.handle_message.await_args.args[0]
+
+    async def test_a_room_reply_opens_with_the_room_behind_it(self):
+        """The reported bug: the first turn into a room must not be answered
+        as a conversation the agent has never seen."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        # A conversation the room had before this turn, ending in an answer
+        # the bot itself posted — whose echo never reaches a session.
+        past = _make_message(
+            body="Deploy finished at noon",
+            message_id="msg-1",
+            actor_id="bot-user-id",
+            created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
+        )
+        trigger = _make_message(
+            body="@hermes_bot which build?",
+            message_id="msg-9",
+            created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
+        )
+        _serve_room_timeline(adapter, [past, trigger])
+
+        await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
+
+        context = self._dispatched_event(adapter).channel_context or ""
+        assert "Deploy finished at noon" in context
+
+    async def test_the_block_names_the_room(self):
+        """The renderer is shared with the thread seed, so the header is the
+        one difference — a room block must say room, not thread."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        past = _make_message(
+            body="Deploy finished at noon",
+            message_id="msg-1",
+            created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
+        )
+        trigger = _make_message(
+            body="@hermes_bot which build?",
+            message_id="msg-9",
+            created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
+        )
+        _serve_room_timeline(adapter, [past, trigger])
+
+        await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
+
+        context = self._dispatched_event(adapter).channel_context or ""
+        assert "[Earlier in this room]" in context
+        assert "[Earlier in this thread]" not in context
+
+    async def test_a_message_newer_than_the_trigger_is_not_earlier(self):
+        """``around`` serves both directions; the block is the room's past,
+        not its future."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        trigger = _make_message(
+            body="@hermes_bot which build?",
+            message_id="msg-9",
+            created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
+        )
+        newer = _make_message(
+            body="Typed while the agent was thinking",
+            message_id="msg-10",
+            created_at=datetime(2026, 10, 22, 12, 6, tzinfo=UTC),
+        )
+        _serve_room_timeline(adapter, [trigger, newer])
+
+        await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
+
+        context = self._dispatched_event(adapter).channel_context or ""
+        assert "Typed while the agent was thinking" not in context
+
+    async def test_an_edit_restart_does_not_reseed(self):
+        """The session guard protects the one path where the same message id
+        is dispatched twice: an edit that restarts a turn already running,
+        whose session now exists. The restart must not carry the room's
+        history a second time — the session holds the original turn."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter.set_session_store(_FakeSessionStore())
+        store = adapter._session_store
+        past = _make_message(
+            body="Deploy finished at noon",
+            message_id="msg-1",
+            created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
+        )
+        trigger = _make_message(
+            body="@hermes_bot which build?",
+            message_id="msg-9",
+            created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
+        )
+        _serve_room_timeline(adapter, [past, trigger])
+
+        await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
+        assert "Deploy finished at noon" in (
+            self._dispatched_event(adapter).channel_context or ""
+        )
+
+        # The gateway has run the turn now, so the session exists — the same
+        # message dispatched again (what an edit-restart does) must not carry
+        # the room's past into a session that already holds it.
+        session_key = adapter._session_key_for(self._dispatched_event(adapter).source)
+        store.create_session(session_key)
+        await self._dispatch(adapter, trigger.body, created_at=trigger.created_at)
+
+        context = self._dispatched_event(adapter).channel_context or ""
+        assert "Deploy finished at noon" not in context
+
+    async def test_a_failed_lookup_leaves_the_turn_running(self):
+        """Best-effort, like the thread seed: the block is context, and its
+        absence must not cost the turn itself."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        adapter._chatto_client.get_room_events_around = AsyncMock(
+            side_effect=ChattoError("boom")
+        )
+
+        await self._dispatch(adapter, "@hermes_bot help")
+
+        assert adapter.handle_message.await_count == 1
+
+    async def test_a_reply_names_what_it_answers(self):
+        """The other half of the report: a reply to the bot's message must
+        reach the agent as a reply, not as a comment from nowhere. The
+        gateway renders the pointer from ``reply_to_text`` — unfilled, the
+        turn reads as a fresh conversation about a quote that is not there."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        target = _make_message(
+            body="Build 4711 is green, logs attached",
+            message_id="msg-1",
+            actor_id="bot-user-id",
+        )
+        trigger = _make_message(
+            body="@hermes_bot and the artifacts?",
+            message_id="msg-9",
+            created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
+        )
+        _serve_room_timeline(adapter, [target, trigger])
+
+        await self._dispatch(
+            adapter,
+            trigger.body,
+            created_at=trigger.created_at,
+            reply_to="msg-1",
+            reply_target=target,
+        )
+
+        event = self._dispatched_event(adapter)
+        assert event.reply_to_message_id == "msg-1"
+        assert event.reply_to_text == "Build 4711 is green, logs attached"
+        assert event.reply_to_is_own_message is True
+
+    async def test_a_reply_to_someone_else_is_not_own(self):
+        """``reply_to_is_own_message`` is what makes the gateway say "your
+        previous message" — claiming a colleague's message as the bot's
+        would be worse than saying nothing."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        target = _make_message(
+            body="I pushed a fix for that",
+            message_id="msg-1",
+            actor_id="user-2",
+        )
+        trigger = _make_message(
+            body="@hermes_bot can you review?",
+            message_id="msg-9",
+            created_at=datetime(2026, 10, 22, 12, 5, tzinfo=UTC),
+        )
+        _serve_room_timeline(adapter, [target, trigger])
+
+        await self._dispatch(
+            adapter,
+            trigger.body,
+            created_at=trigger.created_at,
+            reply_to="msg-1",
+            reply_target=target,
+        )
+
+        event = self._dispatched_event(adapter)
+        assert event.reply_to_text == "I pushed a fix for that"
+        assert event.reply_to_is_own_message is False
+
+    async def test_an_unreadable_reply_target_costs_the_pointer_not_the_turn(self):
+        """Best-effort like the room block: a deleted or unreadable target
+        leaves the pointer empty, and the reply is still dispatched."""
+        adapter = self._adapter()
+        self._seed_channel(adapter)
+        _serve_room_timeline(adapter, [])
+        deleted = _make_message(
+            body="Build 4711 is green, logs attached",
+            message_id="msg-gone",
+            created_at=datetime(2026, 10, 22, 12, 0, tzinfo=UTC),
+        )
+        deleted.deleted_at = datetime(2026, 10, 22, 12, 2, tzinfo=UTC)
+
+        await self._dispatch(
+            adapter,
+            "@hermes_bot hi",
+            reply_to="msg-gone",
+            reply_target=deleted,
+        )
+
+        event = self._dispatched_event(adapter)
+        assert event.reply_to_message_id == "msg-gone"
+        assert event.reply_to_text is None
+        assert event.reply_to_is_own_message is False
+        assert adapter.handle_message.await_count == 1
+
+
 class TestThreadContext:
     """A thread session starts empty, so the first turn into a Chatto thread
     seeds the messages that came before it.
@@ -4447,8 +5059,16 @@ class TestPluginToolSchemas:
             CHATTO_SEND_ROOM_SCHEMA,
             CHATTO_GET_MESSAGE_SCHEMA,
             CHATTO_GET_HISTORY_SCHEMA,
+            CHATTO_SEARCH_MESSAGES_SCHEMA,
+        ],
+        ids=[
+            "list_rooms",
+            "send_dm",
+            "send_room",
+            "get_message",
+            "get_history",
+            "search_messages",
         ],
-        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
@@ -4465,9 +5085,19 @@ class TestPluginToolSchemas:
         assert [tool["name"] for tool in ctx.registered_tools] == [
             "chatto_send_dm",
             "chatto_send_room",
+            "chatto_send_ephemeral",
             "chatto_list_rooms",
             "chatto_get_message",
             "chatto_get_history",
+            "chatto_search_messages",
+            "chatto_list_room_attachments",
+            "chatto_list_pinned_messages",
+            "chatto_pin_message",
+            "chatto_set_custom_status",
+            "chatto_delete_custom_status",
+            "chatto_fetch_link_preview",
+            "chatto_mark_thread_as_read",
+            "chatto_list_room_groups",
         ]
         for tool in ctx.registered_tools:
             assert tool["is_async"] is True, tool["name"]
@@ -4501,6 +5131,15 @@ class TestPluginToolHandlers:
         client.batch_get_room_members = AsyncMock(return_value=[])
         client.follow_thread = AsyncMock()
         client.start_dm = AsyncMock()
+        client.list_room_attachments = AsyncMock(return_value=([], Page()))
+        client.list_pinned_messages = AsyncMock(return_value=PinnedMessagesPage())
+        client.pin_message = AsyncMock()
+        client.unpin_message = AsyncMock(return_value=True)
+        client.set_custom_status = AsyncMock(return_value={})
+        client.delete_custom_status = AsyncMock(return_value={})
+        client.fetch_link_preview = AsyncMock(return_value=(None, ""))
+        client.mark_thread_as_read = AsyncMock(return_value=None)
+        client.list_room_groups = AsyncMock(return_value=[])
         client.post_message = AsyncMock(
             side_effect=lambda *a, **kw: _make_message(
                 message_id=f"msg-{client.post_message.await_count}"
@@ -5613,3 +6252,619 @@ class TestPluginToolHandlers:
         result = json.loads(raw)
         assert result["success"] is True
         client.post_message.assert_awaited()
+
+    # -- chatto_search_messages --
+
+    @staticmethod
+    def _search_page(results, *, next_cursor="", thread_total_count=None, users=None):
+        """A SearchResultsPage as the server returns one."""
+        return SearchResultsPage(
+            results=results,
+            next_cursor=next_cursor,
+            thread_total_count=thread_total_count,
+            users_by_id=dict(users or {}),
+        )
+
+    @staticmethod
+    def _hit(message, relevance=0.9, thread_context=None):
+        return MessageSearchResult(
+            message=message, relevance_score=relevance, thread_context=thread_context
+        )
+
+    async def test_search_returns_hits_with_their_room_and_author(self):
+        """A hit is a pointer: it needs to say where it is, who said it and
+        what it says, or the caller cannot decide which one to read."""
+        client = self._stub_client()
+        message = _make_message(
+            message_id="msg-7",
+            room_id="room-1",
+            body="the deploy finished at noon",
+            actor_id="user-7",
+            created_at=datetime(2026, 10, 20, 12, 0, tzinfo=UTC),
+        )
+        client.search_messages = AsyncMock(
+            return_value=self._search_page(
+                [self._hit(message)],
+                users={"user-7": _make_user("user-7", "seven")},
+            )
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages, {"query": "deploy noon"}, client=client
+        )
+
+        assert result["success"] is True
+        assert result["count"] == 1
+        hit = result["results"][0]
+        assert hit["event_id"] == "msg-7"
+        assert hit["room_id"] == "room-1"
+        assert hit["author_login"] == "seven"
+        assert hit["body"] == "the deploy finished at noon"
+        assert hit["relevance"] == 0.9
+
+    async def test_search_passes_every_filter_through(self):
+        """The server owns the filtering; the tool's job is to hand it over
+        intact rather than second-guess it."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.search_messages = AsyncMock(return_value=self._search_page([]))
+
+        await self._run(
+            _handle_chatto_search_messages,
+            {
+                "query": "deploy",
+                "room_id": "room-1",
+                "author_id": "user-7",
+                "created_after": "2026-10-01",
+                "created_before": "2026-10-31T00:00:00Z",
+                "has_attachments": True,
+                "order": "newest",
+                "group_by": "thread",
+                "scope": "followed_threads",
+                "limit": 5,
+                "cursor": "cur-1",
+            },
+            client=client,
+        )
+
+        kwargs = client.search_messages.await_args.kwargs
+        assert kwargs["room_id"] == "room-1"
+        assert kwargs["author_id"] == "user-7"
+        assert kwargs["created_after"] == datetime(2026, 10, 1, tzinfo=UTC)
+        assert kwargs["created_before"] == datetime(2026, 10, 31, 0, 0, tzinfo=UTC)
+        assert kwargs["has_attachments"] is True
+        assert kwargs["order"] is MessageSearchOrder.NEWEST
+        assert kwargs["group_by"] is MessageSearchGroupBy.THREAD
+        assert kwargs["scope"] is MessageSearchScope.FOLLOWED_THREADS
+        assert kwargs["page_size"] == 5
+        assert kwargs["cursor"] == "cur-1"
+
+    async def test_search_resolves_a_room_name_before_searching(self):
+        """A name is not an ID: the room has to be resolved first, and the
+        server is then scoped by the ID it stands for."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.list_rooms = AsyncMock(return_value=[_make_room_state(room)])
+        client.search_messages = AsyncMock(return_value=self._search_page([]))
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "room_name": "Ops"},
+            client=client,
+        )
+
+        assert result["room_id"] == "room-1"
+        assert client.search_messages.await_args.kwargs["room_id"] == "room-1"
+
+    async def test_search_answers_a_disabled_feature_with_the_reason(self):
+        """Search off is not "no matches". The server says FAILED_PRECONDITION
+        and GetStatus says why — an empty result list would be a lie."""
+        client = self._stub_client()
+        client.search_messages = AsyncMock(
+            side_effect=ChattoConnectError("failed_precondition", "search disabled")
+        )
+        client.get_search_status = AsyncMock(
+            return_value=SearchStatus(state=MessageSearchState.DISABLED)
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages, {"query": "deploy"}, client=client
+        )
+
+        assert result["success"] is False
+        assert "turned off" in result["error"]
+        assert "search.enabled" in result["error"]
+
+    async def test_search_reports_a_building_index_with_its_retry_hint(self):
+        """Indexing resolves itself, so the caller must not be told the feature
+        is broken — it should retry, and the server says when."""
+        client = self._stub_client()
+        client.search_messages = AsyncMock(
+            side_effect=ChattoConnectError("unavailable", "provider not ready")
+        )
+        client.get_search_status = AsyncMock(
+            return_value=SearchStatus(
+                state=MessageSearchState.INDEXING, retry_after_seconds=30
+            )
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages, {"query": "deploy"}, client=client
+        )
+
+        assert result["success"] is False
+        assert "building its index" in result["error"]
+        assert "30s" in result["error"]
+
+    async def test_search_relays_a_real_error_instead_of_blaming_the_feature(self):
+        """Only the two search-state codes mean "the feature is not ready". A
+        permission error is the caller's problem and must not be dressed up as
+        a disabled feature — that would send them to the operator for nothing."""
+        client = self._stub_client()
+        client.search_messages = AsyncMock(
+            side_effect=ChattoConnectError("permission_denied", "no")
+        )
+        client.get_search_status = AsyncMock()
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages, {"query": "deploy"}, client=client
+        )
+
+        assert result["success"] is False
+        assert "Chatto API error" in result["error"]
+        client.get_search_status.assert_not_awaited()
+
+    async def test_search_reports_thread_grouping_with_the_thread_it_stands_for(self):
+        """With group_by=thread a row is a whole conversation, so the root and
+        the latest reply are what makes it readable."""
+        client = self._stub_client()
+        root = _make_message(message_id="msg-1", body="deploy question")
+        reply = _make_message(
+            message_id="msg-2", body="deploy answer", thread_root_event_id="msg-1"
+        )
+        context = ThreadSearchContext(
+            root_message=root,
+            room=RoomSummary(id="room-1", name="Ops"),
+            thread=ThreadSummary(
+                thread_root_event_id="msg-1",
+                reply_count=2,
+                participant_count=2,
+                last_reply_at=datetime(2026, 10, 20, 13, 0, tzinfo=UTC),
+            ),
+            latest_reply=reply,
+        )
+        client.search_messages = AsyncMock(
+            return_value=self._search_page(
+                [self._hit(root, thread_context=context)], thread_total_count=1
+            )
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "group_by": "thread"},
+            client=client,
+        )
+
+        assert result["grouped_by_thread"] is True
+        assert result["thread_total_count"] == 1
+        thread = result["results"][0]["thread"]
+        assert thread["root_event_id"] == "msg-1"
+        assert thread["root_body"] == "deploy question"
+        assert thread["room_name"] == "Ops"
+        assert thread["reply_count"] == 2
+        assert thread["latest_reply_event_id"] == "msg-2"
+
+    async def test_search_pages_with_the_cursor_it_was_given(self):
+        """The cursor is this tool's only way past one page, so it has to come
+        back out as next_cursor — and be usable as an argument again."""
+        client = self._stub_client()
+        client.search_messages = AsyncMock(
+            return_value=self._search_page([], next_cursor="cur-2")
+        )
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "cursor": "cur-1"},
+            client=client,
+        )
+
+        assert result["next_cursor"] == "cur-2"
+        assert client.search_messages.await_args.kwargs["cursor"] == "cur-1"
+
+    async def test_search_refuses_an_empty_query(self):
+        """The schema marks it required, but a model can still send "" — an
+        empty query would search for nothing and return everything."""
+        result, _, _ = await self._run(_handle_chatto_search_messages, {"query": "   "})
+
+        assert result["success"] is False
+        assert "query is required" in result["error"]
+
+    async def test_search_refuses_thread_activity_order_without_thread_grouping(self):
+        """The server rejects this pair; naming both arguments is more useful
+        than relaying INVALID_ARGUMENT."""
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "order": "thread_activity"},
+        )
+
+        assert result["success"] is False
+        assert "requires group_by" in result["error"]
+
+    async def test_search_refuses_a_malformed_date_instead_of_ignoring_it(self):
+        """Silently dropping a bound would answer a wider question than the one
+        asked, which reads as a correct answer."""
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {"query": "deploy", "created_after": "last tuesday"},
+        )
+
+        assert result["success"] is False
+        assert "created_after" in result["error"]
+
+    async def test_search_refuses_an_inverted_time_window(self):
+        client = self._stub_client()
+
+        result, _, _ = await self._run(
+            _handle_chatto_search_messages,
+            {
+                "query": "deploy",
+                "created_after": "2026-10-31",
+                "created_before": "2026-10-01",
+            },
+            client=client,
+        )
+
+        assert result["success"] is False
+        assert "earlier" in result["error"]
+
+    # -- chatto_list_room_attachments --
+
+    async def test_list_room_attachments_reports_the_server_page(self):
+        """The attachment list is pageable server-side, so the answer has to
+        carry the page it came from — ``has_more`` is what tells the agent to
+        ask again with a larger offset."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.list_room_attachments = AsyncMock(
+            return_value=(
+                [{"id": "asset-1", "filename": "a.png", "contentType": "image/png"}],
+                Page(total_count=10, has_more=True),
+            )
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_list_room_attachments,
+            {"room_id": "room-1"},
+            client=client,
+        )
+        assert result["success"] is True
+        assert result["count"] == 1
+        assert result["has_more"] is True
+        assert result["total_count"] == 10
+        assert result["attachments"][0]["filename"] == "a.png"
+
+    # -- chatto_list_pinned_messages --
+
+    async def test_list_pinned_messages_hydrates_authors_in_one_lookup(self):
+        """Pinned messages carry only actor IDs. The directory is asked once
+        for the whole page, not once per pinned message."""
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.list_pinned_messages = AsyncMock(
+            return_value=PinnedMessagesPage(
+                pinned_messages=[
+                    PinnedMessage(
+                        message=_make_message(message_id="msg-1", actor_id="user-1")
+                    ),
+                    PinnedMessage(
+                        message=_make_message(message_id="msg-2", actor_id="user-2")
+                    ),
+                ],
+                page=Page(total_count=2, has_more=False),
+            )
+        )
+        client.batch_get_room_members = AsyncMock(
+            return_value=[
+                DirectoryMember(user=_make_user("user-1", "alice")),
+                DirectoryMember(user=_make_user("user-2", "bob")),
+            ]
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_list_pinned_messages,
+            {"room_id": "room-1"},
+            client=client,
+        )
+        assert [p["event_id"] for p in result["pinned_messages"]] == ["msg-1", "msg-2"]
+        assert [p["author_login"] for p in result["pinned_messages"]] == [
+            "alice",
+            "bob",
+        ]
+        assert client.batch_get_room_members.await_count == 1
+
+    # -- chatto_pin_message --
+
+    async def test_pin_message_refuses_a_read_only_room(self):
+        """Pinning changes what the room shows everyone, so it is a write: the
+        read-only promise for unlisted channels has to hold here too."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_pin_message,
+            {"room_id": "room-1", "event_id": "msg-1", "pin": True},
+            client=client,
+        )
+        assert result["success"] is False
+        assert "read-only" in result["error"]
+        client.pin_message.assert_not_awaited()
+
+    async def test_pin_message_pins_in_an_opted_in_channel(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_pin_message,
+            {"room_id": "room-1", "event_id": "msg-1", "pin": True},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["action"] == "pinned"
+        client.pin_message.assert_awaited_once_with("room-1", "msg-1")
+
+    async def test_pin_message_unpins(self):
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        result, _, _ = await self._run(
+            _handle_chatto_pin_message,
+            {"room_id": "room-1", "event_id": "msg-1", "pin": False},
+            client=client,
+            optional_mention_rooms="room-1",
+        )
+        assert result["action"] == "unpinned"
+        assert result["unpinned"] is True
+        client.unpin_message.assert_awaited_once_with("room-1", "msg-1")
+
+    # -- chatto_send_ephemeral --
+
+    async def test_send_ephemeral_posts_then_schedules_the_ttl(self):
+        """The handler answers before the notice is gone: deletion is a
+        scheduled task, not something the tool call waits out."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        schedule = MagicMock()
+        with patch("adapter._schedule_ephemeral_deletion", schedule):
+            result, client, _ = await self._run(
+                _handle_chatto_send_ephemeral,
+                {"room_id": "room-1", "message": "working", "ttl_seconds": 30},
+                client=client,
+                optional_mention_rooms="room-1",
+            )
+
+        assert result["success"] is True
+        assert result["expires_in_seconds"] == 30
+        client.post_message.assert_awaited_once()
+        schedule.assert_called_once()
+        _cfg, room_id, event_ids, ttl = schedule.call_args.args
+        assert (room_id, event_ids, ttl) == ("room-1", ["msg-1"], 30)
+
+    async def test_send_ephemeral_refuses_a_read_only_room(self):
+        """It posts, so an unlisted channel stays closed to it too."""
+        room = _make_room("room-1", "Ops", RoomKind.CHANNEL)
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        schedule = MagicMock()
+        with patch("adapter._schedule_ephemeral_deletion", schedule):
+            result, _, _ = await self._run(
+                _handle_chatto_send_ephemeral,
+                {"room_id": "room-1", "message": "working"},
+                client=client,
+            )
+
+        assert result["success"] is False
+        assert "read-only" in result["error"]
+        client.post_message.assert_not_awaited()
+        schedule.assert_not_called()
+
+    async def test_delete_after_ttl_removes_every_chunk(self):
+        """A split notice is several messages; the TTL covers all of them."""
+        from adapter import _delete_after_ttl
+
+        client = self._stub_client()
+        client.delete_message = AsyncMock()
+        cfg = ChattoConfiguration(pconfig=_make_config(token="test-token"))
+        with patch("adapter.ChattoClient", return_value=client):
+            await _delete_after_ttl(cfg, "room-1", ["msg-1", "msg-2"], 0)
+
+        assert [call.args for call in client.delete_message.await_args_list] == [
+            ("room-1", "msg-1"),
+            ("room-1", "msg-2"),
+        ]
+        client.close.assert_awaited_once()
+
+    async def test_a_failed_delete_is_swallowed_and_the_rest_still_go(self):
+        """The notice was already delivered; a delete that fails is not an
+        error the caller could act on, and one chunk must not strand the rest."""
+        from adapter import _delete_after_ttl
+
+        client = self._stub_client()
+        client.delete_message = AsyncMock(side_effect=[ChattoError("gone"), None])
+        cfg = ChattoConfiguration(pconfig=_make_config(token="test-token"))
+        with patch("adapter.ChattoClient", return_value=client):
+            await _delete_after_ttl(cfg, "room-1", ["msg-1", "msg-2"], 0)
+
+        assert client.delete_message.await_count == 2
+
+    # -- custom status --
+
+    async def test_set_custom_status_passes_emoji_and_text(self):
+        result, client, _ = await self._run(
+            _handle_chatto_set_custom_status,
+            {"emoji": "🛠️", "text": "processing"},
+        )
+        assert result["success"] is True
+        assert result["emoji"] == "🛠️"
+        client.set_custom_status.assert_awaited_once_with(
+            "🛠️", "processing", expires_at=None
+        )
+
+    async def test_set_custom_status_turns_a_ttl_into_an_expiry(self):
+        result, client, _ = await self._run(
+            _handle_chatto_set_custom_status,
+            {"emoji": "⏳", "text": "waiting", "expires_in_seconds": 60},
+        )
+        assert result["expires_at"] is not None
+        assert client.set_custom_status.call_args.kwargs["expires_at"] is not None
+
+    async def test_delete_custom_status_clears_it(self):
+        result, client, _ = await self._run(_handle_chatto_delete_custom_status, {})
+        assert result["success"] is True
+        client.delete_custom_status.assert_awaited_once()
+
+    # -- chatto_fetch_link_preview --
+
+    async def test_fetch_link_preview_returns_the_metadata(self):
+        client = self._stub_client()
+        client.fetch_link_preview = AsyncMock(
+            return_value=(
+                LinkPreview(
+                    url="https://example.com",
+                    title="Example",
+                    description="An example page",
+                    image_url="https://example.com/preview.png",
+                    site_name="Example",
+                ),
+                "preview-token",
+            )
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_fetch_link_preview,
+            {"url": "https://example.com"},
+            client=client,
+        )
+        assert result["title"] == "Example"
+        assert result["image_url"] == "https://example.com/preview.png"
+        client.fetch_link_preview.assert_awaited_once_with("https://example.com")
+
+    async def test_fetch_link_preview_answers_empty_metadata_as_nulls(self):
+        """A URL the server could not enrich is not an error: the fields come
+        back null so the agent can still post the link."""
+        result, _, _ = await self._run(
+            _handle_chatto_fetch_link_preview, {"url": "https://example.com"}
+        )
+        assert result["success"] is True
+        assert result["title"] is None
+
+    # -- chatto_mark_thread_as_read --
+
+    async def test_mark_thread_as_read_reports_the_previous_marker(self):
+        room = self._readable_room()
+        client = self._stub_client()
+        client.get_room = AsyncMock(return_value=_make_room_state(room))
+        client.mark_thread_as_read = AsyncMock(
+            return_value=datetime(2026, 10, 18, 9, 0, tzinfo=UTC)
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_mark_thread_as_read,
+            {"room_id": "room-1", "thread_root_event_id": "msg-1"},
+            client=client,
+        )
+        assert result["success"] is True
+        assert result["previous_last_read_at"].startswith("2026-10-18T09:00")
+        client.mark_thread_as_read.assert_awaited_once_with("room-1", "msg-1")
+
+    # -- chatto_list_room_groups --
+
+    async def test_list_room_groups_reports_each_group_and_its_rooms(self):
+        client = self._stub_client()
+        client.list_room_groups = AsyncMock(
+            return_value=[
+                RoomGroup(
+                    id="group-1",
+                    name="Team",
+                    description="Internal rooms",
+                    rooms=[
+                        _make_room_state(_make_room("room-1", "Ops", RoomKind.CHANNEL))
+                    ],
+                )
+            ]
+        )
+        result, _, _ = await self._run(
+            _handle_chatto_list_room_groups, {}, client=client
+        )
+        assert result["success"] is True
+        (group,) = result["groups"]
+        assert group["id"] == "group-1"
+        assert group["name"] == "Team"
+        assert group["room_ids"] == ["room-1"]
+
+
+class TestInteractivePromptAuth:
+    """Allowed-user helper for reaction-driven interactive prompts."""
+
+    def test_interactive_prompt_auth_fail_closed(self):
+        adapter = _make_adapter()
+        adapter.chatto_config.allow_all_users.value = False
+        adapter.chatto_config.allowed_users.value = []
+        assert adapter._is_allowed_user_id("user-1") is False
+
+
+class TestRetireClarifyCard:
+    """Test retire_clarify_card edits the prompt message and untracks."""
+
+    def _make_clarify_prompt(self, adapter, clarify_id, chat_id):
+        """Helper to populate the clarify registries."""
+        import time
+
+        from adapter import INTERACTIVE_PROMPT_TIMEOUT_SECONDS, _ClarifyPrompt
+
+        prompt = _ClarifyPrompt(
+            clarify_id=clarify_id,
+            choices=["A", "B"],
+            session_key="sk-1",
+            chat_id=chat_id,
+            message_id="msg-prompt",
+            expires_at=time.monotonic() + INTERACTIVE_PROMPT_TIMEOUT_SECONDS,
+        )
+        adapter._clarify_prompts["msg-prompt"] = prompt
+        adapter._clarify_id_to_message[clarify_id] = "msg-prompt"
+        return prompt
+
+    @pytest.mark.asyncio
+    async def test_retire_edits_message_and_untracks(self):
+        adapter = _make_adapter()
+        adapter.edit_message = AsyncMock()
+        self._make_clarify_prompt(adapter, "clarify-1", "room-1")
+
+        await adapter.retire_clarify_card("clarify-1", "Timed out")
+
+        adapter.edit_message.assert_awaited_once_with(
+            "room-1", "msg-prompt", "❌ Timed out"
+        )
+        assert "clarify-1" not in adapter._clarify_id_to_message
+        assert "msg-prompt" not in adapter._clarify_prompts
+        assert adapter._clarify_prompts.get("msg-prompt") is None
+
+    @pytest.mark.asyncio
+    async def test_retire_unknown_clarify_id_noop(self):
+        adapter = _make_adapter()
+        adapter.edit_message = AsyncMock()
+
+        await adapter.retire_clarify_card("nonexistent", "gone")
+
+        adapter.edit_message.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_retire_edited_message_on_error_logs(self):
+        adapter = _make_adapter()
+        adapter.edit_message = AsyncMock(side_effect=Exception("edit failed"))
+
+        self._make_clarify_prompt(adapter, "clarify-2", "room-1")
+
+        await adapter.retire_clarify_card("clarify-2", "error")
+
+        assert "clarify-2" not in adapter._clarify_id_to_message
+        assert "msg-prompt" not in adapter._clarify_prompts

部分文件因文件數量過多而無法顯示