hyperhive/hive-agent-mcp/src/mcp/render.rs
damocles 80f16094f1 hive-sh4re: split inbox, container, journal, and schedule wire shapes into their own modules
Closes the #3110 split — lib.rs is now just the crate doc comment and
the pub mod list.

journal.rs's new doc comment fixes a pre-existing bug: the old
JournalPriority doc text in lib.rs was actually half Capability's doc
(a leftover from an earlier reorder that moved the code but not the
comment above it).
2026-08-10 23:26:15 +02:00

654 lines
26 KiB
Rust

//! Formatting / render helpers for the MCP tool surface: ack / recv /
//! loose-end / agent-meta reply shaping plus the retry annotation.
//! Stateless string builders, with one exception —
//! [`matrix_unread_summary`] queries `hive-matrix-daemon`'s local
//! `/unread-summary` status endpoint (best-effort) so `get_loose_ends`
//! can prepend an unread-rooms entry.
/// Render the three identical failure arms every data-returning tool handler
/// repeats: a broker `Err` → `"{tool} failed: {m}"`, an unexpected `Ok` variant
/// → `"{tool} unexpected response: …"`, and a transport error → `"{tool}
/// transport error: …"`. Handlers match their own happy-path variant and route
/// everything else here via a catch-all arm (`other => reply_err(other, tool)`),
/// so the triplet lives in exactly one place.
pub(super) fn reply_err(
resp: Result<hive_core_agent_sock::Response, anyhow::Error>,
tool: &str,
) -> String {
match resp {
Ok(hive_core_agent_sock::Response::Err { message }) => format!("{tool} failed: {message}"),
Ok(other) => format!("{tool} unexpected response: {other:?}"),
Err(e) => format!("{tool} transport error: {e:#}"),
}
}
/// Format helper for "send-like" tools (anything that expects an `Ok`).
/// `tool` and `ok_msg` only appear in the result string; they don't change
/// behavior.
#[must_use]
pub fn format_ack(
resp: Result<hive_core_agent_sock::Response, anyhow::Error>,
tool: &str,
ok_msg: String,
) -> String {
match resp {
Ok(hive_core_agent_sock::Response::Ok) => ok_msg,
other => reply_err(other, tool),
}
}
/// Format helper for `recv`: renders zero, one, or many popped
/// messages. Empty list collapses to "(empty)" so claude doesn't go
/// hunting for content. A single message renders as the historical
/// `from: X\n\nbody` block (banner first if `redelivered`). A
/// multi-message batch renders with a `popped N message(s):` header
/// and `---` separators between bodies so the model can tell where
/// one ends and the next begins; per-message redelivery banners
/// included.
#[must_use]
pub fn format_recv(resp: Result<hive_core_agent_sock::Response, anyhow::Error>) -> String {
match resp {
Ok(hive_core_agent_sock::Response::Messages {
messages,
remaining,
}) => render_recv_messages(&messages, remaining),
// A graceful stop is pending — the inbox is fenced. Render a single
// explicit directive (not an empty inbox, which claude might just
// recv again) so every recv during the stop unmissably tells claude
// to flush + end. `remaining` is forced to 0 — the inbox is fenced,
// so a "N more pending" hint would be misleading.
Ok(hive_core_agent_sock::Response::GracefulStop) => {
render_recv_messages(&[graceful_stop_message()], 0)
}
other => reply_err(other, "recv"),
}
}
/// The synthetic single-message directive rendered for a fenced (graceful-stop)
/// inbox — see the `GracefulStop` arm of [`format_recv`].
fn graceful_stop_message() -> hive_sh4re::inbox::DeliveredMessage {
hive_sh4re::inbox::DeliveredMessage {
from: "graceful-stop".into(),
body: "⛔ GRACEFUL STOP IN PROGRESS — the container shuts down as soon as this turn \
ends. Flush anything worth keeping to your durable /state files, then END \
YOUR TURN now. Do NOT call recv again: the inbox is fenced and recv will \
only keep returning this same notice."
.into(),
id: 0,
redelivered: false,
in_reply_to: None,
}
}
/// Render the popped-message payload of a successful `recv` (see `format_recv`
/// for the empty/single/batch shapes). `remaining` is the post-pop inbox
/// depth; when non-zero a shared "(N more pending …)" hint (identical to the
/// wake prompt's) is appended so an in-turn drain knows more is queued. The
/// empty path never carries the hint (nothing was popped).
fn render_recv_messages(
messages: &[hive_sh4re::inbox::DeliveredMessage],
remaining: u64,
) -> String {
use std::fmt::Write as _;
if messages.is_empty() {
return "(empty)".to_owned();
}
let mut out = if messages.len() == 1 {
let m = &messages[0];
let banner = if m.redelivered {
hive_sh4re::inbox::REDELIVERY_HINT
} else {
""
};
format!("{banner}{}from: {}\n\n{}", msg_id_tag(m.id), m.from, m.body)
} else {
let n = messages.len();
let mut out = format!("popped {n} message(s):\n\n");
for (i, m) in messages.iter().enumerate() {
if i > 0 {
out.push_str("\n---\n\n");
}
let banner = if m.redelivered {
hive_sh4re::inbox::REDELIVERY_HINT
} else {
""
};
let _ = write!(
out,
"{banner}{}from: {}\n\n{}",
msg_id_tag(m.id),
m.from,
m.body
);
}
out
};
out.push_str(&hive_sh4re::inbox::pending_hint(remaining));
out
}
/// `[msg #<id>] ` marker prefixed to each recv row so the agent knows
/// what to pass to `ack_until` when bulk-triaging a backlog. Transient
/// pings carry the sentinel id 0 (in-memory only, nothing in the
/// broker to ack) and render without the marker.
fn msg_id_tag(id: i64) -> String {
if id > 0 {
format!("[msg #{id}] ")
} else {
String::new()
}
}
/// Hard cap on how many individual `Todo` lines `render_loose_ends` will
/// emit. Approvals/questions/reminders stay naturally bounded (they're
/// triaged interactively and don't self-multiply), but todos are pushed by
/// unattended producers — matrix/bash/forge are the built-in ones, but any
/// user-configured MCP server can push its own too — an agent that goes a long
/// stretch without calling `get_loose_ends`, or whose producers outpace its
/// triage, can accumulate hundreds of them. Rendering all of them
/// unconditionally risks producing a tool result too large for the MCP
/// transport to return at all, which is worse than a bug: it's a deadlock
/// (the agent can't even see what's pending to start clearing it). Capping
/// here guarantees `get_loose_ends` always returns successfully; the
/// truncation summary tells the agent to review the shown batch, bulk-clear
/// the reviewed ids via `mark_todos_done`, then call again for the next
/// batch — deliberately NOT a "clear everything below id N" range escape
/// hatch (a reviewer's call: a range-based bulk-ack risks silently
/// acking something the agent never actually looked at, since todos are
/// heterogeneous unrelated items, not a sequentially-read stream the way
/// inbox messages are).
const MAX_RENDERED_TODOS: usize = 40;
/// Render one non-`Todo` [`hive_sh4re::inbox::LooseEnd`] variant onto `out`. Split
/// out of `render_loose_ends` to keep that function under clippy's
/// `too_many_lines` limit — `Todo` stays inline there since it also needs
/// the shared `shown_todos` counter.
fn render_one_loose_end(out: &mut String, t: &hive_sh4re::inbox::LooseEnd) {
use std::fmt::Write as _;
match t {
hive_sh4re::inbox::LooseEnd::Approval {
id,
agent,
commit_ref,
description,
age_seconds,
} => {
let desc = description
.as_deref()
.map(|d| format!("{d}"))
.unwrap_or_default();
let _ = writeln!(
out,
"- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}"
);
}
hive_sh4re::inbox::LooseEnd::Question {
id,
asker,
target,
question,
age_seconds,
} => {
let to = target.as_deref().unwrap_or("operator");
let _ = writeln!(
out,
"- question #{id} ({asker} → {to}, {age_seconds}s old): {question}"
);
}
hive_sh4re::inbox::LooseEnd::Reminder {
id,
owner,
message,
due_at,
age_seconds,
} => {
let _ = writeln!(
out,
"- reminder #{id} ({owner}, scheduled {age_seconds}s ago, due_at={due_at}): {message}"
);
}
hive_sh4re::inbox::LooseEnd::PendingMessages { count } => {
let _ = writeln!(
out,
"- {count} pending inbox message(s) — drain with recv (recv(max: {count}) to batch)"
);
}
hive_sh4re::inbox::LooseEnd::UnreadMatrix { rooms, summary } => {
let _ = write!(out, "- unread matrix messages in {rooms} room(s)");
if summary.is_empty() {
let _ = writeln!(
out,
" — use list_rooms + read_room to view, mark_read to clear"
);
} else {
let _ = writeln!(out, ":");
for line in summary.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(
out,
" use list_rooms + read_room to view, mark_read to clear"
);
}
}
hive_sh4re::inbox::LooseEnd::Todo { .. } => {
// Handled inline by the caller (needs the shared shown-count).
}
}
}
/// Render one `Todo` [`hive_sh4re::inbox::LooseEnd`] onto `out`. Split out for the
/// same `too_many_lines` reason as [`render_one_loose_end`].
fn render_todo_loose_end(
out: &mut String,
id: i64,
subsystem: &str,
subsystem_key: Option<&str>,
summary: &str,
source: Option<&str>,
age_seconds: u64,
) {
use std::fmt::Write as _;
let key = subsystem_key.map(|k| format!(" {k}")).unwrap_or_default();
let src = source.map(|s| format!("{s}")).unwrap_or_default();
let _ = writeln!(
out,
"- todo #{id} [{subsystem}{key}, {age_seconds}s old]: {summary}{src} \
(cancel_loose_end kind:\"todo\" id:{id} to clear)"
);
}
/// Inner renderer for a `Vec<LooseEnd>` already extracted from the socket
/// reply. Called by the `get_loose_ends` handler, which injects the
/// `UnreadMatrix` entry before formatting.
pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::inbox::LooseEnd]) -> String {
use std::fmt::Write as _;
if loose_ends.is_empty() {
return "(no loose ends)".to_owned();
}
let mut out = format!("{} loose end(s):\n", loose_ends.len());
let mut shown_todos = 0usize;
let mut hidden_todos = 0usize;
for t in loose_ends {
let hive_sh4re::inbox::LooseEnd::Todo {
id,
subsystem,
subsystem_key,
summary,
source,
age_seconds,
} = t
else {
render_one_loose_end(&mut out, t);
continue;
};
if shown_todos >= MAX_RENDERED_TODOS {
hidden_todos += 1;
continue;
}
render_todo_loose_end(
&mut out,
*id,
subsystem,
subsystem_key.as_deref(),
summary,
source.as_deref(),
*age_seconds,
);
shown_todos += 1;
}
if hidden_todos > 0 {
let _ = writeln!(
out,
"- {hidden_todos} more todo(s) not shown — clear reviewed ids with \
mark_todos_done(ids: [...]), then call get_loose_ends again for the rest"
);
}
out
}
/// Per-room unread entry returned by `matrix_unread_summary`. Mirrors
/// `hive-matrix-mcp`'s `RoomUnread` but defined locally to avoid a
/// cross-crate dep on the matrix-sdk crate tree.
#[derive(Debug, serde::Deserialize)]
pub(super) struct MatrixRoomUnread {
label: String,
count: u32,
last_body: Option<String>,
last_sender: Option<String>,
}
/// Default port `hive-matrix-daemon` serves its MCP + status endpoints
/// on (`hyperhive.mcp.matrixHttpPort`'s nix default). Overridable via
/// `HIVE_MATRIX_HTTP_PORT` for parity with the port options nix already
/// exposes; unset in practice since a single fixed port is safe (each
/// agent container is its own network namespace — see docs/network.md).
const DEFAULT_MATRIX_HTTP_PORT: u16 = 8792;
/// Short request timeout for the local status query below — this must
/// never stall a turn waiting on a wedged same-container daemon.
const MATRIX_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// Query the local matrix daemon's `/unread-summary` status endpoint
/// for per-room unread summaries. Returns `None` if the daemon isn't
/// reachable or the query fails. Best-effort: agents without matrix
/// configured are not penalised.
pub(super) async fn matrix_unread_summary() -> Option<Vec<MatrixRoomUnread>> {
let port = std::env::var("HIVE_MATRIX_HTTP_PORT")
.unwrap_or_else(|_| DEFAULT_MATRIX_HTTP_PORT.to_string());
let url = format!("http://127.0.0.1:{port}/unread-summary");
let client = reqwest::Client::builder()
.timeout(MATRIX_STATUS_TIMEOUT)
.build()
.ok()?;
let val: serde_json::Value = client.get(&url).send().await.ok()?.json().await.ok()?;
// Response: {"kind":"ok","payload":[{label, count, last_body?, last_sender?}]}
let arr = val.get("payload")?.as_array()?;
serde_json::from_value(serde_json::Value::Array(arr.clone())).ok()
}
/// Dial the harness's in-agent socket (`HIVE_AGENT_SOCKET`) with a single
/// one-shot JSON-line request and decode the response. Returns `None` when
/// the socket is unset/absent or on any transport/decode failure —
/// best-effort, single-shot (no retry, unlike the broker's
/// `client::request_retried`): this socket lives in the SAME container, so
/// a connect failure means the harness itself isn't up, which a retry
/// won't fix within a tool call's budget. Shared by [`local_todos`],
/// [`mark_local_todo_done`], and [`local_reminders`]; `remind`/
/// `cancel_loose_end`(reminder) in `mod.rs` dial it directly since their
/// happy path is a plain `Ok`/`Err`, not a `Vec<LooseEnd>` to merge.
pub(super) async fn dial_agent_socket(
req: &hive_agent_sock::Request,
) -> Option<hive_agent_sock::Response> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
let socket = std::env::var_os("HIVE_AGENT_SOCKET").map(std::path::PathBuf::from)?;
if !socket.exists() {
return None;
}
let mut stream = UnixStream::connect(&socket).await.ok()?;
let mut line = serde_json::to_string(req).ok()?;
line.push('\n');
stream.write_all(line.as_bytes()).await.ok()?;
let mut lines = BufReader::new(stream).lines();
let resp_line = lines.next_line().await.ok()??;
serde_json::from_str(&resp_line).ok()
}
/// Query the harness's in-agent socket for this agent's local todos
/// (loose-ends v2). Returns `None` when `HIVE_AGENT_SOCKET` is unset /
/// absent or the query fails — best-effort, like [`matrix_unread_summary`],
/// so an agent without the socket is not penalised.
pub(super) async fn local_todos() -> Option<Vec<hive_sh4re::inbox::LooseEnd>> {
match dial_agent_socket(&hive_agent_sock::Request::ListTodos { subsystem: None }).await? {
hive_agent_sock::Response::LooseEnds { loose_ends } => Some(loose_ends),
_ => None,
}
}
/// Query the harness's in-agent socket for this agent's local pending
/// reminders — was a broker query before reminders moved in-container.
/// Same best-effort contract as [`local_todos`].
pub(super) async fn local_reminders() -> Option<Vec<hive_sh4re::inbox::LooseEnd>> {
match dial_agent_socket(&hive_agent_sock::Request::ListReminders).await? {
hive_agent_sock::Response::LooseEnds { loose_ends } => Some(loose_ends),
_ => None,
}
}
/// Query the harness's in-agent socket for this agent's mirrored questions
/// (both roles — asked and answering). Same best-effort
/// contract as [`local_reminders`]; c0re stays the actual `Ask`/`Answer`
/// routing, this only mirrors the durable "still owed a reply" view.
pub(super) async fn local_questions() -> Option<Vec<hive_sh4re::inbox::LooseEnd>> {
match dial_agent_socket(&hive_agent_sock::Request::ListQuestions).await? {
hive_agent_sock::Response::LooseEnds { loose_ends } => Some(loose_ends),
_ => None,
}
}
/// Mark one of this agent's local todos (loose-ends v2) done by id, via
/// the harness's in-agent socket — reachable through `cancel_loose_end`
/// kind `"todo"` so clearing a todo never has to shell out through a
/// tracked bash task (which would just spawn a fresh completion todo of
/// its own). `None` means the socket is unset/absent or unreachable,
/// distinct from a decoded `Err` response (unknown id) so the caller can
/// tell the two apart.
pub(super) async fn mark_local_todo_done(id: i64) -> Option<hive_agent_sock::Response> {
dial_agent_socket(&hive_agent_sock::Request::MarkTodoDone { id }).await
}
/// Bulk-ack an explicit list of local todo ids, via the harness's in-agent
/// socket — the multi-id analogue of `mark_local_todo_done`, for the
/// `mark_todos_done` tool.
pub(super) async fn mark_local_todos_done(ids: Vec<i64>) -> Option<hive_agent_sock::Response> {
dial_agent_socket(&hive_agent_sock::Request::MarkTodosDone { ids }).await
}
/// Format a `Vec<MatrixRoomUnread>` into a per-room summary string.
/// Single room / single message collapses to one line; multi-room
/// expands to a bulleted list. Returns an empty string for empty input.
pub(super) fn format_matrix_summary(rooms: &[MatrixRoomUnread]) -> String {
use std::fmt::Write as _;
if rooms.is_empty() {
return String::new();
}
let mut out = String::new();
for r in rooms {
if r.count == 1
&& let (Some(body), Some(sender)) = (&r.last_body, &r.last_sender)
{
let _ = writeln!(out, "- {}: {sender}: {body}", r.label);
continue;
}
let _ = writeln!(out, "- {}: {} unread", r.label, r.count);
}
// Remove trailing newline.
if out.ends_with('\n') {
out.pop();
}
out
}
/// Parse the user-facing `kind` string for `cancel_loose_end` into the
/// wire enum. Accepts a small alias set so claude doesn't have to
/// remember the exact spelling (`"q"` / `"r"` shorthand falls out
/// for free).
pub(super) fn parse_loose_end_kind(
raw: &str,
) -> Result<hive_sh4re::inbox::CancelLooseEndKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"question" | "q" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Question),
"reminder" | "r" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Reminder),
"approval" | "a" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Approval),
other => Err(format!(
"cancel_loose_end: unknown kind '{other}' \
(expected \"question\", \"reminder\", \"approval\", or \"todo\")"
)),
}
}
/// Canonical user-facing label for a `CancelLooseEndKind` — used in
/// the success ack so the caller always sees `"question"` /
/// `"reminder"` instead of whatever alias they passed in (`"q"` /
/// `"r"`).
pub(super) fn loose_end_kind_label(kind: hive_sh4re::inbox::CancelLooseEndKind) -> &'static str {
match kind {
hive_sh4re::inbox::CancelLooseEndKind::Question => "question",
hive_sh4re::inbox::CancelLooseEndKind::Reminder => "reminder",
hive_sh4re::inbox::CancelLooseEndKind::Approval => "approval",
}
}
/// Format helper for `get_agent_meta`: renders an agent's identity +
/// current status as a short human-readable block. `name`,
/// `hyperhive_rev`, and `running` are always shown; `status` only
/// appears when one is set, otherwise the line reads `status: <none>`.
/// When `running` is false the host has already cleared `status_text`
/// (it would be a stale snapshot from before the stop) so the status
/// line is implicitly `<none>` in that case — but the explicit
/// `running: no` line tells the caller WHY. See
/// `docs/turn-loop/mcp.md::Core tools` (`get_agent_meta`).
#[must_use]
pub fn format_agent_meta(resp: Result<hive_core_agent_sock::Response, anyhow::Error>) -> String {
match resp {
Ok(hive_core_agent_sock::Response::AgentMeta {
name,
running,
hyperhive_rev,
status_text,
status_set_at,
hive_name,
swarm_name,
matrix_accounts,
}) => {
let rev = hyperhive_rev.as_deref().unwrap_or("<unknown>");
let run = if running { "yes" } else { "no" };
let mut out = format!("name: {name}\nhyperhive_rev: {rev}\nrunning: {run}");
// Surface hive + swarm display names only when set, so
// single-hive deployments don't see noisy `<none>` lines.
if let Some(hn) = hive_name.as_deref() {
use std::fmt::Write as _;
let _ = write!(out, "\nhive_name: {hn}");
}
if let Some(sn) = swarm_name.as_deref() {
use std::fmt::Write as _;
let _ = write!(out, "\nswarm_name: {sn}");
}
match status_text {
None => out.push_str("\nstatus: <none>"),
Some(s) => {
use std::fmt::Write as _;
let age = status_set_at.and_then(|ts| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
// `ts` is a unix epoch second the agent itself
// sourced from `SystemTime` — always positive
// in normal operation. Clamp the negative
// (clock-skew) edge to 0 before the unsigned
// cast so the cast loses no real precision.
let ts_secs = u64::try_from(ts).unwrap_or(0);
let secs = now.saturating_sub(ts_secs);
Some(format_age_secs(secs))
});
// `write!` into the buffer instead of `push_str(&format!(…))` —
// avoids the intermediate allocation clippy::format_push_string
// flags. The infallible `String` writer makes this safe to
// `let _ =`-ignore.
match age {
Some(a) => {
let _ = write!(out, "\nstatus: {s} (set {a} ago)");
}
None => {
let _ = write!(out, "\nstatus: {s}");
}
}
}
}
// Matrix identities the agent can act as (the `account` arg on
// the matrix tools). Listed only when matrix is provisioned, so
// non-matrix agents don't see an empty line.
if !matrix_accounts.is_empty() {
use std::fmt::Write as _;
out.push_str("\nmatrix_accounts:");
for acct in &matrix_accounts {
let uid = acct.user_id.as_deref().unwrap_or("?");
let _ = write!(out, "\n {} ({uid}) on {}", acct.name, acct.homeserver);
}
}
out
}
other => reply_err(other, "get_agent_meta"),
}
}
/// Format a duration in seconds as a human-readable age string.
fn format_age_secs(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86400)
}
}
/// Append a short note to a tool result when the underlying socket call
/// took retries to land. Lets claude distinguish "my request was wrong"
/// from "c0re flickered and the harness rode it out" — without the
/// hint, a tool result that took 30s to come back looks identical to a
/// content failure and the model would burn a turn retrying it.
#[must_use]
pub fn annotate_retries(mut s: String, retries: u32) -> String {
if retries > 0 {
use std::fmt::Write as _;
let suffix = if retries == 1 { "retry" } else { "retries" };
let _ = write!(
s,
"\n\n(note: hive socket connect needed {retries} {suffix} — c0re likely \
restarted. Your request did succeed on the final attempt; no action needed.)"
);
}
s
}
#[cfg(test)]
mod tests {
use super::format_recv;
fn msg(id: i64, from: &str, body: &str) -> hive_sh4re::inbox::DeliveredMessage {
hive_sh4re::inbox::DeliveredMessage {
from: from.to_owned(),
body: body.to_owned(),
id,
redelivered: false,
in_reply_to: None,
}
}
#[test]
fn empty_recv_renders_bare_empty_marker() {
let out = format_recv(Ok(hive_core_agent_sock::Response::Messages {
messages: vec![],
remaining: 0,
}));
assert_eq!(out, "(empty)");
}
#[test]
fn single_recv_with_remaining_appends_pending_hint() {
let out = format_recv(Ok(hive_core_agent_sock::Response::Messages {
messages: vec![msg(7, "alice", "hi")],
remaining: 3,
}));
assert!(out.starts_with("[msg #7] from: alice"));
assert!(out.contains("3 more message(s) pending"));
assert!(out.contains("max: 3"));
}
#[test]
fn single_recv_no_remaining_has_no_pending_hint() {
let out = format_recv(Ok(hive_core_agent_sock::Response::Messages {
messages: vec![msg(7, "alice", "hi")],
remaining: 0,
}));
assert!(!out.contains("more message(s) pending"));
}
#[test]
fn batch_recv_with_remaining_appends_pending_hint_once() {
let out = format_recv(Ok(hive_core_agent_sock::Response::Messages {
messages: vec![msg(7, "alice", "hi"), msg(8, "bob", "yo")],
remaining: 9,
}));
assert!(out.starts_with("popped 2 message(s):"));
assert_eq!(out.matches("more message(s) pending").count(), 1);
// `max` suggestion is clamped to the server-side recv cap.
let batch = 9u64.min(u64::from(hive_sh4re::inbox::RECV_BATCH_MAX));
assert!(out.contains(&format!("max: {batch}")));
}
}