diff --git a/hive-agent/src/stream_enrich.rs b/hive-agent/src/stream_enrich.rs index 39921b9d..654ac714 100644 --- a/hive-agent/src/stream_enrich.rs +++ b/hive-agent/src/stream_enrich.rs @@ -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::(); + let trimmed: String = txt.split_whitespace().collect::>().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("boom"), + "boom" + ); + assert_eq!( + strip_tool_use_error_wrapper(" boom "), + "boom" + ); + let unbalanced = "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( + "file not found", + 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"); + } +}