| 1 | use super::*; |
| 2 | |
| 3 | use crate::agent_roster::{RosterState, build_agent_roster}; |
| 4 | use crate::tools::subagent::{ |
| 5 | AgentRunUsage, AgentWorkerEvent, AgentWorkerRecord, AgentWorkerSpec, AgentWorkerStatus, |
| 6 | AgentWorkerToolProfile, FleetRole, |
| 7 | }; |
| 8 | use crate::worker_profile::WorkerRuntimeProfile; |
| 9 | |
| 10 | fn usage_none() -> AgentRunUsage { |
| 11 | AgentRunUsage { |
| 12 | status: "unavailable".to_string(), |
| 13 | input_tokens: None, |
| 14 | output_tokens: None, |
| 15 | total_tokens: None, |
| 16 | cost_microusd: None, |
| 17 | note: "no route audit for this worker".to_string(), |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | fn usage_of(input: u64, output: u64) -> AgentRunUsage { |
| 22 | AgentRunUsage { |
| 23 | status: "reported".to_string(), |
| 24 | input_tokens: Some(input), |
| 25 | output_tokens: Some(output), |
| 26 | total_tokens: Some(input + output), |
| 27 | cost_microusd: Some(1_234), |
| 28 | ..usage_none() |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | /// Minimal record; each test overrides only the fields it is about. |
| 33 | fn record(worker_id: &str, created_at_ms: u64) -> AgentWorkerRecord { |
| 34 | let spec = AgentWorkerSpec { |
| 35 | worker_id: worker_id.to_string(), |
| 36 | run_id: format!("run-{worker_id}"), |
| 37 | parent_run_id: None, |
| 38 | session_name: Some(worker_id.to_string()), |
| 39 | objective: "do the thing".to_string(), |
| 40 | role: None, |
| 41 | agent_type: FleetRole::Scout, |
| 42 | model: "deepseek-v4-pro".to_string(), |
| 43 | workspace: std::path::PathBuf::from("/tmp/ws"), |
| 44 | git_branch: None, |
| 45 | context_mode: "fresh".to_string(), |
| 46 | fork_context: false, |
| 47 | tool_profile: AgentWorkerToolProfile::Inherited, |
| 48 | runtime_profile: WorkerRuntimeProfile::for_role(FleetRole::Scout), |
| 49 | max_steps: 20, |
| 50 | spawn_depth: 1, |
| 51 | max_spawn_depth: 3, |
| 52 | child_route: None, |
| 53 | launch_manifest: None, |
| 54 | }; |
| 55 | let mut rec = AgentWorkerRecord::new(spec, created_at_ms); |
| 56 | rec.status = AgentWorkerStatus::Running; |
| 57 | rec.started_at_ms = Some(created_at_ms); |
| 58 | rec.usage = usage_none(); |
| 59 | rec |
| 60 | } |
| 61 | |
| 62 | // === The truth rule: absent is not zero === |
| 63 | |
| 64 | #[test] |
| 65 | fn a_worker_without_a_usage_receipt_reports_no_tokens_not_zero() { |
| 66 | let rows = build_agent_roster(&[record("scout", 1_000)], 5_000); |
| 67 | let row = &rows[0]; |
| 68 | assert_eq!( |
| 69 | row.input_tokens, None, |
| 70 | "a missing route audit must stay missing" |
| 71 | ); |
| 72 | assert_eq!(row.output_tokens, None); |
| 73 | assert_eq!(row.cost_microusd, None); |
| 74 | |
| 75 | let text = render_agent_roster(&rows, "main"); |
| 76 | assert!( |
| 77 | text.contains('—'), |
| 78 | "an absent receipt renders as an em dash, never as 0:\n{text}" |
| 79 | ); |
| 80 | assert!( |
| 81 | !text.contains("↓ 0") && !text.contains("↑ 0"), |
| 82 | "no receipt must never be shown as a zero token count:\n{text}" |
| 83 | ); |
| 84 | } |
| 85 | |
| 86 | #[test] |
| 87 | fn reported_usage_is_passed_through_untouched() { |
| 88 | let mut rec = record("builder", 1_000); |
| 89 | rec.usage = usage_of(96_300, 4_120); |
| 90 | let rows = build_agent_roster(&[rec], 5_000); |
| 91 | assert_eq!(rows[0].input_tokens, Some(96_300)); |
| 92 | assert_eq!(rows[0].output_tokens, Some(4_120)); |
| 93 | |
| 94 | let text = render_agent_roster(&rows, "main"); |
| 95 | assert!(text.contains("↓ 96.3k"), "{text}"); |
| 96 | assert!(text.contains("↑ 4.1k"), "{text}"); |
| 97 | } |
| 98 | |
| 99 | #[test] |
| 100 | fn totals_are_absent_when_nothing_reported_and_never_summed_from_zeros() { |
| 101 | let rows = build_agent_roster(&[record("a", 1), record("b", 2)], 10); |
| 102 | assert_eq!( |
| 103 | roster_totals(&rows), |
| 104 | (None, None), |
| 105 | "summing absent receipts into 0 restates the same lie per-row rendering forbids" |
| 106 | ); |
| 107 | assert!(!all_rows_have_usage(&rows)); |
| 108 | |
| 109 | let mut reported = record("c", 3); |
| 110 | reported.usage = usage_of(10, 20); |
| 111 | let mixed = build_agent_roster(&[record("a", 1), reported], 10); |
| 112 | assert_eq!( |
| 113 | roster_totals(&mixed), |
| 114 | (Some(10), Some(20)), |
| 115 | "a partial total reports only what was actually reported" |
| 116 | ); |
| 117 | assert!( |
| 118 | !all_rows_have_usage(&mixed), |
| 119 | "callers must be able to label a partial total as partial" |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | // === Time: elapsed while live, frozen when finished === |
| 124 | |
| 125 | #[test] |
| 126 | fn a_finished_agent_keeps_the_duration_it_finished_with() { |
| 127 | let mut rec = record("done-worker", 1_000); |
| 128 | rec.status = AgentWorkerStatus::Completed; |
| 129 | rec.completed_at_ms = Some(4_500); |
| 130 | |
| 131 | let early = build_agent_roster(&[rec.clone()], 5_000); |
| 132 | let much_later = build_agent_roster(&[rec], 900_000); |
| 133 | assert_eq!(early[0].millis, Some(3_500)); |
| 134 | assert_eq!( |
| 135 | much_later[0].millis, |
| 136 | Some(3_500), |
| 137 | "a finished row must not keep ticking as the session goes on" |
| 138 | ); |
| 139 | } |
| 140 | |
| 141 | #[test] |
| 142 | fn a_live_agent_reports_elapsed_against_the_supplied_instant() { |
| 143 | let rows = build_agent_roster(&[record("live", 1_000)], 210_000); |
| 144 | assert_eq!(rows[0].millis, Some(209_000)); |
| 145 | let text = render_agent_roster(&rows, "main"); |
| 146 | assert!(text.contains("3m 29s"), "{text}"); |
| 147 | } |
| 148 | |
| 149 | #[test] |
| 150 | fn a_worker_that_never_started_has_no_duration_rather_than_zero() { |
| 151 | let mut rec = record("queued", 1_000); |
| 152 | rec.status = AgentWorkerStatus::Queued; |
| 153 | rec.started_at_ms = None; |
| 154 | let rows = build_agent_roster(&[rec], 9_000); |
| 155 | assert_eq!( |
| 156 | rows[0].millis, None, |
| 157 | "reporting 0s would imply the worker had started" |
| 158 | ); |
| 159 | } |
| 160 | |
| 161 | // === Activity line === |
| 162 | |
| 163 | #[test] |
| 164 | fn activity_prefers_the_newest_tool_event_and_stays_one_line() { |
| 165 | let mut rec = record("worker", 1_000); |
| 166 | rec.events.push_back(AgentWorkerEvent { |
| 167 | seq: 1, |
| 168 | worker_id: "worker".to_string(), |
| 169 | status: AgentWorkerStatus::Running, |
| 170 | timestamp_ms: 1_100, |
| 171 | message: Some("starting".to_string()), |
| 172 | step: Some(1), |
| 173 | tool_name: Some("Grep".to_string()), |
| 174 | }); |
| 175 | rec.events.push_back(AgentWorkerEvent { |
| 176 | seq: 2, |
| 177 | worker_id: "worker".to_string(), |
| 178 | status: AgentWorkerStatus::RunningTool, |
| 179 | timestamp_ms: 1_200, |
| 180 | message: None, |
| 181 | step: Some(4), |
| 182 | tool_name: Some("Bash".to_string()), |
| 183 | }); |
| 184 | let rows = build_agent_roster(&[rec], 2_000); |
| 185 | assert_eq!(rows[0].activity.as_deref(), Some("step 4 · Bash")); |
| 186 | } |
| 187 | |
| 188 | #[test] |
| 189 | fn a_multiline_message_is_flattened_and_bounded_to_one_row() { |
| 190 | let mut rec = record("chatty", 1_000); |
| 191 | rec.latest_message = Some(format!("line one\nline two\n{}", "x".repeat(300))); |
| 192 | let rows = build_agent_roster(&[rec], 2_000); |
| 193 | let activity = rows[0].activity.clone().expect("activity"); |
| 194 | assert!(!activity.contains('\n'), "a rail row is one row"); |
| 195 | assert!(activity.chars().count() <= 72, "{}", activity.len()); |
| 196 | assert!(activity.ends_with('…'), "truncation is visible: {activity}"); |
| 197 | } |
| 198 | |
| 199 | #[test] |
| 200 | fn a_worker_with_no_events_reports_no_activity() { |
| 201 | let rows = build_agent_roster(&[record("silent", 1_000)], 2_000); |
| 202 | assert_eq!(rows[0].activity, None); |
| 203 | assert!(render_agent_roster(&rows, "main").contains('—')); |
| 204 | } |
| 205 | |
| 206 | // === Ordering and workflow aggregation === |
| 207 | |
| 208 | #[test] |
| 209 | fn rows_are_ordered_oldest_first_so_the_rail_reads_as_history() { |
| 210 | let rows = build_agent_roster( |
| 211 | &[ |
| 212 | record("third", 3_000), |
| 213 | record("first", 1_000), |
| 214 | record("second", 2_000), |
| 215 | ], |
| 216 | 9_000, |
| 217 | ); |
| 218 | let names: Vec<&str> = rows.iter().map(|row| row.display_name.as_str()).collect(); |
| 219 | assert_eq!(names, vec!["first", "second", "third"]); |
| 220 | } |
| 221 | |
| 222 | #[test] |
| 223 | fn a_workflow_parent_aggregates_its_children_as_n_of_m_done() { |
| 224 | let parent = record("workflow", 1_000); |
| 225 | let parent_run = parent.spec.run_id.clone(); |
| 226 | let mut children = Vec::new(); |
| 227 | for index in 0..6 { |
| 228 | let mut child = record(&format!("child-{index}"), 2_000 + index as u64); |
| 229 | child.parent_run_id = Some(parent_run.clone()); |
| 230 | child.spec.parent_run_id = Some(parent_run.clone()); |
| 231 | if index < 5 { |
| 232 | child.status = AgentWorkerStatus::Completed; |
| 233 | child.completed_at_ms = Some(3_000); |
| 234 | } |
| 235 | children.push(child); |
| 236 | } |
| 237 | let mut records = vec![parent]; |
| 238 | records.extend(children); |
| 239 | let rows = build_agent_roster(&records, 9_000); |
| 240 | |
| 241 | let text = render_agent_roster(&rows, "main"); |
| 242 | assert!( |
| 243 | text.contains("5/6 agents done"), |
| 244 | "a workflow collapses to one progress line:\n{text}" |
| 245 | ); |
| 246 | // Children are listed under the parent, indented, and exactly once. |
| 247 | assert_eq!( |
| 248 | text.matches("child-0").count(), |
| 249 | 1, |
| 250 | "a child must not also appear at the top level:\n{text}" |
| 251 | ); |
| 252 | assert!(text.contains(" ○ child-0"), "{text}"); |
| 253 | } |
| 254 | |
| 255 | #[test] |
| 256 | fn a_grandchild_is_rendered_under_its_parent_not_dropped() { |
| 257 | let mut parent = record("workflow", 1_000); |
| 258 | parent.status = AgentWorkerStatus::Running; |
| 259 | let parent_run = parent.spec.run_id.clone(); |
| 260 | let mut child = record("builder", 2_000); |
| 261 | child.parent_run_id = Some(parent_run.clone()); |
| 262 | child.spec.parent_run_id = Some(parent_run); |
| 263 | child.status = AgentWorkerStatus::Running; |
| 264 | let child_run = child.spec.run_id.clone(); |
| 265 | let mut grandchild = record("scout", 3_000); |
| 266 | grandchild.parent_run_id = Some(child_run); |
| 267 | grandchild.spec.parent_run_id = Some(child.spec.run_id.clone()); |
| 268 | grandchild.status = AgentWorkerStatus::Running; |
| 269 | |
| 270 | let rows = build_agent_roster(&[parent, child, grandchild], 9_000); |
| 271 | let text = render_agent_roster(&rows, "main"); |
| 272 | assert!( |
| 273 | text.contains("scout"), |
| 274 | "a third-level agent must appear in /agents list:\n{text}" |
| 275 | ); |
| 276 | assert_eq!(text.matches("scout").count(), 1, "{text}"); |
| 277 | assert!( |
| 278 | text.contains(" "), |
| 279 | "a grandchild should be indented under its parent:\n{text}" |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | #[test] |
| 284 | fn an_orphaned_child_still_appears_when_its_parent_is_not_in_the_roster() { |
| 285 | // The parent's record can age out of the ledger before the child's does. |
| 286 | let mut child = record("orphan", 2_000); |
| 287 | child.parent_run_id = Some("run-that-aged-out".to_string()); |
| 288 | let rows = build_agent_roster(&[child], 9_000); |
| 289 | let text = render_agent_roster(&rows, "main"); |
| 290 | assert!( |
| 291 | text.contains("orphan"), |
| 292 | "a child whose parent is gone must not vanish from the roster:\n{text}" |
| 293 | ); |
| 294 | } |
| 295 | |
| 296 | // === Rendering basics === |
| 297 | |
| 298 | #[test] |
| 299 | fn an_empty_roster_says_so_instead_of_rendering_an_empty_frame() { |
| 300 | let text = render_agent_roster(&[], "main"); |
| 301 | assert!(text.contains("● main")); |
| 302 | assert!(text.contains("No agents have run in this session yet")); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn every_state_has_a_distinct_single_width_glyph() { |
| 307 | let states = [ |
| 308 | RosterState::Running, |
| 309 | RosterState::Waiting, |
| 310 | RosterState::Done, |
| 311 | RosterState::Failed, |
| 312 | RosterState::Cancelled, |
| 313 | ]; |
| 314 | let glyphs: Vec<&str> = states.iter().map(|state| state.glyph()).collect(); |
| 315 | let unique: std::collections::HashSet<&&str> = glyphs.iter().collect(); |
| 316 | assert_eq!(unique.len(), glyphs.len(), "glyphs must be distinguishable"); |
| 317 | for glyph in glyphs { |
| 318 | assert_eq!(glyph.chars().count(), 1, "{glyph} must occupy one cell"); |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn interrupted_and_cancelled_both_read_as_cancelled_not_failed() { |
| 324 | for status in [AgentWorkerStatus::Cancelled, AgentWorkerStatus::Interrupted] { |
| 325 | let mut rec = record("stopped", 1_000); |
| 326 | rec.status = status; |
| 327 | rec.completed_at_ms = Some(2_000); |
| 328 | let rows = build_agent_roster(&[rec], 9_000); |
| 329 | assert_eq!( |
| 330 | rows[0].state, |
| 331 | RosterState::Cancelled, |
| 332 | "{status:?} is a stop, not a failure — a red ✗ would misreport it" |
| 333 | ); |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | #[test] |
| 338 | fn formatters_never_round_a_real_count_down_to_nothing() { |
| 339 | assert_eq!(format_tokens(0), "0"); |
| 340 | assert_eq!(format_tokens(1), "1"); |
| 341 | assert_eq!(format_tokens(999), "999"); |
| 342 | assert_eq!(format_tokens(1_000), "1.0k"); |
| 343 | assert_eq!(format_tokens(96_300), "96.3k"); |
| 344 | assert_eq!(format_tokens(1_200_000), "1.2M"); |
| 345 | |
| 346 | assert_eq!(format_duration(450), "450ms"); |
| 347 | assert_eq!(format_duration(12_000), "12s"); |
| 348 | assert_eq!(format_duration(209_000), "3m 29s"); |
| 349 | assert_eq!(format_duration(3_600_000 + 120_000), "1h 2m"); |
| 350 | } |
| 351 | |
| 352 | #[test] |
| 353 | fn a_partial_totals_line_says_it_is_partial() { |
| 354 | let mut reported = record("builder", 2_000); |
| 355 | reported.usage = usage_of(96_300, 4_120); |
| 356 | let rows = build_agent_roster(&[record("scout", 1_000), reported], 9_000); |
| 357 | let text = render_agent_roster(&rows, "main"); |
| 358 | assert!( |
| 359 | text.contains("receipts from 1 of 2 agents"), |
| 360 | "a bare total silently implies it covers every agent listed:\n{text}" |
| 361 | ); |
| 362 | |
| 363 | let mut both = record("second", 3_000); |
| 364 | both.usage = usage_of(1_000, 500); |
| 365 | let mut first = record("first", 1_000); |
| 366 | first.usage = usage_of(2_000, 500); |
| 367 | let full = build_agent_roster(&[first, both], 9_000); |
| 368 | let full_text = render_agent_roster(&full, "main"); |
| 369 | assert!(full_text.contains("↓ 3.0k"), "{full_text}"); |
| 370 | assert!( |
| 371 | !full_text.contains("receipts from"), |
| 372 | "a complete total needs no caveat:\n{full_text}" |
| 373 | ); |
| 374 | } |
| 375 | |
| 376 | #[test] |
| 377 | fn a_roster_with_no_receipts_at_all_says_so_rather_than_showing_zeros() { |
| 378 | let rows = build_agent_roster(&[record("a", 1_000), record("b", 2_000)], 9_000); |
| 379 | let text = render_agent_roster(&rows, "main"); |
| 380 | assert!( |
| 381 | text.contains("2 agents · no usage receipts recorded"), |
| 382 | "{text}" |
| 383 | ); |
| 384 | assert!(!text.contains("↓ 0"), "{text}"); |
| 385 | } |
| 386 | |
| 387 | /// Renders the whole thing once so the shape is reviewable in one place. |
| 388 | #[test] |
| 389 | fn rendered_roster_shape() { |
| 390 | let parent = record("refactor-workflow", 1_000); |
| 391 | let parent_run = parent.spec.run_id.clone(); |
| 392 | let mut scout = record("scout", 2_000); |
| 393 | scout.parent_run_id = Some(parent_run.clone()); |
| 394 | scout.status = AgentWorkerStatus::Completed; |
| 395 | scout.completed_at_ms = Some(120_000); |
| 396 | scout.usage = usage_of(96_300, 4_120); |
| 397 | let mut builder = record("builder", 3_000); |
| 398 | builder.parent_run_id = Some(parent_run); |
| 399 | builder.events.push_back(AgentWorkerEvent { |
| 400 | seq: 1, |
| 401 | worker_id: "builder".to_string(), |
| 402 | status: AgentWorkerStatus::RunningTool, |
| 403 | timestamp_ms: 4_000, |
| 404 | message: None, |
| 405 | step: Some(7), |
| 406 | tool_name: Some("apply_patch".to_string()), |
| 407 | }); |
| 408 | builder.usage = usage_of(210_400, 18_900); |
| 409 | |
| 410 | let rows = build_agent_roster(&[parent, scout, builder], 212_000); |
| 411 | let text = render_agent_roster(&rows, "main"); |
| 412 | assert!(text.contains("1/2 agents done"), "{text}"); |
| 413 | assert!(text.contains("step 7 · apply_patch"), "{text}"); |
| 414 | assert!(text.contains("↓ 306.7k"), "{text}"); |
| 415 | } |
| 416 | |
| 417 | // === #5906: a parked husk is not an agent waiting for input === |
| 418 | |
| 419 | /// A child parked at the parent's turn end settles as `WaitingForUser` like a |
| 420 | /// child that really asked a question. Only `parked_at_turn_end` separates |
| 421 | /// them, and the rail has to read it or it prints the same row for both. |
| 422 | #[test] |
| 423 | fn a_parked_record_is_its_own_roster_state_not_waiting() { |
| 424 | let mut parked = record("parked_child", 1_000); |
| 425 | parked.status = AgentWorkerStatus::WaitingForUser; |
| 426 | parked.parked_at_turn_end = true; |
| 427 | |
| 428 | let mut asked = record("asking_child", 2_000); |
| 429 | asked.status = AgentWorkerStatus::WaitingForUser; |
| 430 | |
| 431 | let rows = build_agent_roster(&[parked, asked], 5_000); |
| 432 | let parked_row = rows |
| 433 | .iter() |
| 434 | .find(|row| row.worker_id == "parked_child") |
| 435 | .expect("parked row"); |
| 436 | let asked_row = rows |
| 437 | .iter() |
| 438 | .find(|row| row.worker_id == "asking_child") |
| 439 | .expect("asking row"); |
| 440 | |
| 441 | assert_eq!(parked_row.state, RosterState::Parked); |
| 442 | assert_eq!( |
| 443 | asked_row.state, |
| 444 | RosterState::Waiting, |
| 445 | "a child that genuinely asked must keep the answerable state" |
| 446 | ); |
| 447 | assert_ne!(parked_row.state.glyph(), asked_row.state.glyph()); |
| 448 | assert!( |
| 449 | !parked_row.state.is_terminal(), |
| 450 | "parked work is unfinished, not settled-done" |
| 451 | ); |
| 452 | } |
| 453 | |
| 454 | /// The rail is otherwise oldest-first, but a parked husk is exactly the row an |
| 455 | /// operator must not have to scan past to find live work. |
| 456 | #[test] |
| 457 | fn parked_husks_sort_below_live_and_waiting_agents() { |
| 458 | let mut parked = record("aaa_parked", 1_000); |
| 459 | parked.status = AgentWorkerStatus::WaitingForUser; |
| 460 | parked.parked_at_turn_end = true; |
| 461 | |
| 462 | let running = record("bbb_running", 2_000); |
| 463 | |
| 464 | let mut waiting = record("ccc_waiting", 3_000); |
| 465 | waiting.status = AgentWorkerStatus::WaitingForUser; |
| 466 | |
| 467 | let rows = build_agent_roster(&[parked, running, waiting], 9_000); |
| 468 | let order: Vec<&str> = rows.iter().map(|row| row.worker_id.as_str()).collect(); |
| 469 | assert_eq!( |
| 470 | order, |
| 471 | vec!["bbb_running", "ccc_waiting", "aaa_parked"], |
| 472 | "the parked husk sinks below live and answerable rows despite being oldest" |
| 473 | ); |
| 474 | } |
| 475 | |
| 476 | /// Re-dispatch clears the flag on the record (#5921); the rail must follow it |
| 477 | /// back rather than latching a row as parked forever. |
| 478 | #[test] |
| 479 | fn a_re_dispatched_record_stops_reading_as_parked() { |
| 480 | let mut rec = record("resumed", 1_000); |
| 481 | rec.status = AgentWorkerStatus::WaitingForUser; |
| 482 | rec.parked_at_turn_end = true; |
| 483 | assert_eq!( |
| 484 | build_agent_roster(&[rec.clone()], 5_000)[0].state, |
| 485 | RosterState::Parked |
| 486 | ); |
| 487 | |
| 488 | rec.parked_at_turn_end = false; |
| 489 | rec.status = AgentWorkerStatus::RunningTool; |
| 490 | assert_eq!( |
| 491 | build_agent_roster(&[rec], 5_000)[0].state, |
| 492 | RosterState::Running |
| 493 | ); |
| 494 | } |
| 495 |