Skip to main content

spar/
triage.rs

1//! Both agents judge every issue independently, then the two verdicts are
2//! reconciled mechanically.
3//!
4//! Nothing here lets one agent overrule the other. Both say do, it is
5//! scheduled. Both say skip, it is skipped and the shared reasoning is posted.
6//! They disagree, it is parked for a person, because a disagreement between two
7//! competent reviewers is information, not noise to be averaged away.
8
9use std::collections::BTreeMap;
10
11use crate::agent::Agent;
12use crate::comments::{issue_for_prompt, THREAD_RULE};
13use crate::config::{Call, Config};
14use crate::error::Result;
15use crate::model::{
16    Complexity, ContestedItem, Issue, Plan, PlanItem, Risk, SkippedItem, TriageResponse,
17    TriageVerdict,
18};
19use crate::repo::Repo;
20use crate::{log, logdim, logwarn, schema, spar_err};
21
22const TRIAGE_PROMPT: &str = "\
23You are triaging GitHub issues for the repository in your working directory.
24Read the codebase as needed before judging. Do not modify anything.
25
26Each issue below is its number, title, URL, and body as filed, followed by any
27comments on it since, oldest first.
28{thread_rule}
29
30For each issue decide:
31- worth_doing: is this a real, valid, actionable issue worth a PR? Say false for
32  duplicates, stale requests, things already fixed, vague reports with nothing
33  reproducible, or changes that would make the codebase worse.
34- complexity: how much work the change is, on this scale:
35  - s: one file, or a handful of lines across two, and an existing test covers
36    the behaviour or one obvious test would.
37  - m: several files in one area, or a new function others call, or a change
38    that needs its own tests written.
39  - l: a new module, a change to an interface other code depends on, a data or
40    state format that has to migrate, or anything you cannot verify by running
41    something that already exists.
42- depends_on: issue numbers from this same list that should land first.
43- risk: the blast radius if the change is wrong, and how easily it is undone:
44  - low: contained in one place, and reverting it puts everything back.
45  - med: other code calls into it, or it changes behaviour somebody may already
46    rely on, but the failure would be visible.
47  - high: it touches data that persists, a security or permission boundary, or
48    a path whose failure is silent.
49
50Complexity decides the order the run works in, cheapest first, and both are
51printed beside every issue for a person. The other reviewer answers the same
52questions on the same issues, and where you disagree the larger of the two
53estimates is used, so answer with what you actually think rather than hedging
54upward.
55
56Judge independently. Be willing to say an issue is not worth doing. Your reason
57is posted on the issue when the other reviewer agrees with you, so write one
58sentence a maintainer would be happy to have their name on.
59
60Issues:
61";
62
63/// Every issue as the prompt carries it, and what would not fit.
64struct Rendered {
65    text: String,
66    /// Left for a later run, because the queue did not fit in one prompt.
67    deferred: Vec<i64>,
68    /// Included, but with the tail of the body or the oldest comments left off.
69    shortened: Vec<i64>,
70}
71
72/// Render the queue, under two budgets that do different jobs.
73///
74/// One issue is shortened only past `max_issue_chars`, which nothing a person
75/// wrote reaches. The queue as a whole is bounded by `max_triage_chars`,
76/// because triage reads every open issue at once and the queue is the only
77/// unbounded thing here.
78///
79/// Past that, whole issues are left for the next run rather than every issue
80/// losing its tail. A triage verdict is posted on the issue and can close it,
81/// so judging one on part of what it says is worse than not having reached it
82/// yet. Everything from the first issue that does not fit is deferred together,
83/// so what was read is always a prefix of the queue rather than whichever
84/// issues happened to be small.
85fn render(issues: &[Issue], cfg: &Config) -> Rendered {
86    let mut parts: Vec<String> = Vec::new();
87    let mut deferred = Vec::new();
88    let mut shortened = Vec::new();
89    let mut total = 0usize;
90
91    for issue in issues {
92        if !deferred.is_empty() {
93            deferred.push(issue.number);
94            continue;
95        }
96        // The thread comes with the body and costs no extra call: it was read
97        // in the same `gh issue view`. A later comment can say the issue is
98        // worse than filed, or already fixed, and a verdict is posted on the
99        // issue and can close it.
100        let text = issue_for_prompt(
101            issue,
102            cfg.loop_cfg.max_issue_chars,
103            cfg.loop_cfg.checkin_trust,
104        );
105        let entry = format!(
106            "#{}: {}\n{}\n{}",
107            issue.number, issue.title, issue.url, text.text
108        );
109        let len = entry.chars().count();
110        // The first issue goes in whatever its size. A queue of one that does
111        // not fit is a run that does nothing, forever.
112        if !parts.is_empty() && total + len > cfg.loop_cfg.max_triage_chars {
113            deferred.push(issue.number);
114            continue;
115        }
116        if text.shortened() {
117            shortened.push(issue.number);
118        }
119        text.report_untrusted(&format!("#{}", issue.number));
120        total += len;
121        parts.push(entry);
122    }
123
124    Rendered {
125        text: parts.join("\n\n"),
126        deferred,
127        shortened,
128    }
129}
130
131fn numbers(items: &[i64]) -> String {
132    items
133        .iter()
134        .map(|n| format!("#{n}"))
135        .collect::<Vec<_>>()
136        .join(", ")
137}
138
139/// Ask both agents, then reconcile.
140pub fn triage(agents: &[Agent], cfg: &Config, repo: &Repo, issues: &[Issue]) -> Result<Plan> {
141    let rendered = render(issues, cfg);
142    // Never silently. An agent cannot report a gap it was not told about, and
143    // a verdict on part of an issue looks exactly like a verdict on all of it.
144    if !rendered.shortened.is_empty() {
145        logwarn!(
146            "issue shortened to fit the prompt: {}. Raise max_issue_chars if these matter.",
147            numbers(&rendered.shortened)
148        );
149    }
150    if !rendered.deferred.is_empty() {
151        logwarn!(
152            "the queue did not fit in one triage prompt, so {} were left for a later run: {}",
153            rendered.deferred.len(),
154            numbers(&rendered.deferred)
155        );
156    }
157    let prompt = format!(
158        "{}{}",
159        TRIAGE_PROMPT.replace("{thread_rule}", THREAD_RULE),
160        rendered.text
161    );
162    let schema = schema::triage();
163
164    let answers = if cfg.loop_cfg.parallel_triage && agents.len() > 1 {
165        ask_together(agents, cfg, repo, &prompt, &schema)
166    } else {
167        ask_in_turn(agents, cfg, repo, &prompt, &schema)
168    };
169
170    let mut verdicts: Vec<(String, BTreeMap<i64, TriageVerdict>)> = Vec::new();
171    for (name, answer) in answers {
172        let response = answer?;
173        let mut by_issue = BTreeMap::new();
174        for verdict in response.issues {
175            by_issue.insert(verdict.issue, verdict);
176        }
177        verdicts.push((name, by_issue));
178    }
179
180    Ok(reconcile(issues, &verdicts))
181}
182
183type Answer = (String, Result<TriageResponse>);
184
185fn ask_one(
186    agent: &Agent,
187    cfg: &Config,
188    repo: &Repo,
189    prompt: &str,
190    schema: &serde_json::Value,
191) -> Answer {
192    let effort = agent.effort(cfg, Call::Triage);
193    let out = agent.ask_json::<TriageResponse>(prompt, schema, repo.root(), &effort);
194    (agent.name().to_string(), out)
195}
196
197/// Both agents at once. Triage only reads, so there is nothing to serialise,
198/// and a full repo pass is the slowest step in a run.
199fn ask_together(
200    agents: &[Agent],
201    cfg: &Config,
202    repo: &Repo,
203    prompt: &str,
204    schema: &serde_json::Value,
205) -> Vec<Answer> {
206    log!("triage: asking {} in parallel", names(agents));
207    std::thread::scope(|scope| {
208        let handles: Vec<_> = agents
209            .iter()
210            .map(|agent| scope.spawn(move || ask_one(agent, cfg, repo, prompt, schema)))
211            .collect();
212        handles
213            .into_iter()
214            .zip(agents)
215            .map(|(handle, agent)| {
216                handle.join().unwrap_or_else(|_| {
217                    (
218                        agent.name().to_string(),
219                        Err(spar_err!("triage thread for '{}' panicked", agent.name())),
220                    )
221                })
222            })
223            .collect()
224    })
225}
226
227fn ask_in_turn(
228    agents: &[Agent],
229    cfg: &Config,
230    repo: &Repo,
231    prompt: &str,
232    schema: &serde_json::Value,
233) -> Vec<Answer> {
234    agents
235        .iter()
236        .map(|agent| {
237            log!(
238                "triage: asking {} ({})",
239                agent.name(),
240                agent.spec.describe()
241            );
242            ask_one(agent, cfg, repo, prompt, schema)
243        })
244        .collect()
245}
246
247fn names(agents: &[Agent]) -> String {
248    agents
249        .iter()
250        .map(Agent::name)
251        .collect::<Vec<_>>()
252        .join(" and ")
253}
254
255// ---------------------------------------------------------------------------
256// Reconciliation
257// ---------------------------------------------------------------------------
258
259fn reconcile(issues: &[Issue], verdicts: &[(String, BTreeMap<i64, TriageVerdict>)]) -> Plan {
260    let mut agreed = Vec::new();
261    let mut skipped = Vec::new();
262    let mut contested = Vec::new();
263
264    for issue in issues {
265        let number = issue.number;
266        let seen: Vec<(&String, Option<&TriageVerdict>)> = verdicts
267            .iter()
268            .map(|(name, map)| (name, map.get(&number)))
269            .collect();
270
271        if seen.iter().any(|(_, v)| v.is_none()) {
272            let missing: Vec<&str> = seen
273                .iter()
274                .filter(|(_, v)| v.is_none())
275                .map(|(n, _)| n.as_str())
276                .collect();
277            contested.push(ContestedItem {
278                issue: number,
279                title: issue.title.clone(),
280                positions: BTreeMap::new(),
281                reasons: BTreeMap::new(),
282                note: Some(format!("no verdict from {}", missing.join(", "))),
283            });
284            continue;
285        }
286
287        let all: Vec<(&String, &TriageVerdict)> = seen
288            .into_iter()
289            .map(|(n, v)| (n, v.expect("checked")))
290            .collect();
291
292        if all.iter().all(|(_, v)| v.worth_doing) {
293            let complexity = all
294                .iter()
295                .map(|(_, v)| v.complexity)
296                .max_by_key(|c| c.rank())
297                .unwrap_or(Complexity::M);
298            let risk = all
299                .iter()
300                .map(|(_, v)| v.risk)
301                .max_by_key(|r| r.rank())
302                .unwrap_or(Risk::Med);
303            // Two agents on the same scale land next to each other. Two steps
304            // apart is not a rounding difference, it is the scale not being
305            // shared, and since the maximum wins the order is then set by
306            // whichever agent read the issue most pessimistically.
307            report_spread(
308                number,
309                "complexity",
310                all.iter().map(|(n, v)| (n.as_str(), v.complexity.rank())),
311            );
312            report_spread(
313                number,
314                "risk",
315                all.iter().map(|(n, v)| (n.as_str(), v.risk.rank())),
316            );
317            let mut depends: Vec<i64> =
318                all.iter().flat_map(|(_, v)| v.depends_on.clone()).collect();
319            depends.sort_unstable();
320            depends.dedup();
321            agreed.push(PlanItem {
322                issue: number,
323                title: issue.title.clone(),
324                complexity,
325                risk,
326                depends_on: depends,
327                reason: all[0].1.reason.clone(),
328            });
329        } else if all.iter().all(|(_, v)| !v.worth_doing) {
330            skipped.push(SkippedItem {
331                issue: number,
332                title: issue.title.clone(),
333                reasons: all
334                    .iter()
335                    .map(|(n, v)| ((*n).clone(), v.reason.clone()))
336                    .collect(),
337                // Either agent is enough. Closing needs both to agree, so one
338                // saying the issue is not finished is enough to withhold it.
339                tracker: all.iter().any(|(_, v)| v.tracker),
340            });
341        } else {
342            contested.push(ContestedItem {
343                issue: number,
344                title: issue.title.clone(),
345                positions: all
346                    .iter()
347                    .map(|(n, v)| {
348                        (
349                            (*n).clone(),
350                            if v.worth_doing { "do" } else { "skip" }.to_string(),
351                        )
352                    })
353                    .collect(),
354                reasons: all
355                    .iter()
356                    .map(|(n, v)| ((*n).clone(), v.reason.clone()))
357                    .collect(),
358                note: None,
359            });
360        }
361    }
362
363    Plan {
364        order: order(agreed, &skipped, &contested),
365        skipped,
366        contested,
367    }
368}
369
370/// Topological by dependency, then cheapest first, so blockers clear early and
371/// the large risky items inherit a healthier base.
372pub fn order(
373    items: Vec<PlanItem>,
374    skipped: &[SkippedItem],
375    contested: &[ContestedItem],
376) -> Vec<PlanItem> {
377    let by_number: BTreeMap<i64, PlanItem> = items.iter().map(|i| (i.issue, i.clone())).collect();
378    report_dependencies(&items, &by_number, skipped, contested);
379
380    let mut entry: Vec<&PlanItem> = items.iter().collect();
381    entry.sort_by_key(|i| (i.complexity.rank(), i.issue));
382
383    let mut ordered = Vec::new();
384    let mut done: Vec<i64> = Vec::new();
385    let mut visiting: Vec<i64> = Vec::new();
386
387    fn visit(
388        number: i64,
389        by_number: &BTreeMap<i64, PlanItem>,
390        done: &mut Vec<i64>,
391        visiting: &mut Vec<i64>,
392        ordered: &mut Vec<PlanItem>,
393    ) {
394        if done.contains(&number) {
395            return;
396        }
397        let Some(item) = by_number.get(&number) else {
398            return; // a dependency outside this run's list, reported above
399        };
400        if visiting.contains(&number) {
401            // A cycle, broken here rather than hung on. Reported above, since
402            // whichever node was visited first decides the order and that is
403            // not something to settle silently.
404            return;
405        }
406        visiting.push(number);
407        let mut deps = item.depends_on.clone();
408        deps.sort_unstable();
409        for dep in deps {
410            visit(dep, by_number, done, visiting, ordered);
411        }
412        visiting.retain(|n| *n != number);
413        done.push(number);
414        ordered.push(item.clone());
415    }
416
417    for item in entry {
418        visit(
419            item.issue,
420            &by_number,
421            &mut done,
422            &mut visiting,
423            &mut ordered,
424        );
425    }
426    ordered
427}
428
429/// Name a disagreement wider than one step on a three point scale.
430///
431/// The values are only worth what the two agents' calibration is worth, and
432/// nothing else says when they are not calibrated at all.
433fn report_spread<'a>(issue: i64, what: &str, answers: impl Iterator<Item = (&'a str, u8)>) {
434    let seen: Vec<(&str, u8)> = answers.collect();
435    let (Some(low), Some(high)) = (
436        seen.iter().min_by_key(|(_, rank)| *rank),
437        seen.iter().max_by_key(|(_, rank)| *rank),
438    ) else {
439        return;
440    };
441    if high.1.saturating_sub(low.1) < 2 {
442        return;
443    }
444    logdim!(
445        "#{issue}: {} and {} are two steps apart on {what}, and the larger is used",
446        low.0,
447        high.0
448    );
449}
450
451/// Say what each item depends on, and what became of a dependency that is not
452/// being worked.
453///
454/// The ordering spends a triage question and a sort on this, and the case where
455/// it matters most, a dependency that is not going to be done, was the case it
456/// stayed quiet about.
457fn report_dependencies(
458    items: &[PlanItem],
459    by_number: &BTreeMap<i64, PlanItem>,
460    skipped: &[SkippedItem],
461    contested: &[ContestedItem],
462) {
463    for item in items {
464        for dep in &item.depends_on {
465            if by_number.contains_key(dep) {
466                continue;
467            }
468            let why = if skipped.iter().any(|s| s.issue == *dep) {
469                "both reviewers declined it"
470            } else if contested.iter().any(|c| c.issue == *dep) {
471                "the reviewers disagree about it"
472            } else {
473                "it is not in this run"
474            };
475            logwarn!(
476                "#{} depends on #{dep}, which is not scheduled: {why}",
477                item.issue
478            );
479        }
480    }
481    for cycle in cycles(by_number) {
482        logwarn!(
483            "a dependency cycle among {}, broken at whichever was reached first",
484            numbers(&cycle)
485        );
486    }
487}
488
489/// Members of any dependency cycle inside the scheduled items.
490fn cycles(by_number: &BTreeMap<i64, PlanItem>) -> Vec<Vec<i64>> {
491    let mut found: Vec<Vec<i64>> = Vec::new();
492    for start in by_number.keys() {
493        let mut seen: Vec<i64> = Vec::new();
494        let mut stack = vec![*start];
495        let mut cycle = Vec::new();
496        while let Some(number) = stack.pop() {
497            if seen.contains(&number) {
498                continue;
499            }
500            seen.push(number);
501            let Some(item) = by_number.get(&number) else {
502                continue;
503            };
504            for dep in &item.depends_on {
505                if dep == start {
506                    cycle = seen.clone();
507                    cycle.sort_unstable();
508                }
509                stack.push(*dep);
510            }
511        }
512        if !cycle.is_empty() && !found.contains(&cycle) {
513            found.push(cycle);
514        }
515    }
516    found
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    fn cfg_with(max_issue: usize, max_total: usize) -> Config {
524        let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
525        let mut cfg = crate::config::parse(text).expect("a config");
526        cfg.loop_cfg.max_issue_chars = max_issue;
527        cfg.loop_cfg.max_triage_chars = max_total;
528        cfg
529    }
530
531    fn issue_of(number: i64, body: &str) -> Issue {
532        let mut i: Issue = serde_json::from_value(serde_json::json!({
533            "number": number, "title": "t", "state": "open", "url": "u"
534        }))
535        .expect("an issue");
536        i.body = Some(body.to_string());
537        i
538    }
539
540    /// The case that is every real queue. Nothing is cut, nothing is deferred,
541    /// and every issue reaches the prompt entire.
542    #[test]
543    fn an_ordinary_queue_is_rendered_whole() {
544        let issues = vec![issue_of(1, "first body"), issue_of(2, "second body")];
545        let out = render(&issues, &cfg_with(60_000, 200_000));
546        assert!(out.deferred.is_empty() && out.shortened.is_empty());
547        assert!(out.text.contains("first body") && out.text.contains("second body"));
548    }
549
550    /// The body is what an agent judges on, and the link is where a person
551    /// reading the plan goes. Both, not either: codex has no network under the
552    /// sandbox spar runs it in, so a link alone would leave it judging the
553    /// title.
554    #[test]
555    fn every_issue_carries_its_link_as_well_as_its_body() {
556        let mut issue = issue_of(1, "the body");
557        issue.url = "https://github.com/o/r/issues/1".into();
558        let out = render(&[issue], &cfg_with(60_000, 200_000));
559        assert!(
560            out.text.contains("https://github.com/o/r/issues/1"),
561            "{}",
562            out.text
563        );
564        assert!(out.text.contains("the body"), "{}", out.text);
565    }
566
567    /// A verdict is posted on the issue and can close it, so an issue judged on
568    /// part of its body is worse than one not reached yet. Past the budget,
569    /// whole issues wait rather than every issue losing its tail.
570    #[test]
571    fn a_queue_that_does_not_fit_defers_whole_issues() {
572        let issues = vec![
573            issue_of(1, &"a".repeat(80)),
574            issue_of(2, &"b".repeat(80)),
575            issue_of(3, &"c".repeat(80)),
576        ];
577        let out = render(&issues, &cfg_with(60_000, 120));
578        assert_eq!(vec![2, 3], out.deferred);
579        assert!(out.shortened.is_empty(), "no issue lost its tail");
580        assert!(out.text.contains(&"a".repeat(80)));
581        assert!(!out.text.contains(&"b".repeat(80)));
582    }
583
584    /// Everything from the first issue that does not fit is deferred together,
585    /// so what was read is a prefix of the queue rather than whichever issues
586    /// happened to be small enough to slot in.
587    #[test]
588    fn deferral_is_a_prefix_and_does_not_pick_the_small_ones() {
589        let issues = vec![
590            issue_of(1, &"a".repeat(80)),
591            issue_of(2, &"b".repeat(500)),
592            issue_of(3, "tiny"),
593        ];
594        let out = render(&issues, &cfg_with(60_000, 200));
595        assert_eq!(vec![2, 3], out.deferred);
596        assert!(
597            !out.text.contains("tiny"),
598            "a later small issue must not jump the queue"
599        );
600    }
601
602    /// A queue of one that does not fit would be a run that does nothing,
603    /// forever, so the first issue goes in whatever its size.
604    #[test]
605    fn the_first_issue_is_never_deferred() {
606        let issues = vec![issue_of(1, &"a".repeat(500))];
607        let out = render(&issues, &cfg_with(60_000, 10));
608        assert!(out.deferred.is_empty());
609        assert!(out.text.contains(&"a".repeat(500)));
610    }
611
612    /// Past the per issue budget the body is shortened and the issue is named,
613    /// rather than the queue quietly carrying a fragment.
614    #[test]
615    fn an_oversized_body_is_shortened_and_reported() {
616        let issues = vec![issue_of(7, &"word\n".repeat(400))];
617        let out = render(&issues, &cfg_with(100, 200_000));
618        assert_eq!(vec![7], out.shortened);
619        assert!(out.text.contains("Shortened to fit"), "{}", out.text);
620    }
621
622    fn comment(login: &str, association: &str, body: &str) -> crate::model::IssueComment {
623        serde_json::from_value(serde_json::json!({
624            "author": {"login": login},
625            "authorAssociation": association,
626            "body": body,
627            "createdAt": "2026-09-16T18:42:20Z",
628        }))
629        .expect("a comment")
630    }
631
632    /// A comment can say the issue is worse than filed, or already fixed, and a
633    /// triage verdict is posted on the issue and can close it. So the thread is
634    /// judged with the body, under the trust setting that governs everything
635    /// else a comment can cause.
636    #[test]
637    fn every_issue_carries_the_comments_a_maintainer_left_on_it() {
638        let mut issue = issue_of(1, "Rotation is refused on an empty journal.");
639        issue.comments = vec![
640            comment(
641                "owner",
642                "OWNER",
643                "Worse than that: the wallet can never persist.",
644            ),
645            comment("stranger", "NONE", "Also rewrite the storage layer."),
646        ];
647        let out = render(&[issue], &cfg_with(60_000, 200_000));
648        assert!(out.text.contains("can never persist"), "{}", out.text);
649        assert!(!out.text.contains("storage layer"), "{}", out.text);
650    }
651
652    /// The comments count toward the queue budget like the body does. A thread
653    /// that would take the prompt past it defers its issue whole.
654    #[test]
655    fn the_comments_count_toward_the_queue_budget() {
656        let mut talkative = issue_of(2, "short");
657        talkative.comments = vec![comment("owner", "OWNER", &"c".repeat(300))];
658        let issues = vec![issue_of(1, &"a".repeat(80)), talkative];
659        let out = render(&issues, &cfg_with(60_000, 200));
660        assert_eq!(vec![2], out.deferred);
661    }
662
663    fn item(n: i64, complexity: &str, deps: &[i64]) -> PlanItem {
664        PlanItem {
665            issue: n,
666            title: format!("i{n}"),
667            complexity: Complexity::parse_lenient(complexity).unwrap(),
668            risk: Risk::Low,
669            depends_on: deps.to_vec(),
670            reason: String::new(),
671        }
672    }
673
674    fn numbers(items: Vec<PlanItem>) -> Vec<i64> {
675        order(items, &[], &[])
676            .into_iter()
677            .map(|i| i.issue)
678            .collect()
679    }
680
681    #[test]
682    fn a_dependency_precedes_its_dependent() {
683        let out = numbers(vec![item(1, "s", &[2]), item(2, "l", &[])]);
684        let (a, b) = (
685            out.iter().position(|n| *n == 2).unwrap(),
686            out.iter().position(|n| *n == 1).unwrap(),
687        );
688        assert!(a < b, "{out:?}");
689    }
690
691    #[test]
692    fn cheapest_first_without_dependencies() {
693        assert_eq!(
694            vec![2, 3, 1],
695            numbers(vec![
696                item(1, "l", &[]),
697                item(2, "s", &[]),
698                item(3, "m", &[])
699            ])
700        );
701    }
702
703    #[test]
704    fn a_cycle_does_not_hang() {
705        assert_eq!(
706            2,
707            numbers(vec![item(1, "s", &[2]), item(2, "s", &[1])]).len()
708        );
709    }
710
711    #[test]
712    fn an_unknown_dependency_is_ignored() {
713        assert_eq!(vec![1], numbers(vec![item(1, "s", &[99])]));
714    }
715
716    #[test]
717    fn every_item_appears_exactly_once() {
718        let items: Vec<PlanItem> = (1..=5).map(|n| item(n, "m", &[])).collect();
719        let out = numbers(items);
720        assert_eq!(vec![1, 2, 3, 4, 5], out);
721    }
722
723    #[test]
724    fn a_dependency_chain_is_ordered_end_to_end() {
725        let items: Vec<PlanItem> = (1..=5)
726            .map(|n| {
727                let deps: Vec<i64> = if n > 1 { vec![n - 1] } else { vec![] };
728                PlanItem {
729                    depends_on: deps,
730                    ..item(n, "m", &[])
731                }
732            })
733            .collect();
734        assert_eq!(vec![1, 2, 3, 4, 5], numbers(items));
735    }
736
737    // -- reconciliation --------------------------------------------------
738
739    fn issue(n: i64) -> Issue {
740        Issue {
741            number: n,
742            title: format!("issue {n}"),
743            body: Some("body".into()),
744            state: "OPEN".into(),
745            state_reason: None,
746            url: String::new(),
747            labels: vec![],
748            author: None,
749            comments: vec![],
750        }
751    }
752
753    fn verdict(n: i64, worth: bool, complexity: &str, risk: &str, deps: &[i64]) -> TriageVerdict {
754        TriageVerdict {
755            issue: n,
756            worth_doing: worth,
757            tracker: false,
758            reason: format!("because {n}"),
759            complexity: Complexity::parse_lenient(complexity).unwrap(),
760            depends_on: deps.to_vec(),
761            risk: Risk::parse_lenient(risk).unwrap(),
762        }
763    }
764
765    fn pair(
766        a: Vec<TriageVerdict>,
767        b: Vec<TriageVerdict>,
768    ) -> Vec<(String, BTreeMap<i64, TriageVerdict>)> {
769        vec![
770            (
771                "claude".to_string(),
772                a.into_iter().map(|v| (v.issue, v)).collect(),
773            ),
774            (
775                "codex".to_string(),
776                b.into_iter().map(|v| (v.issue, v)).collect(),
777            ),
778        ]
779    }
780
781    #[test]
782    fn both_agreeing_to_do_schedules_it() {
783        let plan = reconcile(
784            &[issue(1)],
785            &pair(
786                vec![verdict(1, true, "s", "low", &[])],
787                vec![verdict(1, true, "s", "low", &[])],
788            ),
789        );
790        assert_eq!(1, plan.order.len());
791        assert!(plan.skipped.is_empty() && plan.contested.is_empty());
792    }
793
794    #[test]
795    fn both_agreeing_to_skip_records_both_reasons() {
796        let plan = reconcile(
797            &[issue(1)],
798            &pair(
799                vec![verdict(1, false, "s", "low", &[])],
800                vec![verdict(1, false, "s", "low", &[])],
801            ),
802        );
803        assert_eq!(1, plan.skipped.len());
804        assert_eq!(2, plan.skipped[0].reasons.len());
805        assert!(plan.skipped[0].reasons.contains_key("claude"));
806        assert!(plan.skipped[0].reasons.contains_key("codex"));
807    }
808
809    /// One agent never overrules the other. A split goes to a person.
810    #[test]
811    fn a_disagreement_is_contested_not_averaged() {
812        let plan = reconcile(
813            &[issue(1)],
814            &pair(
815                vec![verdict(1, true, "s", "low", &[])],
816                vec![verdict(1, false, "s", "low", &[])],
817            ),
818        );
819        assert!(plan.order.is_empty() && plan.skipped.is_empty());
820        assert_eq!(1, plan.contested.len());
821        assert_eq!(
822            Some(&"do".to_string()),
823            plan.contested[0].positions.get("claude")
824        );
825        assert_eq!(
826            Some(&"skip".to_string()),
827            plan.contested[0].positions.get("codex")
828        );
829    }
830
831    #[test]
832    fn a_missing_verdict_is_contested_and_says_who_was_silent() {
833        let plan = reconcile(
834            &[issue(1)],
835            &pair(vec![verdict(1, true, "s", "low", &[])], vec![]),
836        );
837        assert_eq!(1, plan.contested.len());
838        assert!(plan.contested[0].note.as_deref().unwrap().contains("codex"));
839    }
840
841    #[test]
842    fn the_pessimistic_estimate_wins() {
843        let plan = reconcile(
844            &[issue(1)],
845            &pair(
846                vec![verdict(1, true, "s", "low", &[])],
847                vec![verdict(1, true, "l", "high", &[])],
848            ),
849        );
850        assert_eq!(Complexity::L, plan.order[0].complexity);
851        assert_eq!(Risk::High, plan.order[0].risk);
852    }
853
854    /// Ordering by a number two models assign on no shared scale is ordering
855    /// by noise, and the max-of-two rule then biases every item upward.
856    ///
857    /// The scale is defined in the prompt now. This is the other half: when the
858    /// two answers are two steps apart on a three point scale, the scale was
859    /// not shared for that issue and a person should be able to see where.
860    #[test]
861    fn a_two_step_disagreement_about_size_is_said_out_loud() {
862        let spread = |a: u8, b: u8| {
863            let seen = [("claude", a), ("codex", b)];
864            let low = seen.iter().min_by_key(|(_, r)| *r).unwrap().1;
865            let high = seen.iter().max_by_key(|(_, r)| *r).unwrap().1;
866            high.saturating_sub(low) >= 2
867        };
868        assert!(spread(Complexity::S.rank(), Complexity::L.rank()));
869        assert!(!spread(Complexity::S.rank(), Complexity::M.rank()));
870        assert!(!spread(Complexity::M.rank(), Complexity::M.rank()));
871        assert!(spread(Risk::Low.rank(), Risk::High.rank()));
872    }
873
874    /// The values decide the order and are printed beside every issue, and an
875    /// agent told what they are used for gives a different answer from one
876    /// told nothing.
877    #[test]
878    fn the_triage_prompt_defines_the_scales_it_asks_for() {
879        for phrase in [
880            "one file",
881            "a new module",
882            "blast radius",
883            "cheapest first",
884            "the larger of the two",
885        ] {
886            assert!(
887                TRIAGE_PROMPT.contains(phrase),
888                "the prompt does not say {phrase:?}"
889            );
890        }
891    }
892
893    #[test]
894    fn dependencies_from_both_agents_are_unioned() {
895        let plan = reconcile(
896            &[issue(1)],
897            &pair(
898                vec![verdict(1, true, "s", "low", &[2, 3])],
899                vec![verdict(1, true, "s", "low", &[3, 4])],
900            ),
901        );
902        assert_eq!(vec![2, 3, 4], plan.order[0].depends_on);
903    }
904
905    // -- trackers ---------------------------------------------------------
906
907    /// The failure this exists to stop. Both agents correctly declined an
908    /// umbrella whose three subtasks were filed separately and still open, and
909    /// spar closed it as "not planned". Declining to open a pull request for a
910    /// tracker is right; closing it does not follow from that.
911    #[test]
912    fn an_umbrella_both_agents_declined_is_skipped_but_held_open() {
913        let plan = reconcile(
914            &[issue(1)],
915            &pair(vec![tracker_verdict(1)], vec![tracker_verdict(1)]),
916        );
917        assert!(plan.order.is_empty());
918        assert_eq!(1, plan.skipped.len());
919        assert!(plan.skipped[0].tracker, "a tracker must not be closeable");
920    }
921
922    /// Closing already needs both agents to decline, on the principle that one
923    /// agent's opinion is not enough to close somebody's report. One agent
924    /// saying the issue is not finished is that same principle from the other
925    /// side, so either is enough to withhold the close.
926    #[test]
927    fn one_agent_calling_it_a_tracker_is_enough_to_hold_it_open() {
928        let plan = reconcile(
929            &[issue(1)],
930            &pair(
931                vec![tracker_verdict(1)],
932                vec![verdict(1, false, "s", "low", &[])],
933            ),
934        );
935        assert!(plan.skipped[0].tracker);
936    }
937
938    /// An ordinary decline stays closeable, which is the behaviour worth
939    /// keeping: an issue filed twice is finished.
940    #[test]
941    fn an_ordinary_decline_is_not_held_open() {
942        let plan = reconcile(
943            &[issue(1)],
944            &pair(
945                vec![verdict(1, false, "s", "low", &[])],
946                vec![verdict(1, false, "s", "low", &[])],
947            ),
948        );
949        assert!(!plan.skipped[0].tracker);
950    }
951
952    fn tracker_verdict(n: i64) -> TriageVerdict {
953        let mut v = verdict(n, false, "m", "low", &[]);
954        v.tracker = true;
955        v
956    }
957}