hive-agent: 25 behaviour tests for stream_enrich, which had none

WIP commit so the mutation tests below have a clean base to restore to.
This commit is contained in:
atlas 2026-09-02 10:35:44 +02:00 committed by mara
commit 74806f82a9

View file

@ -1048,3 +1048,381 @@ fn trim_str(s: &str, max: usize) -> String {
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 = (0..40).map(|i| format!("line {i}\n")).collect::<String>();
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 }],
}]},
})
}
#[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");
}
}