hyperhive/hive-agent/src/stream_enrich.rs
atlas 91ab5f9d9c hive-agent: pin classify_tool_result's own 120-char boundary
Mutation testing found this arm untested: classify_tool_result has its
OWN `<= 120` deciding icon-vs-body, separate from the one in
summarize_tool_result that picks the summary text. The existing case
used 2 and 500 characters, so neither arm went near the boundary and
moving it to 119 changed nothing.

Verified the new test fails under exactly that mutation.
2026-09-02 12:29:34 +02:00

1471 lines
55 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Classify raw claude stream-json values into [`crate::term_msg::TermMsg`]
//! rows before SSE delivery.
//!
//! [`classify_stream_value`] is the entry point — it walks one raw claude
//! `stream-json` line (the payload of a [`crate::events::LiveEvent::Stream`])
//! and returns zero or more terminal rows. Applied at SSE-emit time in
//! `crate::web_ui::stream` so both the live tail (`events/stream`) and the
//! history replay (`events/history`) endpoints deliver the same classified
//! shape — the sqlite event log stores the raw, unclassified event, so the
//! DB never needs migration when classification logic changes.
//!
//! The per-tool icon/summary formatting below (`tool_icon`, `fmt_tool_use`
//! and its per-family helpers, `rich_tool_body`) is reused as-is from
//! before mara's terminal-message redesign — that logic (what does
//! `mcp__hyperhive__send`'s row say, which tools get an expandable body)
//! didn't change; only the shape it gets packed into did.
use crate::term_msg::{BodyFormat, ClassifyCtx, Level, TermMsg};
use serde_json::{Value, json};
/// Classify one raw claude stream-json line into zero or more terminal rows.
///
/// Dispatch order mirrors the old client-side `classifyEvent.ts` (before
/// this classification moved server-side):
/// top-level drop-noise types first, then `type="system"`, then task events
/// (matched on `subtype` regardless of `type`), then `assistant`/`user`
/// content, with an unrecognised shape falling through to a loud
/// warn-level catch-all so a silently-dropped event type stays visible.
pub fn classify_stream_value(v: &Value, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
let vtype = v.get("type").and_then(Value::as_str).unwrap_or("");
if matches!(vtype, "result" | "rate_limit_event") {
return vec![];
}
if vtype == "system" {
return classify_system(v);
}
let subtype = v.get("subtype").and_then(Value::as_str);
if matches!(subtype, Some("task_started" | "task_notification")) {
return classify_task_event(v).into_iter().collect();
}
if vtype == "assistant" {
return v
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
.map_or_else(Vec::new, |content| classify_assistant_content(content, ctx));
}
if vtype == "user" {
return v
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
.map_or_else(Vec::new, |content| classify_user_content(content, ctx));
}
vec![TermMsg::new(Level::Warn, trim_str(&v.to_string(), 200)).icon("!")]
}
// ---------------------------------------------------------------------------
// system events
// ---------------------------------------------------------------------------
fn classify_system(v: &Value) -> Vec<TermMsg> {
let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("");
let (category, summary, body) = system_fields(v, subtype);
match category {
"drop" => vec![],
"thinking_tok" => vec![
TermMsg::new(
Level::Debug,
summary.unwrap_or_else(|| "thinking…".to_owned()),
)
.icon("🧠")
.coalesce("thinking-tok"),
],
"details" => {
let mut m = TermMsg::new(Level::Debug, summary.unwrap_or_default());
if let Some(b) = body {
m = m.body(b, None);
}
vec![m]
}
// "note" category — ambient harness/system chatter. Level + coalesce
// key vary by subtype; everything else defaults to a plain debug note.
_ => {
let s = summary.unwrap_or_default();
let m = match subtype {
"api_error" => TermMsg::new(Level::Error, s),
"api_retry" => TermMsg::new(Level::Warn, s),
"plugin_install" => TermMsg::new(Level::Debug, s).coalesce("plugin-install"),
"status" => TermMsg::new(Level::Debug, s).coalesce("status-tick"),
_ => TermMsg::new(Level::Debug, s),
};
vec![m]
}
}
}
/// Compute `(category, summary, body)` for a `type="system"` event.
///
/// Categories understood by the frontend:
/// - `"drop"` — client discards without rendering (noise)
/// - `"thinking_tok"` — client updates a single in-place counter row
/// - `"note"` — client renders `_summary` as a terminal note line
/// - `"details"` — client renders a collapsible `<details>` with `_summary`
/// as the header and `_body` as the expanded text
fn system_fields(v: &Value, subtype: &str) -> (&'static str, Option<String>, Option<String>) {
match subtype {
// Silent startup / stream-end noise — drop.
"init" | "result" | "rate_limit_event" => ("drop", None, None),
// Live thinking-token counter.
"thinking_tokens" => {
let n = v.get("estimated_tokens").and_then(Value::as_u64);
let s = n.map_or_else(
|| "thinking…".to_owned(),
|n| format!("thinking… ~{n} tokens"),
);
("thinking_tok", Some(s), None)
}
// API retry back-off.
"api_retry" => {
let mut parts = vec!["⚠ api retry".to_owned()];
if let (Some(a), Some(m)) = (
v.get("attempt").and_then(Value::as_u64),
v.get("max_retries").and_then(Value::as_u64),
) {
parts.push(format!("{a}/{m}"));
}
if let Some(e) = v.get("error").and_then(Value::as_str) {
parts.push(e.to_owned());
} else if let Some(s) = v.get("error_status").and_then(Value::as_u64) {
parts.push(format!("HTTP {s}"));
}
if let Some(ms) = v.get("retry_delay_ms").and_then(Value::as_f64) {
parts.push(format!("{:.0}ms", ms.round()));
}
("note", Some(parts.join(" · ")), None)
}
// Hard API error.
"api_error" => {
let msg = v
.get("error")
.or_else(|| v.get("message"))
.and_then(Value::as_str)
.map_or_else(
|| {
v.get("error_status")
.and_then(Value::as_u64)
.map_or("unknown".to_owned(), |s| format!("HTTP {s}"))
},
str::to_owned,
);
("note", Some(format!("✗ api error · {msg}")), None)
}
// Plugin (MCP server / slash-command provider) load progress.
"plugin_install" => {
let status = v.get("status").and_then(Value::as_str).unwrap_or("?");
let label = match status {
"completed" => "✓ done",
"started" => "loading…",
other => other,
};
("note", Some(format!("⚙ plugin install · {label}")), None)
}
// Available slash-command set changed — expandable list.
"commands_changed" => {
let cmds = v.get("commands").and_then(Value::as_array);
let count = cmds.map_or(0, Vec::len);
let summary = format!("⚙ commands changed · {count} available");
let body = cmds.map(|cmds| {
cmds.iter()
.filter_map(|c| c.get("name").and_then(Value::as_str))
.map(|n| format!("/{n}"))
.collect::<Vec<_>>()
.join("\n")
});
("details", Some(summary), body)
}
// Compaction complete — pre/post token counts + duration.
"compact_boundary" => {
let m = v
.get("compact_metadata")
.cloned()
.unwrap_or_else(|| json!({}));
let mut parts = vec!["⚙ compact".to_owned()];
if let Some(t) = m.get("trigger").and_then(Value::as_str) {
parts.push(t.to_owned());
}
if let (Some(pre), Some(post)) = (
m.get("pre_tokens").and_then(Value::as_u64),
m.get("post_tokens").and_then(Value::as_u64),
) {
parts.push(format!("{}{} tokens", fmt_tok(pre), fmt_tok(post)));
}
if let Some(ms) = m.get("duration_ms").and_then(Value::as_u64) {
let dur = if ms < 1000 {
format!("{ms}ms")
} else {
#[allow(clippy::cast_precision_loss)]
let s_f = ms as f64 / 1_000.0;
format!("{s_f:.1}s")
};
parts.push(dur);
}
("note", Some(parts.join(" · ")), None)
}
// Generic "still working" heartbeat tick.
"status" => ("note", Some("⚙ status".to_owned()), None),
// Unknown subtype — render with the subtype label as a muted note.
other => ("note", Some(format!("{other}")), None),
}
}
// ---------------------------------------------------------------------------
// assistant events (claude's own output: text / thinking / tool calls)
// ---------------------------------------------------------------------------
fn classify_assistant_content(content: &[Value], ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
let mut rows = Vec::new();
for c in content {
match c.get("type").and_then(Value::as_str) {
Some("text") => {
let text = c.get("text").and_then(Value::as_str).unwrap_or("");
if !text.trim().is_empty() {
// No separate summary line — the markdown body is the
// whole row, matching the old `.text` row shape.
rows.push(
TermMsg::new(Level::Info, String::new())
.body(text.to_owned(), Some(BodyFormat::Markdown)),
);
}
}
Some("thinking") => {
let txt = c
.get("thinking")
.or_else(|| c.get("text"))
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_owned();
let summary = if txt.is_empty() {
"thinking …".to_owned()
} else {
txt
};
rows.push(TermMsg::new(Level::Debug, summary).icon("💭"));
}
Some("tool_use") => {
if let (Some(id), Some(name)) = (
c.get("id").and_then(Value::as_str),
c.get("name").and_then(Value::as_str),
) {
ctx.record_tool_use(id, name);
}
rows.push(classify_tool_use(c));
}
_ => {}
}
}
rows
}
/// `_category === 'rich'` tools used to get an expandable row (diff body
/// for Edit, markdown body for send, plain for everything else with a
/// body). That's now just "does this row have a body" — `body.is_some()`
/// on the returned [`TermMsg`] *is* the expandable signal, no separate
/// flag.
fn classify_tool_use(c: &Value) -> TermMsg {
let name = c.get("name").and_then(Value::as_str).unwrap_or("");
let input = c.get("input").cloned().unwrap_or_else(|| json!({}));
let mut m = TermMsg::new(Level::Info, fmt_tool_use(name, &input)).icon(tool_icon(name));
if let Some((body, body_type)) = rich_tool_body(name, &input) {
let format = match body_type {
"diff" => Some(BodyFormat::Diff),
"markdown" => Some(BodyFormat::Markdown),
_ => None, // "plain"
};
m = m.body(body, format);
}
m
}
// ---------------------------------------------------------------------------
// user events (tool_result — claude's own tool calls answered)
// ---------------------------------------------------------------------------
fn classify_user_content(content: &[Value], ctx: &ClassifyCtx) -> Vec<TermMsg> {
content
.iter()
.filter(|c| c.get("type").and_then(Value::as_str) == Some("tool_result"))
.map(|c| classify_tool_result(c, ctx))
.collect()
}
/// `<tool_use_error>…</tool_use_error>` is claude's own wrapper on failed
/// tool calls — implementation detail, adds nothing for the operator.
fn strip_tool_use_error_wrapper(s: &str) -> String {
let trimmed = s.trim();
trimmed
.strip_prefix("<tool_use_error>")
.and_then(|rest| rest.strip_suffix("</tool_use_error>"))
.map_or_else(|| trimmed.to_owned(), |inner| inner.trim().to_owned())
}
fn classify_tool_result(c: &Value, ctx: &ClassifyCtx) -> TermMsg {
let raw_txt = match c.get("content") {
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<String>(),
Some(Value::String(s)) => s.clone(),
_ => String::new(),
};
let is_error = c.get("is_error").and_then(Value::as_bool).unwrap_or(false);
let txt = if is_error {
strip_tool_use_error_wrapper(&raw_txt)
} else {
raw_txt
};
let tool_use_id = c.get("tool_use_id").and_then(Value::as_str);
let source_name = tool_use_id.and_then(|id| ctx.tool_name(id));
let is_message_bearing = source_name == Some("mcp__hyperhive__recv");
let trimmed: String = txt.split_whitespace().collect::<Vec<_>>().join(" ");
let summary = summarize_tool_result(&txt, &trimmed);
let short = txt.trim().is_empty() || txt.chars().count() <= 120;
if is_error {
let m = TermMsg::new(Level::Error, summary);
return if short {
m.icon("")
} else {
m.body(txt, None)
};
}
if is_message_bearing && !txt.trim().is_empty() {
return TermMsg::new(Level::Info, format!("recv ← {summary}"))
.body(txt, Some(BodyFormat::Markdown));
}
let m = TermMsg::new(Level::Info, summary);
if short {
m.icon("")
} else {
m.body(txt, None)
}
}
/// `(empty)` / a short trimmed line / `"NL · headline…"` for a long one —
/// matches the old client-side `summaryBody` computation exactly.
fn summarize_tool_result(txt: &str, trimmed: &str) -> String {
if trimmed.is_empty() {
return "(empty)".to_owned();
}
if trimmed.chars().count() <= 120 {
return trimmed.to_owned();
}
let lines = txt.lines().filter(|l| !l.is_empty()).count();
let headline: String = trimmed.chars().take(90).collect();
format!("{lines}L · {headline}")
}
// ---------------------------------------------------------------------------
// subagent (claude Task-tool) activity — dead path for agents today (`Task`
// is omitted from the allow-list) but kept for parity with the old
// client-side `classifyTaskEvent`.
// ---------------------------------------------------------------------------
fn classify_task_event(v: &Value) -> Option<TermMsg> {
let id: String = v
.get("task_id")
.and_then(Value::as_str)
.unwrap_or("")
.chars()
.take(8)
.collect();
let kind = v
.get("task_type")
.and_then(Value::as_str)
.map(|t| format!(" [{t}]"))
.unwrap_or_default();
let desc = v
.get("description")
.or_else(|| v.get("summary"))
.and_then(Value::as_str)
.unwrap_or("(no description)");
match v.get("subtype").and_then(Value::as_str) {
Some("task_started") => {
Some(TermMsg::new(Level::Info, format!("task {id} started · {desc}{kind}")).icon(""))
}
Some("task_notification") => {
let status = v.get("status").and_then(Value::as_str).unwrap_or("unknown");
let (glyph, level) = match status {
"completed" => ("", Level::Info),
"failed" => ("", Level::Error),
_ => ("", Level::Info),
};
let out = v
.get("output_file")
.and_then(Value::as_str)
.map(|f| format!(" · → {f}"))
.unwrap_or_default();
Some(TermMsg::new(level, format!("task {id} {glyph} {status} · {desc}{out}")).icon(""))
}
_ => None,
}
}
// ---------------------------------------------------------------------------
// tool helpers
// ---------------------------------------------------------------------------
/// Pre-compute the expandable body for rich tool entries.
///
/// Returns `Some((body, body_type))` where `body_type` becomes the
/// `TermMsg`'s `body_format` and tells the frontend which renderer to use:
/// - `"diff"` → colour-coded `+`/`-` lines
/// - `"plain"` (`None` on the wire) → a plain `<pre>` block
/// - `"markdown"` → rendered via `marked` + `DOMPurify`; used for
/// message-bearing tools: `send`
///
/// Returns `None` for tools that have no body at all.
///
/// **`Write` is intentionally absent**: its `content` field can be megabytes
/// and is one-sided (no `old_string` to diff against), making it too large to
/// inline in every SSE event. The flat `"Write /path"` summary row is
/// sufficient — the operator can open the file directly if they need to inspect
/// the written content.
fn rich_tool_body(name: &str, input: &Value) -> Option<(String, &'static str)> {
match name {
// Edit diff: old lines prefixed `- `, new lines prefixed `+ `.
// The summary already carries `-N +M` counts (from `fmt_builtin_tool`),
// so the body is the full colour-coded diff.
"Edit" => {
let old = input
.get("old_string")
.and_then(Value::as_str)
.unwrap_or("");
let new = input
.get("new_string")
.and_then(Value::as_str)
.unwrap_or("");
let mut body = String::new();
for line in old.lines() {
body.push_str("- ");
body.push_str(line);
body.push('\n');
}
for line in new.lines() {
body.push_str("+ ");
body.push_str(line);
body.push('\n');
}
Some((body, "diff"))
}
// Full bash command — first line is already in `_summary`; the full
// `cmd` (prefixed with `$ `) is the body so multi-line scripts are
// readable on expand.
"mcp__bash__run" => {
let cmd = input.get("cmd").and_then(Value::as_str).unwrap_or("");
if cmd.is_empty() {
None
} else {
Some((format!("$ {cmd}"), "plain"))
}
}
// Message-bearing tool: body is markdown text rendered by the client.
"mcp__hyperhive__send" => {
let body = input.get("body").and_then(Value::as_str).unwrap_or("");
if body.is_empty() {
None
} else {
Some((body.to_owned(), "markdown"))
}
}
_ => None,
}
}
fn tool_icon(name: &str) -> &'static str {
// Exact-match table first, then prefix/contains fallbacks.
match name {
"mcp__hyperhive__send" => "📤",
"mcp__hyperhive__recv" => "📥",
"mcp__hyperhive__remind" => "",
"mcp__hyperhive__set_status" => "🏷️",
"mcp__hyperhive__get_loose_ends" => "🪢",
"mcp__hyperhive__cancel_loose_end" => "✂️",
"mcp__hyperhive__ack_until" => "",
"mcp__hyperhive__get_agent_meta" => "",
"mcp__hyperhive__restart" => "",
"mcp__hyperhive__kill" => "⏹️",
"mcp__hyperhive__start" => "▶️",
"mcp__hyperhive__update" => "🔄",
"mcp__hyperhive__list_containers"
| "mcp__matrix__list_rooms"
| "mcp__matrix__list_room_members"
| "mcp__matrix__list_invites" => "📋",
"mcp__hyperhive__get_logs" | "mcp__hyperhive__get_host_journal" => "📜",
"mcp__matrix__read_room" | "Read" => "📖",
"mcp__matrix__mark_read" => "👁️",
"mcp__bash__kill" => "🛑",
"Write" => "💾",
"Edit" => "✏️",
"Glob" | "Grep" => "🔍",
_ => tool_icon_fallback(name),
}
}
fn tool_icon_fallback(name: &str) -> &'static str {
if name.starts_with("mcp__matrix__") {
"💬"
} else if name.starts_with("mcp__bash__") {
"🖥️"
} else if name.contains("schedule") {
"⏱️"
} else if name.starts_with("mcp__hyperhive__request_") {
"📦"
} else {
"🔧"
}
}
// ---------------------------------------------------------------------------
// tool_use summary formatter — dispatch to per-family sub-functions
// ---------------------------------------------------------------------------
fn fmt_tool_use(name: &str, input: &Value) -> String {
// Short name: strip the MCP server prefix for display.
let short = if let Some(rest) = name.strip_prefix("mcp__hyperhive__") {
format!("{rest}*")
} else if let Some(rest) = name.strip_prefix("mcp__bash__") {
format!("{rest}*")
} else if let Some(rest) = name.strip_prefix("mcp__matrix__") {
format!("{rest}*")
} else {
name.to_owned()
};
if name.starts_with("mcp__hyperhive__") {
fmt_hyperhive_tool(name, &short, input)
} else if name.starts_with("mcp__bash__") {
fmt_bash_tool(name, &short, input)
} else if name.starts_with("mcp__matrix__") {
fmt_matrix_tool(name, &short, input)
} else {
fmt_builtin_tool(name, &short, input)
}
}
/// Built-in claude tools: Read/Write/Edit/Glob/Grep/Bash/TodoWrite and any
/// unknown tool that doesn't carry a known MCP server prefix.
fn fmt_builtin_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"Read" | "Write" => format!("{short} {}", sv(input, "file_path")),
"Edit" => {
let path = sv(input, "file_path");
let old = input
.get("old_string")
.and_then(Value::as_str)
.unwrap_or("");
let new_n = input
.get("new_string")
.and_then(Value::as_str)
.unwrap_or("")
.lines()
.count();
if old.is_empty() {
format!("{short} {path} · +{new_n}")
} else {
let old_n = old.lines().count();
format!("{short} {path} · -{old_n} +{new_n}")
}
}
"Glob" | "Grep" => format!("{short} {}", sv(input, "pattern")),
"Bash" => {
let bg = if input
.get("run_in_background")
.and_then(Value::as_bool)
.unwrap_or(false)
{
" [bg]"
} else {
""
};
format!("{short}{bg} $ {}", sv(input, "command"))
}
"TodoWrite" => {
let n = input
.get("todos")
.and_then(Value::as_array)
.map_or(0, Vec::len);
format!("{short} ({n} items)")
}
_ => fmt_args_generic(short, input),
}
}
/// Summary for `send`, the one message-bearing hyperhive tool.
///
/// Format: `"{short} → {recipient}"` with ` · NL` appended when the body
/// spans multiple lines.
fn fmt_hyperhive_message_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__hyperhive__send" => {
let to = sv(input, "to");
let lines = sv(input, "body").lines().count();
if lines > 1 {
format!("{short}{to} · {lines}L")
} else {
format!("{short}{to}")
}
}
_ => fmt_args_generic(short, input),
}
}
/// `mcp__hyperhive__*` tools.
fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__hyperhive__send" => fmt_hyperhive_message_tool(name, short, input),
"mcp__hyperhive__recv" => {
let mut parts = Vec::new();
if let Some(w) = input.get("wait_seconds").and_then(Value::as_u64) {
parts.push(format!("wait {w}s"));
}
if let Some(m) = input.get("max").and_then(Value::as_u64) {
parts.push(format!("max {m}"));
}
if parts.is_empty() {
format!("{short}()")
} else {
format!("{short} {}", parts.join(" · "))
}
}
"mcp__hyperhive__kill"
| "mcp__hyperhive__restart"
| "mcp__hyperhive__start"
| "mcp__hyperhive__update"
| "mcp__hyperhive__request_init_config" => format!("{short} {}", sv(input, "name")),
"mcp__hyperhive__ack_until" => {
let up_to = input
.get("up_to")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
format!("{short}{up_to}")
}
"mcp__hyperhive__get_logs" => {
let lines = input
.get("lines")
.and_then(Value::as_u64)
.map(|n| format!(" · {n}L"))
.unwrap_or_default();
format!("{short} {}{lines}", sv(input, "agent"))
}
"mcp__hyperhive__get_host_journal" => {
let mut parts = Vec::new();
if let Some(c) = input.get("container").and_then(Value::as_str) {
parts.push(c.to_owned());
} else if let Some(u) = input.get("unit").and_then(Value::as_str) {
parts.push(u.to_owned());
}
if let Some(g) = input.get("grep").and_then(Value::as_str) {
parts.push(format!("/{g}/"));
}
if let Some(l) = input.get("lines").and_then(Value::as_u64) {
parts.push(format!("{l}L"));
}
if parts.is_empty() {
format!("{short}()")
} else {
format!("{short} {}", parts.join(" · "))
}
}
"mcp__hyperhive__remind" => fmt_hyperhive_remind(short, input),
"mcp__hyperhive__request_update_meta_inputs"
| "mcp__hyperhive__list_schedules"
| "mcp__hyperhive__cancel_schedule"
| "mcp__hyperhive__fire_schedule_now"
| "mcp__hyperhive__edit_schedule"
| "mcp__hyperhive__request_schedule_prompt" => {
fmt_hyperhive_schedule_tool(name, short, input)
}
"mcp__hyperhive__set_status" => {
format!("{short} \"{}\"", trim_str(&sv(input, "text"), 60))
}
"mcp__hyperhive__get_loose_ends" => {
let agent = input
.get("agent")
.and_then(Value::as_str)
.map_or_else(|| "()".to_owned(), |a| format!(" [{a}]"));
format!("{short}{agent}")
}
"mcp__hyperhive__get_agent_meta" => {
let name_part = input
.get("name")
.and_then(Value::as_str)
.map_or_else(|| "()".to_owned(), |n| format!(" {n}"));
format!("{short}{name_part}")
}
"mcp__hyperhive__cancel_loose_end" => {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
format!("{short} {} #{id}", sv(input, "kind"))
}
"mcp__hyperhive__mark_todos_done" => fmt_hyperhive_mark_todos_done(short, input),
_ => fmt_args_generic(short, input),
}
}
/// `mark_todos_done` — renders the acked ids so the terminal shows which
/// todos a bulk-clear call actually covered, not just a bracketed count
/// (`fmt_args_generic`'s generic array handling collapses to `ids: [N]`,
/// the count alone, which isn't useful here).
fn fmt_hyperhive_mark_todos_done(short: &str, input: &Value) -> String {
let ids = match input.get("ids").and_then(Value::as_array) {
Some(arr) if !arr.is_empty() => {
let nums: Vec<String> = arr
.iter()
.filter_map(Value::as_u64)
.take(8)
.map(|n| n.to_string())
.collect();
let tail = if arr.len() > 8 { ", …" } else { "" };
format!("[{}{tail}]", nums.join(", "))
}
_ => "[]".to_owned(),
};
format!("{short} {ids}")
}
fn fmt_hyperhive_remind(short: &str, input: &Value) -> String {
let when = if let Some(s) = input.get("delay_seconds").and_then(Value::as_u64) {
if s < 60 {
format!("+{s}s")
} else if s < 3_600 {
format!("+{}m", s / 60)
} else {
#[allow(clippy::cast_precision_loss)]
let h_f = s as f64 / 3_600.0;
format!("+{h_f:.1}h")
}
} else if let Some(ts) = input.get("at_unix_timestamp").and_then(Value::as_u64) {
let h = (ts % 86_400) / 3_600;
let m = (ts % 3_600) / 60;
format!("at {h:02}:{m:02}Z")
} else {
String::new()
};
let msg_raw = input
.get("message")
.or_else(|| input.get("file_path"))
.and_then(Value::as_str)
.unwrap_or("");
let msg = trim_str(&msg_raw.replace(char::is_whitespace, " "), 60);
let when_part = if when.is_empty() {
String::new()
} else {
format!(" {when}")
};
let msg_part = if msg.is_empty() {
String::new()
} else {
format!(" \"{msg}\"")
};
format!("{short}{when_part}{msg_part}")
}
fn fmt_hyperhive_edit_schedule(short: &str, input: &Value) -> String {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
let mut parts = vec![format!("#{id}")];
if input.get("body").is_some() {
parts.push("body".to_owned());
}
if input.get("interval_seconds").is_some() {
parts.push("interval".to_owned());
}
if input.get("next_fire_at_unix").is_some() {
parts.push("next".to_owned());
}
if let Some(a) = input.get("targets_add").and_then(Value::as_array)
&& !a.is_empty()
{
parts.push(format!("+{} tgt", a.len()));
}
if let Some(r) = input.get("targets_remove").and_then(Value::as_array)
&& !r.is_empty()
{
parts.push(format!("-{} tgt", r.len()));
}
format!("{short} {}", parts.join(" · "))
}
/// Schedule-management hyperhive tools (list/cancel/fire/edit/request).
fn fmt_hyperhive_schedule_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__hyperhive__request_update_meta_inputs" => {
let ins = match input.get("inputs").and_then(Value::as_array) {
Some(arr) if !arr.is_empty() => {
let names: Vec<&str> = arr.iter().filter_map(Value::as_str).take(4).collect();
let tail = if arr.len() > 4 { ", …" } else { "" };
format!("[{}{}]", names.join(", "), tail)
}
_ => "all".to_owned(),
};
format!("{short} {ins}")
}
"mcp__hyperhive__list_schedules" => format!("{short}()"),
"mcp__hyperhive__cancel_schedule" => {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
let tgts = match input.get("targets").and_then(Value::as_array) {
Some(arr) if !arr.is_empty() => {
let names: Vec<&str> = arr.iter().filter_map(Value::as_str).collect();
format!(" [{}]", names.join(", "))
}
_ => " all".to_owned(),
};
format!("{short} #{id}{tgts}")
}
"mcp__hyperhive__fire_schedule_now" => {
let id = input
.get("id")
.and_then(Value::as_u64)
.map_or_else(|| "?".to_owned(), |n| n.to_string());
format!("{short} #{id}")
}
"mcp__hyperhive__edit_schedule" => fmt_hyperhive_edit_schedule(short, input),
"mcp__hyperhive__request_schedule_prompt" => {
let tgts = match input.get("targets").and_then(Value::as_array) {
Some(arr) => arr
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", "),
None => "?".to_owned(),
};
let when = input
.get("first_fire_at_unix")
.and_then(Value::as_u64)
.map_or_else(
|| "?".to_owned(),
|ts| {
let h = (ts % 86_400) / 3_600;
let m = (ts % 3_600) / 60;
format!("{h:02}:{m:02}Z")
},
);
let recur = input
.get("interval_seconds")
.and_then(Value::as_u64)
.map(|s| format!(" +{s}s"))
.unwrap_or_default();
format!("{short}{tgts} at {when}{recur}")
}
_ => fmt_args_generic(short, input),
}
}
/// `mcp__bash__*` tools.
fn fmt_bash_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__bash__run" => {
let cmd = sv(input, "cmd");
let first = cmd.lines().next().unwrap_or("").trim().to_owned();
format!("{short} $ {}", trim_str(&first, 72))
}
"mcp__bash__status" => {
let wait = input
.get("wait_seconds")
.and_then(Value::as_u64)
.map(|w| format!(" · wait {w}s"))
.unwrap_or_default();
format!("{short} id:{}{wait}", sv(input, "id"))
}
"mcp__bash__kill" => {
let force = if input.get("force").and_then(Value::as_bool).unwrap_or(false) {
" [force]"
} else {
""
};
format!("{short} {}{force}", sv(input, "id"))
}
_ => fmt_args_generic(short, input),
}
}
/// `mcp__matrix__*` tools.
fn fmt_matrix_tool(name: &str, short: &str, input: &Value) -> String {
match name {
"mcp__matrix__read_room" => {
let limit = input
.get("limit")
.and_then(Value::as_u64)
.map(|l| format!(" [{l}]"))
.unwrap_or_default();
format!("{short} {}{limit}", fmt_room(&sv(input, "room")))
}
"mcp__matrix__mark_read" | "mcp__matrix__join_room" | "mcp__matrix__download_file" => {
format!("{short} {}", fmt_room(&sv(input, "room")))
}
"mcp__matrix__send_message" | "mcp__matrix__send_reply" => format!(
"{short}{}: {}",
fmt_room(&sv(input, "room")),
json_str(&trim_str(&sv(input, "body"), 50))
),
"mcp__matrix__send_dm" => format!(
"{short}{}: {}",
fmt_user(&sv(input, "user_id")),
json_str(&trim_str(&sv(input, "body"), 50))
),
"mcp__matrix__send_reaction" => format!(
"{short} {} {}",
fmt_room(&sv(input, "room")),
sv(input, "key")
),
"mcp__matrix__open_dm" => format!("{short} {}", fmt_user(&sv(input, "user_id"))),
"mcp__matrix__invite_user" => format!(
"{short} {}{}",
fmt_user(&sv(input, "user_id")),
fmt_room(&sv(input, "room"))
),
_ => fmt_args_generic(short, input),
}
}
// ---------------------------------------------------------------------------
// generic fallback formatter (port of JS fmtArgsGeneric)
// ---------------------------------------------------------------------------
fn fmt_args_generic(name: &str, input: &Value) -> String {
let Some(obj) = input.as_object() else {
return format!("{name}()");
};
if obj.is_empty() {
return format!("{name}()");
}
let keys: Vec<&String> = obj.keys().collect();
if keys.len() == 1 {
let k = keys[0];
let v = &obj[k];
if let Some(s) = v.as_str() {
return format!("{name} {k}: {}", json_str(&trim_str(s, 100)));
}
if v.is_number() || v.is_boolean() {
return format!("{name} {k}: {v}");
}
}
let pretty: Vec<String> = keys
.iter()
.take(4)
.map(|k| {
let v = &obj[*k];
if v.is_null() {
format!("{k}: null")
} else if let Some(s) = v.as_str() {
format!("{k}: {}", json_str(&trim_str(s, 40)))
} else if v.is_number() || v.is_boolean() {
format!("{k}: {v}")
} else if let Some(arr) = v.as_array() {
format!("{k}: [{}]", arr.len())
} else {
format!("{k}: {{}}")
}
})
.collect();
let tail = if keys.len() > 4 {
format!(" …+{}", keys.len() - 4)
} else {
String::new()
};
format!("{name} {}{tail}", pretty.join(" · "))
}
// ---------------------------------------------------------------------------
// string utilities
// ---------------------------------------------------------------------------
/// Format a token count as "1k", "1.2M", etc. (mirrors JS `fmtTok`).
fn fmt_tok(n: u64) -> String {
if n >= 1_000_000 {
// Divide before casting to keep the value in a range where f64
// has enough precision for one decimal place (≤ ~9×10^12 tokens).
#[allow(clippy::cast_precision_loss)]
let m = (n / 1_000) as f64 / 1_000.0;
format!("{m:.1}M")
} else if n >= 1_000 {
format!("{}k", n / 1_000)
} else {
n.to_string()
}
}
/// Shorten a matrix room id/alias for display.
fn fmt_room(r: &str) -> String {
if r.starts_with('!') {
// Room id: keep only the local part before the colon (up to 9 chars).
// Both halves of that are character counts — `find` returns a byte
// offset, and spending it as a character budget lets a multi-byte
// local part buy extra characters from the server half.
let local = r.split(':').next().unwrap_or(r);
local.chars().take(9).collect()
} else if r.starts_with('#') {
// Alias: keep `#name` part before the server.
r.split(':').next().unwrap_or(r).to_owned()
} else {
trim_str(r, 20)
}
}
/// Shorten `@user:server` → `@user`.
fn fmt_user(u: &str) -> String {
let end = u.find(':').unwrap_or(u.len());
u[..end].to_owned()
}
/// Extract a string field from a JSON object, defaulting to `""`.
fn sv(v: &Value, key: &str) -> String {
v.get(key).and_then(Value::as_str).unwrap_or("").to_owned()
}
/// Trim to `max` *characters* (not bytes), collapsing whitespace first.
fn trim_str(s: &str, max: usize) -> String {
let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
let mut chars = collapsed.chars();
let trimmed: String = chars.by_ref().take(max).collect();
if chars.next().is_some() {
format!("{trimmed}")
} else {
trimmed
}
}
/// Serialize a string as a JSON string literal (e.g. `"hello"`) for display
/// in summary lines. Falls back to the raw string if serialization fails.
fn json_str(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| s.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn one(v: &Value) -> TermMsg {
let mut ctx = ClassifyCtx::default();
let rows = classify_stream_value(v, &mut ctx);
assert_eq!(rows.len(), 1, "expected exactly one row for {v}");
rows.into_iter().next().unwrap()
}
// -----------------------------------------------------------------
// dispatch
// -----------------------------------------------------------------
#[test]
fn terminal_event_types_render_nothing() {
for t in ["result", "rate_limit_event"] {
let mut ctx = ClassifyCtx::default();
assert!(
classify_stream_value(&json!({ "type": t }), &mut ctx).is_empty(),
"{t} should be dropped"
);
}
}
/// The catch-all exists so a claude event shape nobody anticipated stays
/// *visible* rather than vanishing. Losing it is silent, which is why it
/// gets a test rather than a comment.
#[test]
fn unrecognised_shape_falls_through_loudly() {
let m = one(&json!({ "type": "something_new", "detail": "x" }));
assert_eq!(m.level, Level::Warn);
assert_eq!(m.icon.as_deref(), Some("!"));
assert!(m.summary.contains("something_new"));
}
#[test]
fn catch_all_truncates_a_huge_payload() {
let m = one(&json!({ "type": "unknown", "blob": "x".repeat(5_000) }));
assert_eq!(
m.summary.chars().count(),
201,
"200 chars plus the ellipsis"
);
assert!(m.summary.ends_with('…'));
}
/// Task events are matched on `subtype` *regardless of `type`* — a
/// deliberate asymmetry with every other arm, and one a refactor that
/// "tidies" the dispatch into a single match on `type` would silently
/// break.
#[test]
fn task_events_dispatch_on_subtype_not_type() {
let m = one(&json!({
"type": "assistant",
"subtype": "task_started",
"task_id": "abcdef1234567890",
"description": "do a thing",
}));
assert!(
m.summary.starts_with("task abcdef12 started"),
"{}",
m.summary
);
assert!(
!m.summary.contains("1234567890"),
"task id is truncated to 8 chars"
);
}
#[test]
fn a_failed_task_notification_is_an_error_row() {
let m = one(&json!({
"type": "x", "subtype": "task_notification",
"task_id": "t1", "status": "failed",
}));
assert_eq!(m.level, Level::Error);
}
// -----------------------------------------------------------------
// system events
// -----------------------------------------------------------------
#[test]
fn startup_noise_is_dropped_but_an_unknown_subtype_is_not() {
let mut ctx = ClassifyCtx::default();
assert!(
classify_stream_value(&json!({ "type": "system", "subtype": "init" }), &mut ctx)
.is_empty()
);
// The pairing is the point: "init produces nothing" only means
// something if a *different* subtype produces something.
let m = one(&json!({ "type": "system", "subtype": "brand_new" }));
assert_eq!(m.summary, "⚙ brand_new");
}
#[test]
fn system_severity_is_carried_by_subtype() {
let cases = [
("api_error", Level::Error),
("api_retry", Level::Warn),
("status", Level::Debug),
];
for (subtype, want) in cases {
let m = one(&json!({ "type": "system", "subtype": subtype }));
assert_eq!(m.level, want, "{subtype}");
}
}
/// Coalescing is what stops a heartbeat from flooding the terminal: rows
/// sharing a key collapse into one. A missing key is invisible in every
/// unit except the rendered stream.
#[test]
fn repeating_rows_carry_a_coalesce_key() {
let tick = one(&json!({ "type": "system", "subtype": "status" }));
assert_eq!(tick.coalesce_key.as_deref(), Some("status-tick"));
let think = one(&json!({
"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 1234,
}));
assert_eq!(think.coalesce_key.as_deref(), Some("thinking-tok"));
assert_eq!(think.summary, "thinking… ~1234 tokens");
// Same subtype, no count: still coalesces, still says something.
let bare = one(&json!({ "type": "system", "subtype": "thinking_tokens" }));
assert_eq!(bare.summary, "thinking…");
assert_eq!(bare.coalesce_key.as_deref(), Some("thinking-tok"));
}
#[test]
fn commands_changed_expands_into_the_command_list() {
let m = one(&json!({
"type": "system", "subtype": "commands_changed",
"commands": [{ "name": "compact" }, { "name": "loop" }],
}));
assert_eq!(m.summary, "⚙ commands changed · 2 available");
assert_eq!(m.body.as_deref(), Some("/compact\n/loop"));
}
#[test]
fn api_retry_summarises_every_field_it_was_given() {
let m = one(&json!({
"type": "system", "subtype": "api_retry",
"attempt": 2, "max_retries": 5,
"error_status": 529, "retry_delay_ms": 1500.4,
}));
assert_eq!(m.summary, "⚠ api retry · 2/5 · HTTP 529 · 1500ms");
}
/// `error` wins over `error_status` — the operator gets the message, not
/// the number, whenever there is one.
#[test]
fn api_error_prefers_the_message_over_the_status() {
let with_msg = one(&json!({
"type": "system", "subtype": "api_error",
"error": "overloaded", "error_status": 529,
}));
assert_eq!(with_msg.summary, "✗ api error · overloaded");
let status_only =
one(&json!({ "type": "system", "subtype": "api_error", "error_status": 529 }));
assert_eq!(status_only.summary, "✗ api error · HTTP 529");
let neither = one(&json!({ "type": "system", "subtype": "api_error" }));
assert_eq!(neither.summary, "✗ api error · unknown");
}
// -----------------------------------------------------------------
// tool results
// -----------------------------------------------------------------
/// The 120-char boundary decides between an inline summary and an
/// expandable body, and it counts *characters*, so a multi-byte result is
/// the case that would break a byte-based rewrite.
#[test]
fn tool_result_summary_boundary_counts_characters() {
let at = "a".repeat(120);
assert_eq!(summarize_tool_result(&at, &at), at);
let over = "a".repeat(121);
let s = summarize_tool_result(&over, &over);
assert!(s.starts_with("1L · "), "{s}");
assert!(s.ends_with('…'));
// 120 non-ASCII chars is 240 bytes: byte-length would call this long.
let wide = "é".repeat(120);
assert_eq!(summarize_tool_result(&wide, &wide), wide);
}
#[test]
fn an_empty_tool_result_says_so() {
assert_eq!(summarize_tool_result("", ""), "(empty)");
assert_eq!(summarize_tool_result(" \n\t ", ""), "(empty)");
}
#[test]
fn a_long_tool_result_reports_its_line_count() {
let txt = "line\n".repeat(40);
let trimmed: String = txt.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(summarize_tool_result(&txt, &trimmed).starts_with("40L · "));
}
/// Only a *matched* pair is claude's wrapper. A result that merely starts
/// with the opening tag is real output and must survive intact.
#[test]
fn the_error_wrapper_is_stripped_only_when_balanced() {
assert_eq!(
strip_tool_use_error_wrapper("<tool_use_error>boom</tool_use_error>"),
"boom"
);
assert_eq!(
strip_tool_use_error_wrapper(" <tool_use_error> boom </tool_use_error> "),
"boom"
);
let unbalanced = "<tool_use_error>boom";
assert_eq!(strip_tool_use_error_wrapper(unbalanced), unbalanced);
assert_eq!(strip_tool_use_error_wrapper("plain"), "plain");
}
fn tool_result(text: &str, is_error: bool, id: &str) -> Value {
json!({
"type": "user",
"message": { "content": [{
"type": "tool_result",
"tool_use_id": id,
"is_error": is_error,
"content": [{ "type": "text", "text": text }],
}]},
})
}
/// `classify_tool_result` has its **own** `<= 120`, separate from the one
/// in `summarize_tool_result`, and the two have to agree: one picks the
/// summary text, the other picks icon-vs-body. A result that summarises as
/// short but renders with a body is the inconsistency this pins.
/// Added because mutation testing found the gap — the case below uses 2
/// and 500 characters, so neither arm goes near the boundary and moving it
/// to 119 changed nothing.
#[test]
fn the_icon_or_body_choice_turns_on_the_same_120_boundary() {
let at = one(&tool_result(&"a".repeat(120), false, "t1"));
assert_eq!(at.icon.as_deref(), Some(""), "120 chars is still short");
assert!(at.body.is_none());
let over = one(&tool_result(&"a".repeat(121), false, "t1"));
assert!(over.icon.is_none(), "121 chars stops being short");
assert_eq!(over.body.as_deref().map(str::len), Some(121));
}
#[test]
fn a_short_result_gets_an_icon_and_a_long_one_gets_a_body() {
let short = one(&tool_result("ok", false, "t1"));
assert_eq!(short.icon.as_deref(), Some(""));
assert!(short.body.is_none(), "short results are not expandable");
let long = one(&tool_result(&"x".repeat(500), false, "t1"));
assert!(long.icon.is_none());
assert_eq!(long.body.as_deref().map(str::len), Some(500));
}
#[test]
fn an_error_result_is_an_error_row_with_the_wrapper_gone() {
let m = one(&tool_result(
"<tool_use_error>file not found</tool_use_error>",
true,
"t1",
));
assert_eq!(m.level, Level::Error);
assert_eq!(m.icon.as_deref(), Some(""));
assert_eq!(m.summary, "file not found");
}
/// The one piece of *cross-event* state in this module: the tool name is
/// learned from the assistant event and read back on the user event that
/// answers it. Nothing but a test spanning both events can catch it
/// breaking — and when it does, an inbox message silently renders as
/// plain text instead of markdown.
#[test]
fn a_recv_result_is_markdown_only_because_the_tool_use_was_seen_first() {
let mut ctx = ClassifyCtx::default();
let body = "**bold** message from a peer";
// Without the correlation, it is an ordinary result.
let cold = classify_stream_value(&tool_result(body, false, "call-1"), &mut ctx);
assert_eq!(cold[0].body_format, None);
assert!(!cold[0].summary.starts_with("recv ←"));
// Feed the assistant event that names the tool, then the same result.
classify_stream_value(
&json!({
"type": "assistant",
"message": { "content": [{
"type": "tool_use", "id": "call-1",
"name": "mcp__hyperhive__recv", "input": {},
}]},
}),
&mut ctx,
);
let warm = classify_stream_value(&tool_result(body, false, "call-1"), &mut ctx);
assert!(
warm[0].summary.starts_with("recv ← "),
"{}",
warm[0].summary
);
assert_eq!(warm[0].body_format, Some(BodyFormat::Markdown));
assert_eq!(warm[0].body.as_deref(), Some(body));
}
#[test]
fn a_recv_result_with_no_content_stays_an_ordinary_row() {
let mut ctx = ClassifyCtx::default();
ctx.record_tool_use("call-1", "mcp__hyperhive__recv");
let m = one_with(&tool_result(" ", false, "call-1"), &mut ctx);
assert!(!m.summary.starts_with("recv ←"));
assert_eq!(m.summary, "(empty)");
}
fn one_with(v: &Value, ctx: &mut ClassifyCtx) -> TermMsg {
let rows = classify_stream_value(v, ctx);
assert_eq!(rows.len(), 1, "expected exactly one row for {v}");
rows.into_iter().next().unwrap()
}
// -----------------------------------------------------------------
// assistant content
// -----------------------------------------------------------------
#[test]
fn assistant_text_becomes_a_markdown_body_with_no_summary() {
let m = one(&json!({
"type": "assistant",
"message": { "content": [{ "type": "text", "text": "# hi" }]},
}));
assert_eq!(m.summary, "");
assert_eq!(m.body.as_deref(), Some("# hi"));
assert_eq!(m.body_format, Some(BodyFormat::Markdown));
}
#[test]
fn blank_assistant_text_produces_no_row_at_all() {
let mut ctx = ClassifyCtx::default();
let rows = classify_stream_value(
&json!({
"type": "assistant",
"message": { "content": [{ "type": "text", "text": " \n " }]},
}),
&mut ctx,
);
assert!(rows.is_empty());
}
#[test]
fn one_assistant_event_can_produce_several_rows() {
let mut ctx = ClassifyCtx::default();
let rows = classify_stream_value(
&json!({
"type": "assistant",
"message": { "content": [
{ "type": "text", "text": "doing it" },
{ "type": "thinking", "thinking": "hmm" },
{ "type": "tool_use", "id": "t1", "name": "Bash",
"input": { "command": "ls" } },
{ "type": "something_else" },
]},
}),
&mut ctx,
);
assert_eq!(rows.len(), 3, "the unknown content block is skipped");
assert_eq!(rows[1].icon.as_deref(), Some("💭"));
// The tool_use was recorded for later correlation as a side effect.
assert_eq!(ctx.tool_name("t1"), Some("Bash"));
}
// -----------------------------------------------------------------
// string helpers
// -----------------------------------------------------------------
#[test]
fn token_counts_shorten_at_each_threshold() {
assert_eq!(fmt_tok(0), "0");
assert_eq!(fmt_tok(999), "999");
assert_eq!(fmt_tok(1_000), "1k");
assert_eq!(fmt_tok(999_999), "999k");
assert_eq!(fmt_tok(1_000_000), "1.0M");
assert_eq!(fmt_tok(1_250_000), "1.2M");
}
#[test]
fn trim_str_collapses_whitespace_and_counts_characters() {
assert_eq!(trim_str("a b\n\tc", 99), "a b c");
assert_eq!(trim_str("abcdef", 3), "abc…");
// Exactly at the limit: no ellipsis.
assert_eq!(trim_str("abc", 3), "abc");
// 4 chars / 8 bytes — a byte-based limit would cut this at 2.
assert_eq!(trim_str("éééé", 4), "éééé");
}
#[test]
fn matrix_ids_shorten_to_something_recognisable() {
assert_eq!(fmt_user("@mara:pr1ma.darkest.space"), "@mara");
assert_eq!(fmt_user("@mara"), "@mara");
assert_eq!(fmt_room("#hive-chat:pr1ma.darkest.space"), "#hive-chat");
assert_eq!(fmt_room("!abcdefghijkl:server"), "!abcdefgh");
assert_eq!(fmt_room("plain name"), "plain name");
}
/// A short room id keeps its whole local part and stops at the colon.
/// The non-ASCII case is the one that used to leak: `find(':')` is a
/// *byte* offset and it was being spent as a *character* budget, so a
/// multi-byte local part bought extra characters from the server half.
#[test]
fn a_room_id_never_shows_part_of_the_server() {
assert_eq!(fmt_room("!short:server"), "!short");
assert_eq!(fmt_room("!ÄÖÜ:server"), "!ÄÖÜ");
assert_eq!(fmt_room("!ÄÖÜ"), "!ÄÖÜ");
}
}