Skip to main content

magi/
report.rs

1//! Terminal rendering.
2//!
3//! A run produces a lot of state; the report exists so the operator can decide
4//! what to do next without opening `run.json`. It leads with the disagreement,
5//! because that is the part that carries information: three judges agreeing
6//! tells you nothing the winner's diff does not.
7//!
8//! Colour is a six-line local implementation rather than a crate. The
9//! alternatives all decide *for* you whether the stream supports colour, which
10//! makes the output untestable — `assert!(text.contains("winner  A"))` fails on
11//! an escape sequence the test never asked for.
12use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::config::MergeMode;
16use crate::run::{CommandOutcome, RunState, RunStatus};
17use crate::stats::Stats;
18
19static COLOR: AtomicBool = AtomicBool::new(true);
20
21/// Turn colour on or off for every subsequent render.
22pub fn set_color(on: bool) {
23    COLOR.store(on, Ordering::Relaxed);
24}
25
26fn paint(text: &str, code: &str) -> String {
27    if COLOR.load(Ordering::Relaxed) {
28        format!("\x1b[{code}m{text}\x1b[0m")
29    } else {
30        text.to_owned()
31    }
32}
33
34fn bold(t: &str) -> String {
35    paint(t, "1")
36}
37fn dim(t: &str) -> String {
38    paint(t, "2")
39}
40fn red(t: &str) -> String {
41    paint(t, "31")
42}
43fn green(t: &str) -> String {
44    paint(t, "32")
45}
46fn yellow(t: &str) -> String {
47    paint(t, "33")
48}
49fn cyan(t: &str) -> String {
50    paint(t, "36")
51}
52
53/// Colour for a status word.
54///
55/// `Stalled` is deliberately not green: a run whose judges were taken out by a
56/// rate limit must not look like a healthy `Ready` in a one-line listing.
57fn status_word(status: RunStatus) -> String {
58    let text = format!("{status:?}").to_lowercase();
59    match status {
60        RunStatus::Merged => bold(&green(&text)),
61        RunStatus::Ready => green(&text),
62        RunStatus::Stalled => bold(&yellow(&text)),
63        RunStatus::Blocked => yellow(&text),
64        RunStatus::Failed => red(&text),
65        _ => cyan(&text),
66    }
67}
68
69/// One-line summary, for `magi list`.
70pub fn line(state: &RunState) -> String {
71    let winner = state
72        .tally
73        .as_ref()
74        .map_or("-".to_owned(), |t| t.winner.to_string());
75    let agent = state.winner().map_or("-", |c| c.agent.as_str());
76    // A below-quorum verdict carries an explicit stamp so a row in a listing
77    // reads "stalled" and "2/3 judges" without opening the report.
78    let quorum = match state.tally.as_ref() {
79        Some(t) if !t.met_quorum => format!(
80            "  {}",
81            bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
82        ),
83        Some(t) if t.present > 0 && t.present < t.judges => format!(
84            "  {}",
85            yellow(&format!("judges {}/{}", t.present, t.judges))
86        ),
87        _ => String::new(),
88    };
89    format!(
90        "{}  {:<20}  {:>2}c {:>2}j  win {} ({}){quorum}  {}",
91        dim(&state.id),
92        status_word(state.status),
93        state.candidates.len(),
94        state.judgements.len(),
95        winner,
96        agent,
97        first_line(&state.instruction)
98    )
99}
100
101fn first_line(text: &str) -> String {
102    let line = text.lines().next().unwrap_or_default();
103    if line.chars().count() > 68 {
104        format!("{}…", line.chars().take(67).collect::<String>())
105    } else {
106        line.to_owned()
107    }
108}
109
110fn short(commit: &str) -> String {
111    commit.chars().take(7).collect()
112}
113
114/// Full report for one run.
115pub fn run(state: &RunState) -> String {
116    let mut s = String::new();
117    let _ = writeln!(
118        s,
119        "{} {}  {}",
120        bold("magi run"),
121        bold(&state.id),
122        status_word(state.status)
123    );
124    let _ = writeln!(
125        s,
126        "  repo    {} ({} @ {})",
127        state.repo.display(),
128        state.base_branch,
129        short(&state.base_commit)
130    );
131    let _ = writeln!(s, "  created {}", state.created_local());
132    let _ = writeln!(s, "  task    {}", first_line(&state.instruction));
133    let _ = writeln!(s, "  state   {}", state.dir().display());
134
135    let _ = writeln!(s, "\n{}", bold("candidates"));
136    for c in &state.candidates {
137        let flag = match (&c.failed, c.empty) {
138            (Some(e), _) => red(&format!("failed: {e}")),
139            (None, true) => yellow("no change"),
140            _ => format!("{} files, {} commits", c.files, c.commits),
141        };
142        let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
143            bold(&green("  <- winner"))
144        } else {
145            String::new()
146        };
147        let _ = writeln!(
148            s,
149            "  {}  {:<12} {:<30} {:>5}s{}",
150            bold(&c.label.to_string()),
151            c.agent,
152            flag,
153            c.duration_ms / 1000,
154            crown
155        );
156    }
157
158    if !state.judgements.is_empty() {
159        let _ = writeln!(s, "\n{}", bold("blind judging"));
160        for j in &state.judgements {
161            match &j.failed {
162                Some(e) => {
163                    let _ = writeln!(
164                        s,
165                        "  judge {}  {}",
166                        j.judge,
167                        red(&format!("no ranking: {e}"))
168                    );
169                }
170                None => {
171                    let _ = writeln!(
172                        s,
173                        "  judge {}  {:<12} {}  confidence {}",
174                        j.judge,
175                        j.agent,
176                        bold(&j.ranking.iter().collect::<String>()),
177                        j.confidence.map_or("-".to_owned(), |c| c.to_string())
178                    );
179                }
180            }
181        }
182    }
183
184    if let Some(t) = &state.tally {
185        if t.deliberated {
186            let _ = writeln!(s, "\n{}", bold("deliberation"));
187            for round in &state.deliberation {
188                for turn in &round.turns {
189                    let _ = writeln!(
190                        s,
191                        "  r{} judge {} -> {}",
192                        round.round,
193                        turn.judge,
194                        turn.tentative.map_or("-".to_owned(), |c| c.to_string())
195                    );
196                }
197            }
198        }
199
200        if !state.votes.is_empty() {
201            let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
202            for v in &state.votes {
203                let _ = writeln!(
204                    s,
205                    "  judge {}  {:<12} {}{}",
206                    v.judge,
207                    v.agent,
208                    bold(&v.vote.unwrap_or('?').to_string()),
209                    if v.changed {
210                        yellow("  (changed after deliberation)")
211                    } else {
212                        String::new()
213                    }
214                );
215            }
216        }
217
218        let _ = writeln!(s, "\n{}", bold("tally"));
219        // A tally with no panel (`uncontested`) must not fall through the
220        // judges/first-choice/after-votes lines below: they are written
221        // unconditionally and every one of them reads, in the words a panel
222        // that collapsed would also produce, as a run that lost its judges
223        // rather than one that never needed them.
224        match &t.uncontested {
225            Some(reason) => {
226                let _ = writeln!(
227                    s,
228                    "  judging       {}",
229                    cyan(&format!("not needed — {reason}"))
230                );
231            }
232            None => {
233                let _ = writeln!(
234                    s,
235                    "  judges        {} present{}",
236                    if t.met_quorum {
237                        green(&format!("{}/{}", t.present, t.judges))
238                    } else {
239                        red(&format!("{}/{}", t.present, t.judges))
240                    },
241                    if t.quorum > 0 {
242                        format!(" ({quorum} required)", quorum = t.quorum)
243                    } else {
244                        String::new()
245                    }
246                );
247                if !t.met_quorum {
248                    let _ = writeln!(
249                        s,
250                        "  {}",
251                        bold(&red("BELOW QUORUM — verdict is not trustworthy"))
252                    );
253                }
254                let _ = writeln!(
255                    s,
256                    "  first choice  {}",
257                    t.first_choice
258                        .iter()
259                        .map(|(k, v)| format!("{k}:{v}"))
260                        .collect::<Vec<_>>()
261                        .join("  ")
262                );
263                let _ = writeln!(
264                    s,
265                    "  initial       {}",
266                    match (t.rankings, t.unanimous_initial) {
267                        (0, _) => red("no usable ranking"),
268                        (1, _) => yellow("one usable ranking - not a consensus"),
269                        (_, true) => green("unanimous"),
270                        (_, false) => yellow("split"),
271                    }
272                );
273                let _ = writeln!(
274                    s,
275                    "  after votes   {}  ({} judge(s) moved)",
276                    if t.unanimous_final {
277                        green("unanimous")
278                    } else {
279                        yellow("still split")
280                    },
281                    t.changed_votes
282                );
283                if let Some(tb) = &t.tie_break {
284                    let _ = writeln!(s, "  tie break     {tb}");
285                }
286            }
287        }
288        if !state.quota.is_empty() {
289            let _ = writeln!(
290                s,
291                "  rate limited  {}",
292                state
293                    .quota
294                    .iter()
295                    .map(|q| q.seat.as_str())
296                    .collect::<Vec<_>>()
297                    .join(", ")
298            );
299        }
300        let _ = writeln!(s, "  winner        {}", bold(&green(&t.winner.to_string())));
301    }
302
303    if !state.reviews.is_empty() {
304        let _ = writeln!(s, "\n{}", bold("review + verification"));
305        for r in &state.reviews {
306            let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
307            // A build/link failure is not a verdict on the patch (a shared
308            // `CARGO_TARGET_DIR` link race looks exactly like one), so it
309            // must not read the same as a real test failure.
310            let e2e = if r.e2e.is_empty() {
311                dim("no e2e")
312            } else if r.e2e.iter().all(|o| o.ok()) {
313                green("e2e green")
314            } else if r.e2e.iter().any(CommandOutcome::build_failed) {
315                yellow("e2e could not run (build/link failure)")
316            } else {
317                red("e2e RED")
318            };
319            let e2e = if r.verify_retried {
320                format!("{e2e}, retried once")
321            } else {
322                e2e
323            };
324            // Three distinct facts, not two: a round can be *open* (blocking
325            // findings still standing), *incomplete* (a seat never answered,
326            // so what the round says is missing input) or genuinely clean.
327            let status = if r.incomplete() {
328                yellow("incomplete")
329            } else if r.clean {
330                green("clean")
331            } else {
332                yellow("open")
333            };
334            // A missing seat must stay visible even when `warn` policy let
335            // the round gate as clean: the reader should never have to take
336            // "clean" on faith when the panel wasn't full.
337            let panel = if r.incomplete() {
338                let missing: Vec<String> = r
339                    .reviews
340                    .iter()
341                    .filter_map(|x| {
342                        x.failed
343                            .as_ref()
344                            .map(|why| format!("review-{}: {why}", x.reviewer))
345                    })
346                    .collect();
347                format!(
348                    "  {}/{} reviewers answered ({})",
349                    r.answered,
350                    r.expected,
351                    missing.join(", ")
352                )
353            } else {
354                String::new()
355            };
356            let _ = writeln!(
357                s,
358                "  round {}  {} @ {}{panel}  {raised} finding(s), {} blocking, {e2e}{}",
359                r.round,
360                status,
361                short(&r.head),
362                r.blocking,
363                r.fix.as_ref().map_or(String::new(), |f| match &f.failed {
364                    // Never the same shape as "N addressed / M rejected": the
365                    // fixer's diff may well have landed (see the `fix` node's
366                    // own event), but whether it addressed anything is
367                    // unknown, not zero.
368                    Some(reason) => format!(
369                        "  fix: {}{}",
370                        yellow(&format!("adoption report lost ({reason})")),
371                        if f.committed {
372                            String::new()
373                        } else {
374                            red(" (NO COMMIT)")
375                        }
376                    ),
377                    None => format!(
378                        "  fix: {} addressed / {} rejected{}",
379                        f.addressed.len(),
380                        f.rejected.len(),
381                        if f.committed {
382                            String::new()
383                        } else {
384                            red(" (NO COMMIT)")
385                        }
386                    ),
387                })
388            );
389            for rec in &r.reviews {
390                for f in &rec.findings {
391                    let adopted = r
392                        .fix
393                        .as_ref()
394                        .is_some_and(|fix| fix.addressed.contains(&f.id));
395                    let _ = writeln!(
396                        s,
397                        "      {} [{:?}] {}{}",
398                        dim(&f.id),
399                        f.severity,
400                        f.title,
401                        if adopted {
402                            green("  fixed")
403                        } else {
404                            String::new()
405                        }
406                    );
407                }
408            }
409        }
410    }
411
412    if let Some(bs) = &state.base_sync {
413        let _ = writeln!(s, "\n{}", bold("base sync"));
414        let status = if let Some(c) = &bs.conflict {
415            red(&format!("conflict: {}", first_line(c)))
416        } else if bs.behind == 0 {
417            green("in sync")
418        } else {
419            yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
420        };
421        let _ = writeln!(
422            s,
423            "  {} @ {}  {status}{}",
424            state.base_branch,
425            short(&bs.tip),
426            if bs.attempts > 0 {
427                format!("  ({} rebase attempt(s))", bs.attempts)
428            } else {
429                String::new()
430            }
431        );
432    }
433
434    if !state.gate.is_empty() {
435        let _ = writeln!(s, "\n{}", bold("gate"));
436        for o in &state.gate {
437            let _ = writeln!(
438                s,
439                "  {}  {}",
440                if o.ok() { green("pass") } else { red("FAIL") },
441                o.command
442            );
443        }
444    }
445
446    if let Some(m) = &state.merge {
447        let _ = writeln!(s, "\n{}", bold("merge"));
448        if m.mode == MergeMode::None {
449            // `ok: true` here means "magi did nothing, as configured", not
450            // "landed" — a green `ok` next to a shell command reads as done,
451            // and the branch is still sitting unmerged.
452            let _ = writeln!(
453                s,
454                "  mode None  {}",
455                cyan("not landed — nothing to do by design")
456            );
457            if let Some(w) = state.winner() {
458                let _ = writeln!(
459                    s,
460                    "  branch {} still exists, unmerged into {}",
461                    w.branch, state.base_branch
462                );
463            }
464            let _ = writeln!(
465                s,
466                "  rebase onto {} before merging by hand, and pass an explicit \
467                 commit message — a squash merge otherwise inherits the \
468                 candidate's placeholder subject",
469                state.base_branch
470            );
471            let _ = writeln!(s, "  {}", m.detail.lines().next().unwrap_or(""));
472        } else {
473            let _ = writeln!(
474                s,
475                "  mode {:?}  {}\n  {}",
476                m.mode,
477                if m.ok {
478                    green("ok")
479                } else {
480                    yellow("not merged")
481                },
482                m.detail.lines().next().unwrap_or("")
483            );
484        }
485    }
486
487    if !state.leaks.is_empty() {
488        let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
489        for l in &state.leaks {
490            let _ = writeln!(s, "  {} x{} in {}", l.token, l.count, l.site);
491        }
492    }
493
494    if let Some(w) = state.winner()
495        && !w.folded
496    {
497        let _ = writeln!(
498            s,
499            "\n{} {}\n  branch {}",
500            bold("winner worktree"),
501            w.worktree.display(),
502            w.branch
503        );
504    }
505    s
506}
507
508/// The seats currently mid-answer, for `magi show` and the raw report route.
509///
510/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
511/// run` / `magi review`'s own `execute()`, and by then this process has
512/// nothing left in flight to report; the TUI does not track daemon liveness
513/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
514/// the web UI's raw-report route — needs this, and both already know how to
515/// ask whether a daemon is currently driving it.
516///
517/// `live` is whether a daemon's heartbeat currently names this run
518/// (`daemon::is_working_on`). An [`ActiveSeat`](crate::run::ActiveSeat) left
519/// behind by a killed process is not lied about as running just because
520/// nobody has cleared it from disk yet — see that type's own docs for why an
521/// entry alone is not proof of anything.
522pub fn active_seats(state: &RunState, live: bool) -> String {
523    if state.active.is_empty() {
524        return String::new();
525    }
526    let mut s = String::new();
527    let _ = writeln!(s, "\n{}", bold("running now"));
528    if !live {
529        let _ = writeln!(
530            s,
531            "  {}",
532            yellow(
533                "no live daemon claims this run right now — likely left behind by a killed process"
534            )
535        );
536    }
537    let now = jiff::Timestamp::now();
538    for (seat, a) in &state.active {
539        let retry = if a.attempt > 0 {
540            format!(" retry {}", a.attempt)
541        } else {
542            String::new()
543        };
544        let _ = writeln!(
545            s,
546            "  {:<12} {:<12}{retry}  {}s elapsed, {}s left of {}s",
547            seat,
548            a.node,
549            a.elapsed_secs(now),
550            a.remaining_secs(now),
551            a.timeout_secs
552        );
553    }
554    s
555}
556
557/// Aggregate tables, for `magi stats`.
558pub fn stats(stats: &Stats) -> String {
559    let t = &stats.totals;
560    let mut s = String::new();
561    let _ = writeln!(s, "{}", bold("runs"));
562    let _ = writeln!(
563        s,
564        "  {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
565        t.runs,
566        t.merged,
567        t.ready,
568        t.blocked,
569        t.failed,
570        t.completion_rate()
571    );
572    if t.tallied > 0 {
573        let _ = writeln!(
574            s,
575            "  {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
576             {} of those changed a mind, {} converged to unanimous",
577            t.tallied,
578            t.split,
579            t.split_rate(),
580            t.deliberated,
581            t.minds_changed,
582            t.converged
583        );
584    }
585
586    if !stats.agents.is_empty() {
587        let _ = writeln!(
588            s,
589            "\n{}",
590            bold("implementation (relative, on this workload)")
591        );
592        let _ = writeln!(
593            s,
594            "  {:<14}{:>6}{:>8}{:>8}{:>8}",
595            "agent", "won", "entered", "rate", "empty"
596        );
597        for a in &stats.agents {
598            let _ = writeln!(
599                s,
600                "  {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
601                a.agent,
602                a.wins,
603                a.entered,
604                a.win_rate(),
605                a.empty
606            );
607        }
608    }
609
610    if !stats.reviewers.is_empty() {
611        let _ = writeln!(s, "\n{}", bold("review"));
612        let _ = writeln!(
613            s,
614            "  {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
615            "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
616        );
617        for r in &stats.reviewers {
618            let _ = writeln!(
619                s,
620                "  {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
621                r.agent,
622                r.rounds,
623                r.submitted,
624                r.adopted_per_round(),
625                r.precision(),
626                r.unique_rate(),
627                r.timeout_rate()
628            );
629        }
630    }
631
632    if stats.e2e.rounds > 0 {
633        let _ = writeln!(s, "\n{}", bold("verification"));
634        let _ = writeln!(
635            s,
636            "  {} rounds ran e2e, {} failed, {} of those with a clean static \
637             review ({:.0}% sole detections)",
638            stats.e2e.rounds,
639            stats.e2e.failures,
640            stats.e2e.sole_detections,
641            stats.e2e.sole_rate()
642        );
643    }
644    s
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::config::Config;
651    use crate::run::{
652        Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
653        Tally,
654    };
655    use std::collections::BTreeMap;
656    use std::path::PathBuf;
657    use std::sync::{Mutex, MutexGuard};
658
659    /// `COLOR` is process-global, so these tests cannot run concurrently.
660    static SERIAL: Mutex<()> = Mutex::new(());
661
662    fn plain() -> MutexGuard<'static, ()> {
663        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
664        set_color(false);
665        guard
666    }
667
668    fn state() -> RunState {
669        // `run()` prints `state.dir()`, which reads the process-global home;
670        // pinning it here keeps this test off the operator's real one. The
671        // directory itself is never read, only its path printed, so nothing
672        // needs to create or clean it up.
673        crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
674        let mut s = RunState::new(
675            PathBuf::from("/repo"),
676            "main".to_owned(),
677            "abcdef1234".to_owned(),
678            "add retries to the uploader".to_owned(),
679            Config::default(),
680        );
681        s.candidates = vec![Candidate {
682            index: 0,
683            label: 'A',
684            agent: "opus".to_owned(),
685            branch: "magi/x/A".to_owned(),
686            worktree: PathBuf::from("/wt/A"),
687            summary: String::new(),
688            stat: String::new(),
689            files: 3,
690            commits: 2,
691            empty: false,
692            failed: None,
693            duration_ms: 42_000,
694            folded: false,
695        }];
696        s.tally = Some(Tally {
697            first_choice: BTreeMap::from([('A', 3)]),
698            borda: BTreeMap::new(),
699            winner: 'A',
700            rankings: 3,
701            unanimous_initial: true,
702            deliberated: false,
703            changed_votes: 0,
704            unanimous_final: true,
705            tie_break: None,
706            judges: 3,
707            present: 3,
708            quorum: 2,
709            met_quorum: true,
710            uncontested: None,
711        });
712        s
713    }
714
715    #[test]
716    fn run_report_names_the_winner_and_its_author() {
717        let _guard = plain();
718        let text = run(&state());
719        assert!(text.contains("<- winner"), "{text}");
720        assert!(text.contains("opus"));
721        assert!(text.contains("3 files, 2 commits"));
722        assert!(text.contains("winner        A"));
723        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
724    }
725
726    #[test]
727    fn colour_is_emitted_only_when_enabled() {
728        let _guard = plain();
729        set_color(true);
730        let coloured = run(&state());
731        set_color(false);
732        let plain = run(&state());
733        assert!(coloured.contains('\x1b'));
734        assert!(!plain.contains('\x1b'));
735        assert!(coloured.len() > plain.len());
736    }
737
738    #[test]
739    fn list_line_is_single_line() {
740        let _guard = plain();
741        let l = line(&state());
742        assert_eq!(l.lines().count(), 1);
743        assert!(l.contains("add retries"));
744        assert!(l.contains("win A (opus)"));
745    }
746
747    #[test]
748    fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
749        let _guard = plain();
750        let mut s = state();
751        s.tally = Some(Tally {
752            first_choice: BTreeMap::from([('A', 0)]),
753            borda: BTreeMap::new(),
754            winner: 'A',
755            rankings: 0,
756            unanimous_initial: false,
757            deliberated: false,
758            changed_votes: 0,
759            unanimous_final: false,
760            tie_break: None,
761            judges: 0,
762            present: 0,
763            quorum: 0,
764            met_quorum: true,
765            uncontested: Some(
766                "only candidate A produced a usable change; no panel was asked".to_owned(),
767            ),
768        });
769        let text = run(&s);
770        assert!(
771            !text.contains("0/3"),
772            "no panel sat, so the judges line must not read as one that collapsed: {text}"
773        );
774        assert!(!text.contains("no usable ranking"), "{text}");
775        assert!(!text.contains("still split"), "{text}");
776        assert!(!text.contains("BELOW QUORUM"), "{text}");
777        assert!(
778            text.contains("not needed"),
779            "the report must say judging was skipped, not silent: {text}"
780        );
781        assert!(text.contains("winner        A"));
782    }
783
784    #[test]
785    fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
786        let _guard = plain();
787        let mut s = state();
788        s.tally = Some(Tally {
789            first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
790            borda: BTreeMap::new(),
791            winner: 'A',
792            rankings: 1,
793            unanimous_initial: false,
794            deliberated: false,
795            changed_votes: 0,
796            unanimous_final: false,
797            tie_break: None,
798            judges: 3,
799            present: 1,
800            quorum: 2,
801            met_quorum: false,
802            uncontested: None,
803        });
804        let text = run(&s);
805        assert!(text.contains("1/3"), "{text}");
806        assert!(
807            text.contains("BELOW QUORUM"),
808            "a real collapse must still be flagged: {text}"
809        );
810        assert!(
811            !text.contains("not needed"),
812            "a collapsed panel must not be described as one that was never asked: {text}"
813        );
814    }
815
816    #[test]
817    fn a_mode_none_merge_does_not_read_as_landed() {
818        let _guard = plain();
819        let mut s = state();
820        s.merge = Some(MergeOutcome {
821            mode: crate::config::MergeMode::None,
822            ok: true,
823            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
824        });
825        let text = run(&s);
826        assert!(
827            !text.contains("  ok"),
828            "mode none must not be shown as a landed merge: {text}"
829        );
830        assert!(text.contains("not landed"), "{text}");
831        assert!(
832            text.contains("branch magi/x/A"),
833            "the report must say what's left behind: {text}"
834        );
835        assert!(
836            text.contains("rebase"),
837            "the report must point at the hand-landing steps: {text}"
838        );
839    }
840
841    #[test]
842    fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
843        let _guard = plain();
844        let mut s = state();
845        s.tally = Some(Tally {
846            first_choice: BTreeMap::from([('A', 0)]),
847            borda: BTreeMap::new(),
848            winner: 'A',
849            rankings: 0,
850            unanimous_initial: false,
851            deliberated: false,
852            changed_votes: 0,
853            unanimous_final: false,
854            tie_break: None,
855            judges: 0,
856            present: 0,
857            quorum: 0,
858            met_quorum: true,
859            uncontested: Some("only candidate A produced a usable change".to_owned()),
860        });
861        let l = line(&s);
862        assert!(
863            !l.contains("judges") && !l.contains("quorum"),
864            "an uncontested run must not carry the same badge a short panel gets: {l}"
865        );
866    }
867
868    #[test]
869    fn long_instructions_are_elided() {
870        let _guard = plain();
871        let mut s = state();
872        s.instruction = "x".repeat(200);
873        assert!(line(&s).contains('…'));
874    }
875
876    #[test]
877    fn a_lost_fix_report_reads_differently_from_zero_adoption() {
878        let _guard = plain();
879        let mut lost = state();
880        lost.reviews = vec![ReviewRound {
881            round: 1,
882            head: "abc1234".to_owned(),
883            reviews: Vec::new(),
884            e2e: Vec::new(),
885            verify_retried: false,
886            fix: Some(FixRecord {
887                agent: "opus".to_owned(),
888                addressed: Vec::new(),
889                rejected: Vec::new(),
890                notes: String::new(),
891                committed: true,
892                failed: Some("timed out".to_owned()),
893                duration_ms: 0,
894            }),
895            blocking: 3,
896            answered: 0,
897            expected: 0,
898            clean: false,
899        }];
900        let text = run(&lost);
901        assert!(text.contains("adoption report lost (timed out)"), "{text}");
902        assert!(
903            !text.contains("0 addressed"),
904            "a lost report must never read as `0 addressed`: {text}"
905        );
906
907        let mut rejected_all = state();
908        rejected_all.reviews = vec![ReviewRound {
909            round: 1,
910            head: "abc1234".to_owned(),
911            reviews: Vec::new(),
912            e2e: Vec::new(),
913            verify_retried: false,
914            fix: Some(FixRecord {
915                agent: "opus".to_owned(),
916                addressed: Vec::new(),
917                rejected: Vec::new(),
918                notes: String::new(),
919                committed: true,
920                failed: None,
921                duration_ms: 0,
922            }),
923            blocking: 3,
924            answered: 0,
925            expected: 0,
926            clean: false,
927        }];
928        let text2 = run(&rejected_all);
929        assert!(
930            text2.contains("0 addressed / 0 rejected"),
931            "a round the fixer actually reported on keeps the count: {text2}"
932        );
933    }
934
935    #[test]
936    fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
937        // Two independent facts share this one line, and each arrived from a
938        // different change: a seat that never answered, and a fixer whose
939        // adoption report was lost. Rendering either must not shadow the
940        // other, and neither may collapse into the plain `clean`/`open`
941        // pair the line used to carry.
942        let _guard = plain();
943        let mut s = state();
944        s.reviews = vec![ReviewRound {
945            round: 1,
946            head: "abc1234".to_owned(),
947            reviews: vec![
948                ReviewRecord {
949                    reviewer: 1,
950                    agent: "alpha".to_owned(),
951                    summary: String::new(),
952                    findings: Vec::new(),
953                    failed: None,
954                    duration_ms: 0,
955                },
956                ReviewRecord {
957                    reviewer: 2,
958                    agent: "beta".to_owned(),
959                    summary: String::new(),
960                    findings: Vec::new(),
961                    failed: Some("agent timed out".to_owned()),
962                    duration_ms: 0,
963                },
964            ],
965            e2e: Vec::new(),
966            verify_retried: false,
967            fix: Some(FixRecord {
968                agent: "opus".to_owned(),
969                addressed: Vec::new(),
970                rejected: Vec::new(),
971                notes: String::new(),
972                committed: true,
973                failed: Some("timed out".to_owned()),
974                duration_ms: 0,
975            }),
976            blocking: 0,
977            answered: 1,
978            expected: 2,
979            clean: false,
980        }];
981        let text = run(&s);
982        assert!(text.contains("incomplete"), "{text}");
983        assert!(text.contains("1/2 reviewers answered"), "{text}");
984        assert!(text.contains("review-2: agent timed out"), "{text}");
985        assert!(text.contains("adoption report lost (timed out)"), "{text}");
986        assert!(
987            !text.contains("clean"),
988            "a round missing half its panel must never render as clean: {text}"
989        );
990    }
991
992    #[test]
993    fn a_build_failure_is_not_reported_as_a_test_failure() {
994        let _guard = plain();
995        let mut s = state();
996        s.reviews = vec![ReviewRound {
997            round: 1,
998            head: "abc1234".to_owned(),
999            reviews: Vec::new(),
1000            e2e: vec![CommandOutcome {
1001                command: "cargo test".to_owned(),
1002                code: Some(1),
1003                output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1004                duration_ms: 100,
1005            }],
1006            verify_retried: true,
1007            fix: None,
1008            blocking: 0,
1009            answered: 0,
1010            expected: 0,
1011            clean: false,
1012        }];
1013        let text = run(&s);
1014        assert!(text.contains("could not run"), "{text}");
1015        assert!(text.contains("retried once"), "{text}");
1016        assert!(!text.contains("e2e RED"), "{text}");
1017    }
1018
1019    #[test]
1020    fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1021        let _guard = plain();
1022        let mut s = state();
1023        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1024        let text = active_seats(&s, true);
1025        assert!(text.contains("running now"));
1026        assert!(text.contains("judge-2"));
1027        assert!(text.contains("judge"));
1028        assert!(!text.contains("no live daemon"), "{text}");
1029    }
1030
1031    #[test]
1032    fn active_seats_flags_a_leftover_from_a_dead_process() {
1033        let _guard = plain();
1034        let mut s = state();
1035        s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1036        let text = active_seats(&s, false);
1037        assert!(
1038            text.contains("no live daemon"),
1039            "a stale entry must not read as running: {text}"
1040        );
1041    }
1042
1043    #[test]
1044    fn active_seats_is_empty_when_nothing_is_running() {
1045        let _guard = plain();
1046        assert_eq!(active_seats(&state(), true), "");
1047    }
1048
1049    #[test]
1050    fn stats_table_renders_without_runs() {
1051        let _guard = plain();
1052        let text = stats(&Stats::default());
1053        assert!(text.contains("0 total"));
1054        assert!(!text.contains("implementation"));
1055    }
1056}