1use chrono::{DateTime, Utc};
18use luft_core::contract::event::AgentEvent;
19use luft_core::contract::ids::RunId;
20use luft_core::state::{CheckpointStatus, RunCheckpoint};
21use luft_planner::PlanMeta;
22use serde::Serialize;
23use std::collections::{HashMap, HashSet};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum PhasesSource {
29 Meta,
30 EventsFallback,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum PhaseStatus {
37 Pending,
38 Running,
39 Completed,
40 Failed,
41}
42
43impl PhaseStatus {
44 pub fn as_str(self) -> &'static str {
45 match self {
46 PhaseStatus::Pending => "pending",
47 PhaseStatus::Running => "running",
48 PhaseStatus::Completed => "completed",
49 PhaseStatus::Failed => "failed",
50 }
51 }
52
53 pub fn bracket(self) -> &'static str {
54 match self {
55 PhaseStatus::Pending => "[pending]",
56 PhaseStatus::Running => "[running]",
57 PhaseStatus::Completed => "[completed]",
58 PhaseStatus::Failed => "[failed]",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize)]
65pub struct PhasesView {
66 pub run: RunHeader,
67 pub source: PhasesSource,
68 pub phases: Vec<PhaseRow>,
69}
70
71#[derive(Debug, Clone, Serialize)]
73pub struct RunHeader {
74 pub run_id: RunId,
75 pub task: String,
76 pub status: CheckpointStatus,
77 pub current_phase: u32,
78 pub total_phases: u32,
79 pub total_tokens: u64,
80 pub elapsed_secs: Option<f64>,
81 pub created_at: u64,
82}
83
84#[derive(Debug, Clone, Serialize)]
86pub struct PhaseRow {
87 pub phase_id: u32,
88 pub label: String,
89 pub detail: Option<String>,
90 pub status: PhaseStatus,
91 pub planned: Option<usize>,
92 pub ok: usize,
93 pub failed: usize,
94 pub elapsed_secs: Option<f64>,
95 pub agents: Vec<AgentRow>,
96}
97
98#[derive(Debug, Clone, Serialize)]
100pub struct AgentRow {
101 pub short_id: String,
102 pub status: String,
103 pub tokens: Option<u64>,
104 pub findings: usize,
105 pub tool_count: Option<usize>,
106 pub last_message: Option<String>,
107}
108
109pub fn build_phases_view(checkpoint: &RunCheckpoint, events: &[AgentEvent]) -> PhasesView {
113 let source = if checkpoint.workflow_meta.is_some() {
114 PhasesSource::Meta
115 } else {
116 PhasesSource::EventsFallback
117 };
118
119 let phases = match &checkpoint.workflow_meta {
120 Some(meta_json) => {
121 let meta: luft_planner::PlanMeta = serde_json::from_value(meta_json.clone())
122 .unwrap_or_else(|e| {
123 tracing::warn!(error = %e, "failed to deserialize workflow_meta; falling back to events");
124 luft_planner::PlanMeta::default()
125 });
126 build_from_meta(&meta, checkpoint, events)
127 }
128 None => build_from_events(checkpoint, events),
129 };
130
131 let total_phases = phases.len() as u32;
132 let elapsed_secs = compute_run_elapsed(events, checkpoint);
133
134 PhasesView {
135 run: RunHeader {
136 run_id: checkpoint.run_id,
137 task: checkpoint.task.clone(),
138 status: checkpoint.status.clone(),
139 current_phase: checkpoint.current_phase,
140 total_phases,
141 total_tokens: checkpoint.total_tokens,
142 elapsed_secs,
143 created_at: checkpoint.created_at,
144 },
145 source,
146 phases,
147 }
148}
149
150fn build_from_meta(
155 meta: &PlanMeta,
156 checkpoint: &RunCheckpoint,
157 events: &[AgentEvent],
158) -> Vec<PhaseRow> {
159 let phase_started_ts = collect_phase_started_ts(events);
160 let phase_done_info = collect_phase_done_info(events);
161 let completed_map = build_completed_map(checkpoint);
162 let completed_agents = collect_completed_agents(checkpoint);
163 let running_agents = collect_running_agents(events, &checkpoint.agent_results);
164
165 meta.phases
166 .iter()
167 .enumerate()
168 .map(|(idx, mp)| {
169 let phase_id = (idx + 1) as u32;
170
171 let (ok, failed, status) = resolve_phase_status(phase_id, checkpoint, &completed_map);
172
173 let elapsed_secs = compute_phase_elapsed(phase_id, &phase_started_ts, &phase_done_info);
174
175 let agents = build_agent_rows(phase_id, &completed_agents, &running_agents);
176
177 PhaseRow {
178 phase_id,
179 label: mp.label.clone(),
180 detail: Some(mp.detail.clone()).filter(|d| !d.is_empty()),
181 status,
182 planned: if mp.agents > 0 { Some(mp.agents) } else { None },
183 ok,
184 failed,
185 elapsed_secs,
186 agents,
187 }
188 })
189 .collect()
190}
191
192fn build_from_events(checkpoint: &RunCheckpoint, events: &[AgentEvent]) -> Vec<PhaseRow> {
193 let phase_started_ts = collect_phase_started_ts(events);
194 let phase_done_info = collect_phase_done_info(events);
195 let completed_map = build_completed_map(checkpoint);
196 let completed_agents = collect_completed_agents(checkpoint);
197 let running_agents = collect_running_agents(events, &checkpoint.agent_results);
198
199 let mut phase_ids: Vec<(u32, String, usize)> = Vec::new();
201 let mut seen: HashSet<u32> = HashSet::new();
202 for e in events {
203 if let AgentEvent::PhaseStarted {
204 phase_id,
205 label,
206 planned,
207 ..
208 } = e
209 {
210 if seen.insert(*phase_id) {
211 phase_ids.push((*phase_id, label.clone(), *planned));
212 }
213 }
214 }
215
216 if phase_ids.is_empty() {
217 return vec![];
218 }
219
220 phase_ids
221 .iter()
222 .map(|(phase_id, label, planned)| {
223 let (ok, failed, status) = resolve_phase_status(*phase_id, checkpoint, &completed_map);
224 let elapsed_secs =
225 compute_phase_elapsed(*phase_id, &phase_started_ts, &phase_done_info);
226 let agents = build_agent_rows(*phase_id, &completed_agents, &running_agents);
227
228 PhaseRow {
229 phase_id: *phase_id,
230 label: label.clone(),
231 detail: None,
232 status,
233 planned: Some(*planned),
234 ok,
235 failed,
236 elapsed_secs,
237 agents,
238 }
239 })
240 .collect()
241}
242
243fn resolve_phase_status(
248 phase_id: u32,
249 checkpoint: &RunCheckpoint,
250 completed_map: &HashMap<u32, (usize, usize)>,
251) -> (usize, usize, PhaseStatus) {
252 if let Some((ok, failed)) = completed_map.get(&phase_id) {
253 let status = if *failed > 0 {
254 PhaseStatus::Failed
255 } else {
256 PhaseStatus::Completed
257 };
258 return (*ok, *failed, status);
259 }
260
261 let status = if checkpoint.current_phase == 0 {
263 PhaseStatus::Pending
264 } else if phase_id < checkpoint.current_phase {
265 PhaseStatus::Completed
266 } else if phase_id == checkpoint.current_phase {
267 PhaseStatus::Running
268 } else {
269 PhaseStatus::Pending
270 };
271
272 (0, 0, status)
273}
274
275fn build_completed_map(checkpoint: &RunCheckpoint) -> HashMap<u32, (usize, usize)> {
276 checkpoint
277 .completed_phases
278 .iter()
279 .map(|s| (s.phase_id, (s.ok, s.failed)))
280 .collect()
281}
282
283fn collect_completed_agents(checkpoint: &RunCheckpoint) -> HashMap<u32, Vec<AgentRow>> {
289 let mut map: HashMap<u32, Vec<AgentRow>> = HashMap::new();
290 for cache in checkpoint.agent_results.values() {
291 let row = AgentRow {
292 short_id: format!("{:.8}", cache.agent_id),
293 status: agent_status_str(&cache.status),
294 tokens: Some(cache.tokens),
295 findings: cache.findings.len(),
296 tool_count: None,
297 last_message: None,
298 };
299 map.entry(cache.phase_id).or_default().push(row);
300 }
301 map
302}
303
304fn collect_running_agents(
306 events: &[AgentEvent],
307 completed: &HashMap<luft_core::contract::ids::AgentId, luft_core::state::AgentResultCache>,
308) -> HashMap<u32, Vec<AgentRow>> {
309 let mut done_agents: HashSet<luft_core::contract::ids::AgentId> = HashSet::new();
310 for e in events {
311 if let AgentEvent::AgentDone { agent_id, .. } = e {
312 done_agents.insert(*agent_id);
313 }
314 }
315
316 let mut map: HashMap<u32, Vec<AgentRow>> = HashMap::new();
317 for e in events {
318 if let AgentEvent::AgentStarted {
319 phase_id, agent_id, ..
320 } = e
321 {
322 if completed.contains_key(agent_id) || done_agents.contains(agent_id) {
323 continue;
324 }
325 let row = AgentRow {
326 short_id: format!("{:.8}", agent_id),
327 status: "running".to_string(),
328 tokens: None,
329 findings: 0,
330 tool_count: None,
331 last_message: None,
332 };
333 map.entry(*phase_id).or_default().push(row);
334 }
335 }
336 map
337}
338
339fn build_agent_rows(
340 phase_id: u32,
341 completed: &HashMap<u32, Vec<AgentRow>>,
342 running: &HashMap<u32, Vec<AgentRow>>,
343) -> Vec<AgentRow> {
344 let mut agents = Vec::new();
345 if let Some(c) = completed.get(&phase_id) {
346 agents.extend(c.iter().cloned());
347 }
348 if let Some(r) = running.get(&phase_id) {
349 agents.extend(r.iter().cloned());
350 }
351 agents
352}
353
354fn agent_status_str(status: &str) -> String {
355 match status {
356 "ok" | "Ok" | "OK" => "completed".to_string(),
357 "error" | "Error" => "failed".to_string(),
358 "cancelled" | "Cancelled" => "cancelled".to_string(),
359 "timed_out" | "TimedOut" | "timedout" => "timed_out".to_string(),
360 other => other.to_lowercase(),
361 }
362}
363
364fn collect_phase_started_ts(events: &[AgentEvent]) -> HashMap<u32, DateTime<Utc>> {
369 let mut map = HashMap::new();
370 for e in events {
371 if let AgentEvent::PhaseStarted { phase_id, ts, .. } = e {
372 map.entry(*phase_id).or_insert(*ts);
373 }
374 }
375 map
376}
377
378#[derive(Debug)]
379struct PhaseDoneInfo {
380 ts: DateTime<Utc>,
381 #[allow(dead_code)]
382 ok: usize,
383 #[allow(dead_code)]
384 failed: usize,
385}
386
387fn collect_phase_done_info(events: &[AgentEvent]) -> HashMap<u32, PhaseDoneInfo> {
388 let mut map = HashMap::new();
389 for e in events {
390 if let AgentEvent::PhaseDone {
391 phase_id,
392 ts,
393 ok,
394 failed,
395 ..
396 } = e
397 {
398 map.insert(
399 *phase_id,
400 PhaseDoneInfo {
401 ts: *ts,
402 ok: *ok,
403 failed: *failed,
404 },
405 );
406 }
407 }
408 map
409}
410
411fn compute_phase_elapsed(
412 phase_id: u32,
413 started: &HashMap<u32, DateTime<Utc>>,
414 done: &HashMap<u32, PhaseDoneInfo>,
415) -> Option<f64> {
416 let start = started.get(&phase_id)?;
417 let end = done.get(&phase_id)?;
418 let dur = end.ts.signed_duration_since(*start);
419 let secs = dur.num_milliseconds() as f64 / 1000.0;
420 if secs >= 0.0 {
421 Some(secs)
422 } else {
423 None
424 }
425}
426
427fn compute_run_elapsed(events: &[AgentEvent], checkpoint: &RunCheckpoint) -> Option<f64> {
428 let run_started = events.iter().find_map(|e| {
429 if let AgentEvent::RunStarted { ts, .. } = e {
430 Some(*ts)
431 } else {
432 None
433 }
434 });
435
436 let run_done = events.iter().find_map(|e| {
437 if let AgentEvent::RunDone { ts, .. } = e {
438 Some(*ts)
439 } else {
440 None
441 }
442 });
443
444 match (run_started, run_done) {
445 (Some(start), Some(end)) => {
446 let secs = end.signed_duration_since(start).num_milliseconds() as f64 / 1000.0;
447 if secs >= 0.0 {
448 Some(secs)
449 } else {
450 None
451 }
452 }
453 (Some(start), None) => {
454 let secs = Utc::now().signed_duration_since(start).num_milliseconds() as f64 / 1000.0;
456 if secs >= 0.0 {
457 Some(secs)
458 } else {
459 None
460 }
461 }
462 (None, _) => {
463 if checkpoint.created_at > 0 && checkpoint.updated_at > checkpoint.created_at {
465 Some((checkpoint.updated_at - checkpoint.created_at) as f64)
466 } else {
467 None
468 }
469 }
470 }
471}
472
473#[cfg(test)]
478mod tests {
479 use super::*;
480 use chrono::TimeZone;
481 use luft_core::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
482 use luft_core::state::{AgentResultCache, PhaseSummary};
483 use luft_planner::{MetaPhase, PlanMeta};
484 use std::collections::HashMap;
485
486 fn ts(secs: i64) -> DateTime<Utc> {
487 Utc.timestamp_opt(secs, 0).unwrap()
488 }
489
490 fn cp(meta: Option<PlanMeta>, current_phase: u32) -> RunCheckpoint {
491 RunCheckpoint {
492 run_id: RunId::now_v7(),
493 task: "test task".into(),
494 status: CheckpointStatus::Running,
495 current_phase,
496 completed_phases: vec![],
497 agent_results: HashMap::new(),
498 findings: vec![],
499 total_tokens: 0,
500 created_at: 1000,
501 updated_at: 2000,
502 completed_spans: vec![],
503 workflow_meta: meta.map(|m| serde_json::to_value(m).unwrap()),
504 started_agent_ids: vec![],
505 }
506 }
507
508 fn cp_with_agents(
509 meta: Option<PlanMeta>,
510 current_phase: u32,
511 agents: Vec<(AgentId, PhaseId, &str, u64, usize)>,
512 ) -> RunCheckpoint {
513 let mut checkpoint = cp(meta, current_phase);
514 for (agent_id, phase_id, status, tokens, findings_count) in agents {
515 checkpoint.agent_results.insert(
516 agent_id,
517 AgentResultCache {
518 agent_id,
519 phase_id,
520 status: status.to_string(),
521 output: serde_json::Value::Null,
522 findings: (0..findings_count)
523 .map(|i| luft_core::contract::finding::Finding {
524 kind: "test".into(),
525 severity: luft_core::contract::finding::Severity::Info,
526 title: format!("finding {}", i),
527 detail: String::new(),
528 location: None,
529 evidence: vec![],
530 data: serde_json::Value::Null,
531 })
532 .collect(),
533 tokens,
534 completed_at: 0,
535 cache_key_hash: None,
536 description: None,
537 role: None,
538 },
539 );
540 checkpoint.total_tokens += tokens;
541 }
542 checkpoint
543 }
544
545 #[test]
548 fn meta_pending_when_no_events() {
549 let meta = PlanMeta {
550 phases: vec![MetaPhase {
551 label: "x".into(),
552 detail: "do x".into(),
553 agents: 2,
554 ..Default::default()
555 }],
556 reasoning: "r".into(),
557 };
558 let checkpoint = cp(Some(meta), 0);
559 let view = build_phases_view(&checkpoint, &[]);
560 assert_eq!(view.source, PhasesSource::Meta);
561 assert_eq!(view.phases.len(), 1);
562 assert_eq!(view.phases[0].status, PhaseStatus::Pending);
563 assert!(view.phases[0].agents.is_empty());
564 }
565
566 #[test]
567 fn meta_running_current_phase() {
568 let meta = PlanMeta {
569 phases: vec![
570 MetaPhase {
571 label: "a".into(),
572 detail: "1".into(),
573 agents: 1,
574 ..Default::default()
575 },
576 MetaPhase {
577 label: "b".into(),
578 detail: "2".into(),
579 agents: 1,
580 ..Default::default()
581 },
582 MetaPhase {
583 label: "c".into(),
584 detail: "3".into(),
585 agents: 1,
586 ..Default::default()
587 },
588 ],
589 reasoning: String::new(),
590 };
591 let checkpoint = cp(Some(meta), 2);
592 let view = build_phases_view(&checkpoint, &[]);
593 assert_eq!(view.phases[0].status, PhaseStatus::Completed);
594 assert_eq!(view.phases[1].status, PhaseStatus::Running);
595 assert_eq!(view.phases[2].status, PhaseStatus::Pending);
596 }
597
598 #[test]
599 fn meta_with_completed_agents() {
600 let meta = PlanMeta {
601 phases: vec![MetaPhase {
602 label: "gather".into(),
603 detail: "collect".into(),
604 agents: 2,
605 ..Default::default()
606 }],
607 reasoning: String::new(),
608 };
609 let a1 = AgentId::now_v7();
610 let a2 = AgentId::now_v7();
611 let checkpoint = cp_with_agents(
612 Some(meta),
613 1,
614 vec![(a1, 1, "ok", 120, 0), (a2, 1, "ok", 80, 1)],
615 );
616 let view = build_phases_view(&checkpoint, &[]);
617 assert_eq!(view.phases[0].agents.len(), 2);
618 let tokens: std::collections::HashSet<_> =
619 view.phases[0].agents.iter().map(|a| a.tokens).collect();
620 assert!(tokens.contains(&Some(120)));
621 assert!(tokens.contains(&Some(80)));
622 let findings_total: usize = view.phases[0].agents.iter().map(|a| a.findings).sum();
623 assert_eq!(findings_total, 1);
624 }
625
626 #[test]
627 fn meta_with_running_agent_from_events() {
628 let meta = PlanMeta {
629 phases: vec![MetaPhase {
630 label: "analyze".into(),
631 detail: "analyze".into(),
632 agents: 2,
633 ..Default::default()
634 }],
635 reasoning: String::new(),
636 };
637 let a1 = AgentId::now_v7();
638 let running_agent = AgentId::now_v7();
639 let checkpoint = cp_with_agents(Some(meta), 1, vec![(a1, 1, "ok", 640, 2)]);
640 let events = vec![AgentEvent::AgentStarted {
641 run_id: checkpoint.run_id,
642 phase_id: 1,
643 agent_id: running_agent,
644 prompt_preview: "working".into(),
645 model: None,
646 description: None,
647 role: None,
648 name: None,
649 agent_seq: 0,
650 }];
651 let view = build_phases_view(&checkpoint, &events);
652 assert_eq!(view.phases[0].agents.len(), 2);
653 assert_eq!(view.phases[0].agents[0].status, "completed");
655 assert_eq!(view.phases[0].agents[0].tokens, Some(640));
656 assert_eq!(view.phases[0].agents[1].status, "running");
658 assert_eq!(view.phases[0].agents[1].tokens, None);
659 assert_eq!(view.phases[0].agents[1].tool_count, None);
660 }
661
662 #[test]
663 fn meta_phase_elapsed_from_ts() {
664 let meta = PlanMeta {
665 phases: vec![MetaPhase {
666 label: "p".into(),
667 detail: "d".into(),
668 agents: 1,
669 ..Default::default()
670 }],
671 reasoning: String::new(),
672 };
673 let checkpoint = cp(Some(meta), 1);
674 let events = vec![
675 AgentEvent::PhaseStarted {
676 run_id: checkpoint.run_id,
677 phase_id: 1,
678 label: "p".into(),
679 planned: 1,
680 parent_span_id: None,
681 description: None,
682 role: None,
683 ts: ts(100),
684 },
685 AgentEvent::PhaseDone {
686 run_id: checkpoint.run_id,
687 phase_id: 1,
688 ok: 1,
689 failed: 0,
690 ts: ts(103),
691 },
692 ];
693 let view = build_phases_view(&checkpoint, &events);
694 assert_eq!(view.phases[0].elapsed_secs, Some(3.0));
695 }
696
697 #[test]
698 fn meta_phase_elapsed_none_when_ts_missing() {
699 let meta = PlanMeta {
700 phases: vec![MetaPhase {
701 label: "p".into(),
702 detail: "d".into(),
703 agents: 1,
704 ..Default::default()
705 }],
706 reasoning: String::new(),
707 };
708 let checkpoint = cp(Some(meta), 1);
709 let view = build_phases_view(&checkpoint, &[]);
711 assert_eq!(view.phases[0].elapsed_secs, None);
712 }
713
714 #[test]
715 fn meta_failed_phase_status() {
716 let meta = PlanMeta {
717 phases: vec![MetaPhase {
718 label: "p".into(),
719 detail: "d".into(),
720 agents: 1,
721 ..Default::default()
722 }],
723 reasoning: String::new(),
724 };
725 let mut checkpoint = cp(Some(meta), 1);
726 checkpoint.completed_phases = vec![PhaseSummary {
727 phase_id: 1,
728 label: "p".into(),
729 planned: 1,
730 ok: 0,
731 failed: 2,
732 description: None,
733 role: None,
734 }];
735 let view = build_phases_view(&checkpoint, &[]);
736 assert_eq!(view.phases[0].status, PhaseStatus::Failed);
737 assert_eq!(view.phases[0].ok, 0);
738 assert_eq!(view.phases[0].failed, 2);
739 }
740
741 #[test]
744 fn fallback_events_reconstructs_phases() {
745 let checkpoint = cp(None, 1);
746 let events = vec![
747 AgentEvent::PhaseStarted {
748 run_id: checkpoint.run_id,
749 phase_id: 1,
750 label: "discover".into(),
751 planned: 2,
752 parent_span_id: None,
753 description: None,
754 role: None,
755 ts: ts(100),
756 },
757 AgentEvent::PhaseStarted {
758 run_id: checkpoint.run_id,
759 phase_id: 2,
760 label: "report".into(),
761 planned: 1,
762 parent_span_id: None,
763 description: None,
764 role: None,
765 ts: ts(200),
766 },
767 ];
768 let view = build_phases_view(&checkpoint, &events);
769 assert_eq!(view.source, PhasesSource::EventsFallback);
770 assert_eq!(view.phases.len(), 2);
771 assert_eq!(view.phases[0].label, "discover");
772 assert_eq!(view.phases[1].label, "report");
773 assert!(view.phases[0].detail.is_none());
774 }
775
776 #[test]
777 fn fallback_no_events_empty_phases() {
778 let checkpoint = cp(None, 0);
779 let view = build_phases_view(&checkpoint, &[]);
780 assert_eq!(view.source, PhasesSource::EventsFallback);
781 assert!(view.phases.is_empty());
782 }
783
784 #[test]
787 fn header_fields_populated() {
788 let meta = PlanMeta {
789 phases: vec![MetaPhase {
790 label: "x".into(),
791 detail: "y".into(),
792 agents: 1,
793 ..Default::default()
794 }],
795 reasoning: String::new(),
796 };
797 let checkpoint = cp(Some(meta), 1);
798 let view = build_phases_view(&checkpoint, &[]);
799 assert_eq!(view.run.task, "test task");
800 assert_eq!(view.run.current_phase, 1);
801 assert_eq!(view.run.total_phases, 1);
802 }
803
804 #[test]
805 fn header_elapsed_from_run_events() {
806 let checkpoint = cp(None, 1);
807 let events = vec![
808 AgentEvent::RunStarted {
809 run_id: checkpoint.run_id,
810 task: "t".into(),
811 ts: ts(100),
812 },
813 AgentEvent::RunDone {
814 run_id: checkpoint.run_id,
815 status: luft_core::contract::event::RunStatus::Completed,
816 total_tokens: TokenUsage::default(),
817 report: serde_json::Value::Null,
818 ts: ts(142),
819 },
820 ];
821 let view = build_phases_view(&checkpoint, &events);
822 assert_eq!(view.run.elapsed_secs, Some(42.0));
823 }
824
825 #[test]
826 fn header_elapsed_fallback_to_checkpoint() {
827 let checkpoint = cp(None, 1);
828 let view = build_phases_view(&checkpoint, &[]);
829 assert_eq!(view.run.elapsed_secs, Some(1000.0));
831 }
832
833 #[test]
836 fn agent_status_str_variants() {
837 assert_eq!(agent_status_str("ok"), "completed");
838 assert_eq!(agent_status_str("error"), "failed");
839 assert_eq!(agent_status_str("cancelled"), "cancelled");
840 assert_eq!(agent_status_str("timed_out"), "timed_out");
841 }
842
843 #[test]
844 fn phase_status_bracket() {
845 assert_eq!(PhaseStatus::Pending.bracket(), "[pending]");
846 assert_eq!(PhaseStatus::Running.bracket(), "[running]");
847 assert_eq!(PhaseStatus::Completed.bracket(), "[completed]");
848 assert_eq!(PhaseStatus::Failed.bracket(), "[failed]");
849 }
850
851 #[test]
852 fn pending_phase_has_no_agents() {
853 let meta = PlanMeta {
854 phases: vec![
855 MetaPhase {
856 label: "a".into(),
857 detail: "1".into(),
858 agents: 1,
859 ..Default::default()
860 },
861 MetaPhase {
862 label: "b".into(),
863 detail: "2".into(),
864 agents: 1,
865 ..Default::default()
866 },
867 ],
868 reasoning: String::new(),
869 };
870 let checkpoint = cp(Some(meta), 0);
871 let view = build_phases_view(&checkpoint, &[]);
872 for phase in &view.phases {
874 assert_eq!(phase.status, PhaseStatus::Pending);
875 assert!(phase.agents.is_empty());
876 }
877 }
878}