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:
parent
8f56f86df5
commit
74806f82a9
1 changed files with 378 additions and 0 deletions
|
|
@ -1048,3 +1048,381 @@ fn trim_str(s: &str, max: usize) -> String {
|
||||||
fn json_str(s: &str) -> String {
|
fn json_str(s: &str) -> String {
|
||||||
serde_json::to_string(s).unwrap_or_else(|_| s.to_owned())
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue