1use serde::Serialize;
6use std::collections::BTreeMap;
7use std::fmt::Write;
8
9use crate::consensus::{Condition, ConsensusResult, DedupFinding, Dissent};
10use crate::schema::{AgentName, AgentOutput, Mode};
11
12#[non_exhaustive]
25#[derive(Debug, Clone)]
26pub struct ReportConfig {
27 pub banner_width: usize,
30 pub agent_titles: BTreeMap<AgentName, (String, String)>,
33}
34
35pub struct ReportFormatter {
41 config: ReportConfig,
42 banner_inner: usize,
43}
44
45#[derive(Debug, Clone, Serialize)]
50pub struct MagiReport {
51 pub agents: Vec<AgentOutput>,
53 pub consensus: ConsensusResult,
55 pub banner: String,
57 pub report: String,
59 pub degraded: bool,
61 pub failed_agents: BTreeMap<AgentName, String>,
64}
65
66impl Default for ReportConfig {
67 fn default() -> Self {
68 let mut agent_titles = BTreeMap::new();
69 agent_titles.insert(
70 AgentName::Melchior,
71 ("Melchior".to_string(), "Scientist".to_string()),
72 );
73 agent_titles.insert(
74 AgentName::Balthasar,
75 ("Balthasar".to_string(), "Pragmatist".to_string()),
76 );
77 agent_titles.insert(
78 AgentName::Caspar,
79 ("Caspar".to_string(), "Critic".to_string()),
80 );
81 Self {
82 banner_width: 52,
83 agent_titles,
84 }
85 }
86}
87
88impl ReportFormatter {
89 pub fn new() -> Self {
91 Self::with_config(ReportConfig::default())
92 }
93
94 pub fn with_config(config: ReportConfig) -> Self {
96 let banner_inner = config.banner_width - 2;
97 Self {
98 config,
99 banner_inner,
100 }
101 }
102
103 pub fn format_banner(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
116 let mut out = String::new();
117 let sep = self.format_separator();
118
119 writeln!(out, "{}", sep).ok();
120 writeln!(
121 out,
122 "{}",
123 self.format_line(" MAGI SYSTEM -- VERDICT")
124 )
125 .ok();
126 writeln!(out, "{}", sep).ok();
127
128 for agent in agents {
129 let (display_name, title) = self.agent_display(&agent.agent);
130 let pct = (agent.confidence * 100.0).round() as u32;
131 let content = format!(
132 " {} ({}): {} ({}%)",
133 display_name, title, agent.verdict, pct
134 );
135 writeln!(out, "{}", self.format_line(&content)).ok();
136 }
137
138 writeln!(out, "{}", sep).ok();
139 let consensus_line = format!(" CONSENSUS: {}", consensus.consensus);
140 writeln!(out, "{}", self.format_line(&consensus_line)).ok();
141 write!(out, "{}", sep).ok();
142
143 out
144 }
145
146 pub fn format_init_banner(&self, mode: &Mode, model: &str, timeout_secs: u64) -> String {
150 let mut out = String::new();
151 let sep = self.format_separator();
152
153 writeln!(out, "{}", sep).ok();
154 writeln!(
155 out,
156 "{}",
157 self.format_line(" MAGI SYSTEM -- INITIALIZING")
158 )
159 .ok();
160 writeln!(out, "{}", sep).ok();
161 writeln!(
162 out,
163 "{}",
164 self.format_line(&format!(" Mode: {}", mode))
165 )
166 .ok();
167 writeln!(
168 out,
169 "{}",
170 self.format_line(&format!(" Model: {}", model))
171 )
172 .ok();
173 writeln!(
174 out,
175 "{}",
176 self.format_line(&format!(" Timeout: {}s", timeout_secs))
177 )
178 .ok();
179 write!(out, "{}", sep).ok();
180
181 out
182 }
183
184 pub fn format_report(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
190 let mut out = String::new();
191
192 out.push_str(&self.format_banner(agents, consensus));
194 out.push('\n');
195
196 out.push_str(&self.format_consensus_summary(consensus));
198
199 if !consensus.findings.is_empty() {
201 out.push_str(&self.format_findings(&consensus.findings));
202 }
203
204 if !consensus.dissent.is_empty() {
206 out.push_str(&self.format_dissent(&consensus.dissent));
207 }
208
209 if !consensus.conditions.is_empty() {
211 out.push_str(&self.format_conditions(&consensus.conditions));
212 }
213
214 out.push_str(&self.format_recommendations(&consensus.recommendations));
216
217 out
218 }
219
220 fn format_separator(&self) -> String {
222 format!("+{}+", "=".repeat(self.banner_inner))
223 }
224
225 fn format_line(&self, content: &str) -> String {
229 if content.len() > self.banner_inner {
230 let boundary = content.floor_char_boundary(self.banner_inner);
231 format!(
232 "|{:<width$}|",
233 &content[..boundary],
234 width = self.banner_inner
235 )
236 } else {
237 format!("|{:<width$}|", content, width = self.banner_inner)
238 }
239 }
240
241 fn agent_display(&self, name: &AgentName) -> (&str, &str) {
246 if let Some((display_name, title)) = self.config.agent_titles.get(name) {
247 (display_name.as_str(), title.as_str())
248 } else {
249 (name.display_name(), name.title())
250 }
251 }
252
253 fn format_consensus_summary(&self, consensus: &ConsensusResult) -> String {
255 let mut out = String::new();
256 writeln!(out, "\n## Consensus Summary\n").ok();
257 writeln!(out, "{}", consensus.majority_summary).ok();
258 out
259 }
260
261 fn format_findings(&self, findings: &[DedupFinding]) -> String {
263 let mut out = String::new();
264 writeln!(out, "\n## Key Findings\n").ok();
265 for finding in findings {
266 let sources = finding
267 .sources
268 .iter()
269 .map(|s| s.display_name())
270 .collect::<Vec<_>>()
271 .join(", ");
272 writeln!(
273 out,
274 "{} **[{}]** {} _(from {})_",
275 finding.severity.icon(),
276 finding.severity,
277 finding.title,
278 sources
279 )
280 .ok();
281 writeln!(out, " {}", finding.detail).ok();
282 writeln!(out).ok();
283 }
284 out
285 }
286
287 fn format_dissent(&self, dissent: &[Dissent]) -> String {
289 let mut out = String::new();
290 writeln!(out, "\n## Dissenting Opinion\n").ok();
291 for d in dissent {
292 let (display_name, title) = self.agent_display(&d.agent);
293 writeln!(out, "**{} ({})**: {}", display_name, title, d.summary).ok();
294 writeln!(out).ok();
295 writeln!(out, "{}", d.reasoning).ok();
296 writeln!(out).ok();
297 }
298 out
299 }
300
301 fn format_conditions(&self, conditions: &[Condition]) -> String {
303 let mut out = String::new();
304 writeln!(out, "\n## Conditions for Approval\n").ok();
305 for c in conditions {
306 let (display_name, _) = self.agent_display(&c.agent);
307 writeln!(out, "- **{}**: {}", display_name, c.condition).ok();
308 }
309 writeln!(out).ok();
310 out
311 }
312
313 fn format_recommendations(&self, recommendations: &BTreeMap<AgentName, String>) -> String {
315 let mut out = String::new();
316 writeln!(out, "\n## Recommended Actions\n").ok();
317 for (name, rec) in recommendations {
318 let (display_name, title) = self.agent_display(name);
319 writeln!(out, "- **{}** ({}): {}", display_name, title, rec).ok();
320 }
321 out
322 }
323}
324
325impl Default for ReportFormatter {
326 fn default() -> Self {
327 Self::new()
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::consensus::*;
335 use crate::schema::*;
336
337 fn make_agent(
339 name: AgentName,
340 verdict: Verdict,
341 confidence: f64,
342 summary: &str,
343 reasoning: &str,
344 recommendation: &str,
345 ) -> AgentOutput {
346 AgentOutput {
347 agent: name,
348 verdict,
349 confidence,
350 summary: summary.to_string(),
351 reasoning: reasoning.to_string(),
352 findings: vec![],
353 recommendation: recommendation.to_string(),
354 }
355 }
356
357 fn make_consensus(
359 label: &str,
360 verdict: Verdict,
361 confidence: f64,
362 score: f64,
363 agents: &[&AgentOutput],
364 ) -> ConsensusResult {
365 let mut votes = BTreeMap::new();
366 let mut recommendations = BTreeMap::new();
367 for a in agents {
368 votes.insert(a.agent, a.verdict);
369 recommendations.insert(a.agent, a.recommendation.clone());
370 }
371
372 let majority_summary = agents
373 .iter()
374 .filter(|a| a.effective_verdict() == verdict.effective())
375 .map(|a| format!("{}: {}", a.agent.display_name(), a.summary))
376 .collect::<Vec<_>>()
377 .join(" | ");
378
379 ConsensusResult {
380 consensus: label.to_string(),
381 consensus_verdict: verdict,
382 confidence,
383 score,
384 agent_count: agents.len(),
385 votes,
386 majority_summary,
387 dissent: vec![],
388 findings: vec![],
389 conditions: vec![],
390 recommendations,
391 }
392 }
393
394 #[test]
398 fn test_banner_lines_are_exactly_52_chars_wide() {
399 let m = make_agent(
400 AgentName::Melchior,
401 Verdict::Approve,
402 0.9,
403 "Good",
404 "R",
405 "Rec",
406 );
407 let b = make_agent(
408 AgentName::Balthasar,
409 Verdict::Conditional,
410 0.85,
411 "Ok",
412 "R",
413 "Rec",
414 );
415 let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.78, "Bad", "R", "Rec");
416 let agents = vec![m.clone(), b.clone(), c.clone()];
417 let consensus = make_consensus(
418 "GO WITH CAVEATS",
419 Verdict::Approve,
420 0.85,
421 0.33,
422 &[&m, &b, &c],
423 );
424
425 let formatter = ReportFormatter::new();
426 let banner = formatter.format_banner(&agents, &consensus);
427
428 for line in banner.lines() {
429 if !line.is_empty() {
430 assert_eq!(line.len(), 52, "Line is not 52 chars: '{}'", line);
431 }
432 }
433 }
434
435 #[test]
437 fn test_banner_with_long_content_fits_52_chars() {
438 let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
439 let b = make_agent(
440 AgentName::Balthasar,
441 Verdict::Approve,
442 0.85,
443 "S",
444 "R",
445 "Rec",
446 );
447 let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.95, "S", "R", "Rec");
448 let agents = vec![m.clone(), b.clone(), c.clone()];
449 let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
450
451 let formatter = ReportFormatter::new();
452 let banner = formatter.format_banner(&agents, &consensus);
453
454 for line in banner.lines() {
455 if !line.is_empty() {
456 assert_eq!(line.len(), 52, "Line is not 52 chars: '{}'", line);
457 }
458 }
459 }
460
461 #[test]
465 fn test_report_with_mixed_consensus_contains_all_headers() {
466 let m = make_agent(
467 AgentName::Melchior,
468 Verdict::Approve,
469 0.9,
470 "Good code",
471 "Solid",
472 "Merge",
473 );
474 let b = make_agent(
475 AgentName::Balthasar,
476 Verdict::Conditional,
477 0.85,
478 "Needs work",
479 "Issues",
480 "Fix first",
481 );
482 let c = make_agent(
483 AgentName::Caspar,
484 Verdict::Reject,
485 0.78,
486 "Problems",
487 "Risky",
488 "Reject",
489 );
490 let agents = vec![m.clone(), b.clone(), c.clone()];
491
492 let mut consensus = make_consensus(
493 "GO WITH CAVEATS",
494 Verdict::Approve,
495 0.85,
496 0.33,
497 &[&m, &b, &c],
498 );
499 consensus.dissent = vec![Dissent {
500 agent: AgentName::Caspar,
501 summary: "Problems found".to_string(),
502 reasoning: "Risk is too high".to_string(),
503 }];
504 consensus.conditions = vec![Condition {
505 agent: AgentName::Balthasar,
506 condition: "Fix first".to_string(),
507 }];
508 consensus.findings = vec![DedupFinding {
509 severity: Severity::Warning,
510 title: "Test finding".to_string(),
511 detail: "Detail here".to_string(),
512 sources: vec![AgentName::Melchior, AgentName::Caspar],
513 }];
514
515 let formatter = ReportFormatter::new();
516 let report = formatter.format_report(&agents, &consensus);
517
518 assert!(
519 report.contains("## Consensus Summary"),
520 "Missing Consensus Summary"
521 );
522 assert!(report.contains("## Key Findings"), "Missing Key Findings");
523 assert!(
524 report.contains("## Dissenting Opinion"),
525 "Missing Dissenting Opinion"
526 );
527 assert!(
528 report.contains("## Conditions for Approval"),
529 "Missing Conditions"
530 );
531 assert!(
532 report.contains("## Recommended Actions"),
533 "Missing Recommended Actions"
534 );
535 }
536
537 #[test]
539 fn test_report_without_dissent_omits_dissent_section() {
540 let m = make_agent(
541 AgentName::Melchior,
542 Verdict::Approve,
543 0.9,
544 "Good",
545 "R",
546 "Merge",
547 );
548 let b = make_agent(
549 AgentName::Balthasar,
550 Verdict::Approve,
551 0.85,
552 "Good",
553 "R",
554 "Merge",
555 );
556 let c = make_agent(
557 AgentName::Caspar,
558 Verdict::Approve,
559 0.95,
560 "Good",
561 "R",
562 "Merge",
563 );
564 let agents = vec![m.clone(), b.clone(), c.clone()];
565 let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
566
567 let formatter = ReportFormatter::new();
568 let report = formatter.format_report(&agents, &consensus);
569
570 assert!(!report.contains("## Dissenting Opinion"));
571 }
572
573 #[test]
575 fn test_report_without_conditions_omits_conditions_section() {
576 let m = make_agent(
577 AgentName::Melchior,
578 Verdict::Approve,
579 0.9,
580 "Good",
581 "R",
582 "Merge",
583 );
584 let b = make_agent(
585 AgentName::Balthasar,
586 Verdict::Approve,
587 0.85,
588 "Good",
589 "R",
590 "Merge",
591 );
592 let c = make_agent(
593 AgentName::Caspar,
594 Verdict::Approve,
595 0.95,
596 "Good",
597 "R",
598 "Merge",
599 );
600 let agents = vec![m.clone(), b.clone(), c.clone()];
601 let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
602
603 let formatter = ReportFormatter::new();
604 let report = formatter.format_report(&agents, &consensus);
605
606 assert!(!report.contains("## Conditions for Approval"));
607 }
608
609 #[test]
611 fn test_report_without_findings_omits_findings_section() {
612 let m = make_agent(
613 AgentName::Melchior,
614 Verdict::Approve,
615 0.9,
616 "Good",
617 "R",
618 "Merge",
619 );
620 let b = make_agent(
621 AgentName::Balthasar,
622 Verdict::Approve,
623 0.85,
624 "Good",
625 "R",
626 "Merge",
627 );
628 let c = make_agent(
629 AgentName::Caspar,
630 Verdict::Approve,
631 0.95,
632 "Good",
633 "R",
634 "Merge",
635 );
636 let agents = vec![m.clone(), b.clone(), c.clone()];
637 let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
638
639 let formatter = ReportFormatter::new();
640 let report = formatter.format_report(&agents, &consensus);
641
642 assert!(!report.contains("## Key Findings"));
643 }
644
645 #[test]
649 fn test_format_banner_has_correct_structure() {
650 let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
651 let b = make_agent(AgentName::Balthasar, Verdict::Reject, 0.7, "S", "R", "Rec");
652 let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.8, "S", "R", "Rec");
653 let agents = vec![m.clone(), b.clone(), c.clone()];
654 let consensus = make_consensus("HOLD (2-1)", Verdict::Reject, 0.7, -0.33, &[&m, &b, &c]);
655
656 let formatter = ReportFormatter::new();
657 let banner = formatter.format_banner(&agents, &consensus);
658
659 assert!(banner.contains("MAGI SYSTEM -- VERDICT"));
660 assert!(banner.contains("Melchior (Scientist)"));
661 assert!(banner.contains("APPROVE"));
662 assert!(banner.contains("CONSENSUS:"));
663 assert!(banner.contains("HOLD (2-1)"));
664 }
665
666 #[test]
668 fn test_format_init_banner_shows_mode_model_timeout() {
669 let formatter = ReportFormatter::new();
670 let banner = formatter.format_init_banner(&Mode::CodeReview, "claude-sonnet", 300);
671
672 assert!(banner.contains("code-review"), "Missing mode");
673 assert!(banner.contains("claude-sonnet"), "Missing model");
674 assert!(banner.contains("300"), "Missing timeout");
675
676 for line in banner.lines() {
677 if !line.is_empty() {
678 assert_eq!(line.len(), 52, "Init banner line not 52 chars: '{}'", line);
679 }
680 }
681 }
682
683 #[test]
685 fn test_separator_format() {
686 let formatter = ReportFormatter::new();
687 let banner = formatter.format_init_banner(&Mode::Analysis, "test", 60);
688 let sep = format!("+{}+", "=".repeat(50));
689
690 assert!(banner.contains(&sep), "Missing separator line");
691 assert_eq!(sep.len(), 52);
692 }
693
694 #[test]
696 fn test_agent_line_format() {
697 let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
698 let b = make_agent(
699 AgentName::Balthasar,
700 Verdict::Approve,
701 0.85,
702 "S",
703 "R",
704 "Rec",
705 );
706 let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.78, "S", "R", "Rec");
707 let agents = vec![m.clone(), b.clone(), c.clone()];
708 let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
709
710 let formatter = ReportFormatter::new();
711 let banner = formatter.format_banner(&agents, &consensus);
712
713 assert!(banner.contains("Melchior (Scientist): APPROVE (90%)"));
714 assert!(banner.contains("Caspar (Critic): APPROVE (78%)"));
715 }
716
717 #[test]
721 fn test_findings_section_format() {
722 let m = make_agent(
723 AgentName::Melchior,
724 Verdict::Approve,
725 0.9,
726 "Good",
727 "R",
728 "Merge",
729 );
730 let agents = vec![m.clone()];
731 let mut consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
732 consensus.findings = vec![DedupFinding {
733 severity: Severity::Critical,
734 title: "SQL injection risk".to_string(),
735 detail: "User input not sanitized".to_string(),
736 sources: vec![AgentName::Melchior, AgentName::Caspar],
737 }];
738
739 let formatter = ReportFormatter::new();
740 let report = formatter.format_report(&agents, &consensus);
741
742 assert!(report.contains("[!!!]"), "Missing critical icon");
743 assert!(report.contains("[CRITICAL]"), "Missing severity label");
744 assert!(report.contains("SQL injection risk"), "Missing title");
745 assert!(report.contains("Melchior"), "Missing source agent");
746 assert!(report.contains("Caspar"), "Missing source agent");
747 assert!(
748 report.contains("User input not sanitized"),
749 "Missing detail"
750 );
751 }
752
753 #[test]
755 fn test_dissent_section_format() {
756 let m = make_agent(
757 AgentName::Melchior,
758 Verdict::Approve,
759 0.9,
760 "Good",
761 "R",
762 "Merge",
763 );
764 let c = make_agent(
765 AgentName::Caspar,
766 Verdict::Reject,
767 0.8,
768 "Bad",
769 "Too risky",
770 "Reject",
771 );
772 let agents = vec![m.clone(), c.clone()];
773 let mut consensus = make_consensus("GO (1-1)", Verdict::Approve, 0.8, 0.0, &[&m, &c]);
774 consensus.dissent = vec![Dissent {
775 agent: AgentName::Caspar,
776 summary: "Too many issues".to_string(),
777 reasoning: "The code has critical flaws".to_string(),
778 }];
779
780 let formatter = ReportFormatter::new();
781 let report = formatter.format_report(&agents, &consensus);
782
783 assert!(report.contains("Caspar"), "Missing dissenting agent name");
784 assert!(report.contains("Critic"), "Missing dissenting agent title");
785 assert!(
786 report.contains("Too many issues"),
787 "Missing dissent summary"
788 );
789 assert!(
790 report.contains("The code has critical flaws"),
791 "Missing dissent reasoning"
792 );
793 }
794
795 #[test]
797 fn test_conditions_section_format() {
798 let m = make_agent(
799 AgentName::Melchior,
800 Verdict::Approve,
801 0.9,
802 "Good",
803 "R",
804 "Merge",
805 );
806 let b = make_agent(
807 AgentName::Balthasar,
808 Verdict::Conditional,
809 0.85,
810 "Ok",
811 "R",
812 "Fix tests",
813 );
814 let agents = vec![m.clone(), b.clone()];
815 let mut consensus =
816 make_consensus("GO WITH CAVEATS", Verdict::Approve, 0.85, 0.75, &[&m, &b]);
817 consensus.conditions = vec![Condition {
818 agent: AgentName::Balthasar,
819 condition: "Fix tests first".to_string(),
820 }];
821
822 let formatter = ReportFormatter::new();
823 let report = formatter.format_report(&agents, &consensus);
824
825 assert!(
826 report.contains("- **Balthasar**:"),
827 "Missing bullet with agent name"
828 );
829 assert!(report.contains("Fix tests first"), "Missing condition text");
830 }
831
832 #[test]
834 fn test_recommendations_section_format() {
835 let m = make_agent(
836 AgentName::Melchior,
837 Verdict::Approve,
838 0.9,
839 "Good",
840 "R",
841 "Merge immediately",
842 );
843 let b = make_agent(
844 AgentName::Balthasar,
845 Verdict::Approve,
846 0.85,
847 "Good",
848 "R",
849 "Ship it",
850 );
851 let agents = vec![m.clone(), b.clone()];
852 let consensus = make_consensus("GO (2-0)", Verdict::Approve, 0.9, 1.0, &[&m, &b]);
853
854 let formatter = ReportFormatter::new();
855 let report = formatter.format_report(&agents, &consensus);
856
857 assert!(
858 report.contains("Merge immediately"),
859 "Missing Melchior recommendation"
860 );
861 assert!(
862 report.contains("Ship it"),
863 "Missing Balthasar recommendation"
864 );
865 }
866
867 #[test]
869 fn test_agent_display_fallback_to_agent_name_methods() {
870 let config = ReportConfig {
871 banner_width: 52,
872 agent_titles: BTreeMap::new(),
873 };
874 let formatter = ReportFormatter::with_config(config);
875
876 let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
877 let agents = vec![m.clone()];
878 let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
879
880 let banner = formatter.format_banner(&agents, &consensus);
881 assert!(
882 banner.contains("Melchior"),
883 "Should use AgentName::display_name()"
884 );
885 }
886
887 #[test]
891 fn test_magi_report_serializes_to_json() {
892 let m = make_agent(
893 AgentName::Melchior,
894 Verdict::Approve,
895 0.9,
896 "Good",
897 "R",
898 "Merge",
899 );
900 let agents = vec![m.clone()];
901 let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
902
903 let report = MagiReport {
904 agents,
905 consensus,
906 banner: "banner".to_string(),
907 report: "report".to_string(),
908 degraded: false,
909 failed_agents: BTreeMap::new(),
910 };
911
912 let json = serde_json::to_string(&report).expect("serialize");
913 assert!(json.contains("\"consensus\""));
914 assert!(json.contains("\"agents\""));
915 assert!(json.contains("\"degraded\""));
916 }
917
918 #[test]
920 fn test_magi_report_not_degraded_with_three_agents() {
921 let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
922 let b = make_agent(
923 AgentName::Balthasar,
924 Verdict::Approve,
925 0.85,
926 "S",
927 "R",
928 "Rec",
929 );
930 let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.95, "S", "R", "Rec");
931 let agents = vec![m.clone(), b.clone(), c.clone()];
932 let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
933
934 let report = MagiReport {
935 agents,
936 consensus,
937 banner: String::new(),
938 report: String::new(),
939 degraded: false,
940 failed_agents: BTreeMap::new(),
941 };
942
943 assert!(!report.degraded);
944 assert!(report.failed_agents.is_empty());
945 }
946
947 #[test]
949 fn test_magi_report_degraded_with_failed_agents() {
950 let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
951 let b = make_agent(
952 AgentName::Balthasar,
953 Verdict::Approve,
954 0.85,
955 "S",
956 "R",
957 "Rec",
958 );
959 let agents = vec![m.clone(), b.clone()];
960 let consensus = make_consensus("GO (2-0)", Verdict::Approve, 0.9, 1.0, &[&m, &b]);
961
962 let report = MagiReport {
963 agents,
964 consensus,
965 banner: String::new(),
966 report: String::new(),
967 degraded: true,
968 failed_agents: BTreeMap::from([(AgentName::Caspar, "timeout".to_string())]),
969 };
970
971 assert!(report.degraded);
972 assert_eq!(report.failed_agents.len(), 1);
973 assert!(report.failed_agents.contains_key(&AgentName::Caspar));
974 }
975
976 #[test]
978 fn test_magi_report_json_agent_names_lowercase() {
979 let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
980 let agents = vec![m.clone()];
981 let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
982
983 let report = MagiReport {
984 agents,
985 consensus,
986 banner: String::new(),
987 report: String::new(),
988 degraded: false,
989 failed_agents: BTreeMap::new(),
990 };
991
992 let json = serde_json::to_string(&report).expect("serialize");
993 assert!(
994 json.contains("\"melchior\""),
995 "Agent name should be lowercase in JSON"
996 );
997 assert!(
998 !json.contains("\"Melchior\""),
999 "Agent name should NOT be capitalized in JSON"
1000 );
1001 }
1002
1003 #[test]
1005 fn test_magi_report_confidence_rounded() {
1006 let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1007 let agents = vec![m.clone()];
1008 let mut consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.86, 1.0, &[&m]);
1009 consensus.confidence = 0.8567;
1010
1011 let report = MagiReport {
1012 agents,
1013 consensus,
1014 banner: String::new(),
1015 report: String::new(),
1016 degraded: false,
1017 failed_agents: BTreeMap::new(),
1018 };
1019
1020 let json = serde_json::to_string(&report).expect("serialize");
1023 assert!(
1024 json.contains("0.8567"),
1025 "Confidence should be serialized as-is (rounding is consensus engine's job)"
1026 );
1027 }
1028}