use super::*;
fn agent(id: &str, state: AgentState, ts: i64) -> Agent {
Agent {
branch_name: format!("agents/{id}"),
agent_id: id.to_string(),
tip_oid: "a".repeat(40),
tip_short_oid: "aaaaaaaa".into(),
tip_timestamp_unix: ts,
steps: vec![],
preview: None,
streaming_text: None,
tool_calls: vec![],
state,
state_uncertain: false,
pending_messages: 0,
conflicted_oid: None,
budget_oid: None,
abandoned_oid: None,
notify_oid: None,
goal_ball: None,
}
}
fn unseen(_: SeenKind, _: &str, _: &str, _: &str) -> bool {
false
}
fn plain(id: &str) -> ConvBall {
ConvBall {
id: id.to_owned(),
state: None,
title: None,
badge: None,
}
}
#[test]
fn one_row_per_root_with_subtree_member_counts() {
let agents = [
agent("r1", AgentState::Quiescent, 10),
agent("r1-c1", AgentState::Quiescent, 20),
agent("r2", AgentState::Quiescent, 30),
];
let rows = build(&agents, "/ws", &unseen, 100, &plain);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].root_id, "r2");
assert!(!rows[0].has_children());
assert_eq!(rows[1].root_id, "r1");
assert_eq!(rows[1].members, 2);
assert!(rows[1].has_children());
assert_eq!(rows[1].age_secs, 80, "age from the subtree's latest tip");
}
#[test]
fn sort_is_attention_then_running_then_recency() {
let agents = [
agent("idle-new", AgentState::Quiescent, 90),
agent("idle-old", AgentState::Quiescent, 10),
agent("running", AgentState::Live, 5),
agent("dead", AgentState::Stopped, 1),
];
let rows = build(&agents, "/ws", &unseen, 100, &plain);
let ids: Vec<&str> = rows.iter().map(|r| r.root_id.as_str()).collect();
assert_eq!(ids, ["dead", "running", "idle-new", "idle-old"]);
assert_eq!(rows[0].attention, 1);
assert_eq!(rows[1].attention, 0);
}
#[test]
fn subtree_state_aggregates_in_flight_over_live_over_root() {
let streaming = [
agent("r", AgentState::Quiescent, 1),
agent("r-c", AgentState::InFlight, 2),
];
let row = &build(&streaming, "/ws", &unseen, 10, &plain)[0];
assert_eq!(row.state, AgentState::InFlight);
assert!(row.streaming);
let live = [
agent("r", AgentState::Quiescent, 1),
agent("r-c", AgentState::Live, 2),
];
let row = &build(&live, "/ws", &unseen, 10, &plain)[0];
assert_eq!(row.state, AgentState::Live);
assert!(!row.streaming);
let mut root = agent("r", AgentState::Stopped, 1);
root.state_uncertain = true;
let settled = [root, agent("r-c", AgentState::Quiescent, 2)];
let row = &build(&settled, "/ws", &unseen, 10, &plain)[0];
assert_eq!(row.state, AgentState::Stopped);
assert!(row.uncertain);
}
#[test]
fn deciding_agents_uncertainty_is_carried() {
let mut child = agent("r-c", AgentState::InFlight, 2);
child.state_uncertain = true;
let agents = [agent("r", AgentState::Quiescent, 1), child];
assert!(build(&agents, "/ws", &unseen, 10, &plain)[0].uncertain);
}
#[test]
fn preview_is_the_roots_first_line_capped_with_streaming_fallback() {
let mut a = agent("r", AgentState::Quiescent, 1);
a.preview = Some("first line\nsecond line".into());
assert_eq!(
build(&[a], "/ws", &unseen, 10, &plain)[0].preview,
"first line"
);
let mut b = agent("r", AgentState::InFlight, 1);
b.streaming_text = Some("streamed so far".into());
assert_eq!(
build(&[b], "/ws", &unseen, 10, &plain)[0].preview,
"streamed so far",
"streaming text backfills a missing request preview"
);
let mut c = agent("r", AgentState::Quiescent, 1);
c.preview = Some("x".repeat(200));
assert_eq!(
build(&[c], "/ws", &unseen, 10, &plain)[0]
.preview
.chars()
.count(),
80,
"capped at the preview cap"
);
let d = agent("r", AgentState::Quiescent, 1);
assert_eq!(build(&[d], "/ws", &unseen, 10, &plain)[0].preview, "");
}
#[test]
fn the_ball_badge_is_the_roots_goal_stamp_coloured_by_the_join() {
use crate::projects::join::JoinState;
let resolve = |id: &str| ConvBall {
id: id.to_owned(),
state: Some(JoinState::Bound),
title: Some("Do the thing".to_owned()),
badge: None,
};
let mut root = agent("r", AgentState::Quiescent, 2);
root.goal_ball = Some("b1".to_owned());
let child = agent("r-c", AgentState::Quiescent, 1);
let rows = build(&[root, child], "/ws", &unseen, 10, &resolve);
let ball = rows[0].ball.as_ref().expect("the root's stamped ball");
assert_eq!(ball.id, "b1");
assert_eq!(ball.state, Some(JoinState::Bound));
assert_eq!(ball.title.as_deref(), Some("Do the thing"));
let bare = agent("bare", AgentState::Quiescent, 1);
assert!(build(&[bare], "/ws", &unseen, 10, &plain)[0].ball.is_none());
}
#[test]
fn age_clamps_clock_skew_to_zero() {
let a = agent("r", AgentState::Quiescent, 100);
assert_eq!(build(&[a], "/ws", &unseen, 50, &plain)[0].age_secs, 0);
}
#[test]
fn age_label_buckets() {
assert_eq!(age_label(-5), "0s");
assert_eq!(age_label(42), "42s");
assert_eq!(age_label(420), "7m");
assert_eq!(age_label(7200), "2h");
assert_eq!(age_label(200_000), "2d");
}
#[test]
fn members_returns_the_subtree_in_descent_order() {
let agents = [
agent("r1", AgentState::Quiescent, 1),
agent("r1-b", AgentState::Quiescent, 2),
agent("r1-a", AgentState::Quiescent, 3),
agent("r2", AgentState::Quiescent, 4),
];
let rows = members(&agents, "r1");
let ids: Vec<&str> = rows
.iter()
.filter_map(|r| agents.get(r.index))
.map(|a| a.agent_id.as_str())
.collect();
assert_eq!(
ids,
["r1", "r1-a", "r1-b"],
"root first, children id-sorted"
);
assert_eq!(rows[0].depth, 0);
assert_eq!(rows[1].depth, 1);
assert!(members(&agents, "r1-a").is_empty(), "a child is not a root");
assert!(members(&agents, "ghost").is_empty());
}
#[test]
fn root_of_resolves_any_member_to_its_conversation_root() {
let agents = [
agent("r1", AgentState::Quiescent, 1),
agent("r1-a", AgentState::Quiescent, 2),
agent("r2", AgentState::Quiescent, 3),
];
assert_eq!(root_of(&agents, "r1").as_deref(), Some("r1"));
assert_eq!(root_of(&agents, "r1-a").as_deref(), Some("r1"));
assert_eq!(root_of(&agents, "r2").as_deref(), Some("r2"));
assert_eq!(root_of(&agents, "ghost"), None);
assert_eq!(root_of(&[], "r1"), None);
}