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