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 ts: Default::default(),
651 }];
652 let view = build_phases_view(&checkpoint, &events);
653 assert_eq!(view.phases[0].agents.len(), 2);
654 assert_eq!(view.phases[0].agents[0].status, "completed");
656 assert_eq!(view.phases[0].agents[0].tokens, Some(640));
657 assert_eq!(view.phases[0].agents[1].status, "running");
659 assert_eq!(view.phases[0].agents[1].tokens, None);
660 assert_eq!(view.phases[0].agents[1].tool_count, None);
661 }
662
663 #[test]
664 fn meta_phase_elapsed_from_ts() {
665 let meta = PlanMeta {
666 phases: vec![MetaPhase {
667 label: "p".into(),
668 detail: "d".into(),
669 agents: 1,
670 ..Default::default()
671 }],
672 reasoning: String::new(),
673 };
674 let checkpoint = cp(Some(meta), 1);
675 let events = vec![
676 AgentEvent::PhaseStarted {
677 run_id: checkpoint.run_id,
678 phase_id: 1,
679 label: "p".into(),
680 planned: 1,
681 parent_span_id: None,
682 description: None,
683 role: None,
684 ts: ts(100),
685 },
686 AgentEvent::PhaseDone {
687 run_id: checkpoint.run_id,
688 phase_id: 1,
689 ok: 1,
690 failed: 0,
691 ts: ts(103),
692 },
693 ];
694 let view = build_phases_view(&checkpoint, &events);
695 assert_eq!(view.phases[0].elapsed_secs, Some(3.0));
696 }
697
698 #[test]
699 fn meta_phase_elapsed_none_when_ts_missing() {
700 let meta = PlanMeta {
701 phases: vec![MetaPhase {
702 label: "p".into(),
703 detail: "d".into(),
704 agents: 1,
705 ..Default::default()
706 }],
707 reasoning: String::new(),
708 };
709 let checkpoint = cp(Some(meta), 1);
710 let view = build_phases_view(&checkpoint, &[]);
712 assert_eq!(view.phases[0].elapsed_secs, None);
713 }
714
715 #[test]
716 fn meta_failed_phase_status() {
717 let meta = PlanMeta {
718 phases: vec![MetaPhase {
719 label: "p".into(),
720 detail: "d".into(),
721 agents: 1,
722 ..Default::default()
723 }],
724 reasoning: String::new(),
725 };
726 let mut checkpoint = cp(Some(meta), 1);
727 checkpoint.completed_phases = vec![PhaseSummary {
728 phase_id: 1,
729 label: "p".into(),
730 planned: 1,
731 ok: 0,
732 failed: 2,
733 description: None,
734 role: None,
735 }];
736 let view = build_phases_view(&checkpoint, &[]);
737 assert_eq!(view.phases[0].status, PhaseStatus::Failed);
738 assert_eq!(view.phases[0].ok, 0);
739 assert_eq!(view.phases[0].failed, 2);
740 }
741
742 #[test]
745 fn fallback_events_reconstructs_phases() {
746 let checkpoint = cp(None, 1);
747 let events = vec![
748 AgentEvent::PhaseStarted {
749 run_id: checkpoint.run_id,
750 phase_id: 1,
751 label: "discover".into(),
752 planned: 2,
753 parent_span_id: None,
754 description: None,
755 role: None,
756 ts: ts(100),
757 },
758 AgentEvent::PhaseStarted {
759 run_id: checkpoint.run_id,
760 phase_id: 2,
761 label: "report".into(),
762 planned: 1,
763 parent_span_id: None,
764 description: None,
765 role: None,
766 ts: ts(200),
767 },
768 ];
769 let view = build_phases_view(&checkpoint, &events);
770 assert_eq!(view.source, PhasesSource::EventsFallback);
771 assert_eq!(view.phases.len(), 2);
772 assert_eq!(view.phases[0].label, "discover");
773 assert_eq!(view.phases[1].label, "report");
774 assert!(view.phases[0].detail.is_none());
775 }
776
777 #[test]
778 fn fallback_no_events_empty_phases() {
779 let checkpoint = cp(None, 0);
780 let view = build_phases_view(&checkpoint, &[]);
781 assert_eq!(view.source, PhasesSource::EventsFallback);
782 assert!(view.phases.is_empty());
783 }
784
785 #[test]
788 fn header_fields_populated() {
789 let meta = PlanMeta {
790 phases: vec![MetaPhase {
791 label: "x".into(),
792 detail: "y".into(),
793 agents: 1,
794 ..Default::default()
795 }],
796 reasoning: String::new(),
797 };
798 let checkpoint = cp(Some(meta), 1);
799 let view = build_phases_view(&checkpoint, &[]);
800 assert_eq!(view.run.task, "test task");
801 assert_eq!(view.run.current_phase, 1);
802 assert_eq!(view.run.total_phases, 1);
803 }
804
805 #[test]
806 fn header_elapsed_from_run_events() {
807 let checkpoint = cp(None, 1);
808 let events = vec![
809 AgentEvent::RunStarted {
810 run_id: checkpoint.run_id,
811 task: "t".into(),
812 ts: ts(100),
813 },
814 AgentEvent::RunDone {
815 run_id: checkpoint.run_id,
816 status: luft_core::contract::event::RunStatus::Completed,
817 total_tokens: TokenUsage::default(),
818 report: serde_json::Value::Null,
819 ts: ts(142),
820 },
821 ];
822 let view = build_phases_view(&checkpoint, &events);
823 assert_eq!(view.run.elapsed_secs, Some(42.0));
824 }
825
826 #[test]
827 fn header_elapsed_fallback_to_checkpoint() {
828 let checkpoint = cp(None, 1);
829 let view = build_phases_view(&checkpoint, &[]);
830 assert_eq!(view.run.elapsed_secs, Some(1000.0));
832 }
833
834 #[test]
837 fn agent_status_str_variants() {
838 assert_eq!(agent_status_str("ok"), "completed");
839 assert_eq!(agent_status_str("error"), "failed");
840 assert_eq!(agent_status_str("cancelled"), "cancelled");
841 assert_eq!(agent_status_str("timed_out"), "timed_out");
842 }
843
844 #[test]
845 fn phase_status_bracket() {
846 assert_eq!(PhaseStatus::Pending.bracket(), "[pending]");
847 assert_eq!(PhaseStatus::Running.bracket(), "[running]");
848 assert_eq!(PhaseStatus::Completed.bracket(), "[completed]");
849 assert_eq!(PhaseStatus::Failed.bracket(), "[failed]");
850 }
851
852 #[test]
853 fn pending_phase_has_no_agents() {
854 let meta = PlanMeta {
855 phases: vec![
856 MetaPhase {
857 label: "a".into(),
858 detail: "1".into(),
859 agents: 1,
860 ..Default::default()
861 },
862 MetaPhase {
863 label: "b".into(),
864 detail: "2".into(),
865 agents: 1,
866 ..Default::default()
867 },
868 ],
869 reasoning: String::new(),
870 };
871 let checkpoint = cp(Some(meta), 0);
872 let view = build_phases_view(&checkpoint, &[]);
873 for phase in &view.phases {
875 assert_eq!(phase.status, PhaseStatus::Pending);
876 assert!(phase.agents.is_empty());
877 }
878 }
879}