1use 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
63struct Rendered {
65 text: String,
66 deferred: Vec<i64>,
68 shortened: Vec<i64>,
70}
71
72fn 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 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 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
139pub fn triage(agents: &[Agent], cfg: &Config, repo: &Repo, issues: &[Issue]) -> Result<Plan> {
141 let rendered = render(issues, cfg);
142 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
197fn 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
255fn 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 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 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
370pub 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; };
400 if visiting.contains(&number) {
401 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
429fn 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
451fn 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
489fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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}