返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / agent_roster / tests.rs
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
495 lines RUST