1use std::collections::{BTreeMap, BTreeSet};
16use std::path::Path;
17
18use anyhow::{bail, Context, Result};
19use clap::ValueEnum;
20use serde::{Deserialize, Serialize};
21use tracing::warn;
22
23use crate::jev::citations::Citation;
24use crate::jev::client::JevClient;
25use crate::jev::error::is_auth_failure;
26use crate::jev::input::truncate_middle;
27use crate::jev::protocol::{Answer, Question, SystemOneRequest, Usage};
28use crate::provider::{IssueDoc, ItemState};
29
30pub use crate::jev::input::TRUNCATION_MARKER;
31
32pub mod effort;
33
34const STAGE_QUESTIONS_YAML: &str = include_str!("../templates/jev-route-questions.yaml");
36
37pub const NO_DESIGN: &str = "none";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
51pub enum Provider {
52 #[value(name = "anthropic")]
54 Anthropic,
55 #[value(name = "openai")]
57 OpenAi,
58 #[value(name = "gemini")]
61 Gemini,
62}
63
64impl Provider {
65 pub const ALL: [Self; 3] = [Self::Anthropic, Self::OpenAi, Self::Gemini];
67
68 #[must_use]
71 pub const fn name(self) -> &'static str {
72 match self {
73 Self::Anthropic => "anthropic",
74 Self::OpenAi => "openai",
75 Self::Gemini => "gemini",
76 }
77 }
78
79 const fn tiers_yaml(self) -> &'static str {
81 match self {
82 Self::Anthropic => include_str!("../templates/jev-route-tiers-anthropic.yaml"),
83 Self::OpenAi => include_str!("../templates/jev-route-tiers-openai.yaml"),
84 Self::Gemini => include_str!("../templates/jev-route-tiers-gemini.yaml"),
85 }
86 }
87
88 pub fn tiers(self) -> Result<Tiers> {
90 Tiers::parse(self.tiers_yaml())
91 .with_context(|| format!("Invalid embedded tiers for provider {}", self.name()))
92 }
93}
94
95pub const DEFAULT_CLOSE_CALL: f64 = 0.3;
98
99pub const DEFAULT_MAX_INPUT_CHARS: usize = 60_000;
102
103const MIN_TIERS: usize = 2;
106
107pub type OpenDependencies = BTreeMap<(String, u64), Vec<Citation>>;
113
114pub type ReferenceFetchFailures = BTreeMap<(String, u64), Vec<ReferenceFetchFailure>>;
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
123pub struct ReferenceFetchFailure {
124 #[serde(rename = "ref")]
126 pub item_ref: String,
127 pub error: String,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
133#[serde(rename_all = "lowercase")]
134pub enum Stage {
135 Design,
137 Implement,
139 Review,
141}
142
143impl Stage {
144 const ALL: [Self; 3] = [Self::Design, Self::Implement, Self::Review];
146
147 const fn template_key(self) -> &'static str {
149 match self {
150 Self::Design => "stage_design",
151 Self::Implement => "stage_implement",
152 Self::Review => "stage_review",
153 }
154 }
155
156 fn question_key(self, provider: &str) -> String {
159 format!("{provider}.{}", self.template_key())
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct Tier {
167 pub name: String,
169 pub description: String,
171 pub models: Option<Vec<effort::Model>>,
173}
174
175#[derive(Deserialize)]
177#[serde(deny_unknown_fields)]
178struct TiersFile {
179 tiers: Vec<Tier>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct Tiers(Vec<Tier>);
185
186impl Tiers {
187 pub fn parse(yaml: &str) -> Result<Self> {
190 let file: TiersFile = serde_yaml::from_str(yaml).context("Failed to parse tiers file")?;
191 if file.tiers.len() < MIN_TIERS {
192 bail!(
193 "a tiers file needs at least {MIN_TIERS} tiers, got {}",
194 file.tiers.len()
195 );
196 }
197 let mut seen = BTreeSet::new();
198 for tier in &file.tiers {
199 if tier.name.trim().is_empty() || tier.description.trim().is_empty() {
200 bail!("every tier needs a non-empty name and description");
201 }
202 if tier.name == NO_DESIGN {
203 bail!("the tier name {NO_DESIGN:?} is reserved for \"no design work remains\"");
204 }
205 if !seen.insert(tier.name.as_str()) {
206 bail!("tier {:?} is defined more than once", tier.name);
207 }
208 }
209 let mut model_rungs = BTreeMap::new();
210 for tier in &file.tiers {
211 if let Some(models) = &tier.models {
212 effort::validate(models).with_context(|| format!("rung {:?}", tier.name))?;
213 for model in models {
214 for &stage in &model.stages {
215 if let Some(previous) = model_rungs.insert((&model.name, stage), &tier.name)
216 {
217 bail!(
218 "model {:?} is bound to both rungs {previous:?} and {:?} for {stage:?}",
219 model.name,
220 tier.name
221 );
222 }
223 }
224 }
225 }
226 }
227 Ok(Self(file.tiers))
228 }
229
230 pub fn load_file(path: &Path) -> Result<Self> {
232 let yaml = std::fs::read_to_string(path)
233 .with_context(|| format!("Failed to read tiers file {}", path.display()))?;
234 Self::parse(&yaml).with_context(|| format!("Invalid tiers file {}", path.display()))
235 }
236
237 #[must_use]
239 pub fn as_slice(&self) -> &[Tier] {
240 &self.0
241 }
242
243 fn rank(&self, choice: &str) -> Option<usize> {
246 if choice == NO_DESIGN {
247 return Some(0);
248 }
249 self.0.iter().position(|t| t.name == choice).map(|i| i + 1)
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct Ladder {
260 pub name: String,
262 pub tiers: Tiers,
264}
265
266impl Ladder {
267 pub fn builtin(provider: Provider) -> Result<Self> {
269 Ok(Self {
270 name: provider.name().to_string(),
271 tiers: provider.tiers()?,
272 })
273 }
274
275 #[must_use]
277 pub fn named(name: String, tiers: Tiers) -> Self {
278 Self { name, tiers }
279 }
280}
281
282pub fn build_route_questions(ladders: &[Ladder]) -> Result<BTreeMap<String, Question>> {
288 build_route_questions_for_mode(ladders, true)
289}
290
291fn build_route_questions_for_mode(
292 ladders: &[Ladder],
293 effort_advice: bool,
294) -> Result<BTreeMap<String, Question>> {
295 let templates: BTreeMap<String, Question> = serde_yaml::from_str(STAGE_QUESTIONS_YAML)
296 .context("Failed to parse the embedded stage questions")?;
297 for stage in Stage::ALL {
298 if !templates.contains_key(stage.template_key()) {
299 bail!(
300 "the embedded stage questions have no {:?}",
301 stage.template_key()
303 );
304 }
305 }
306 let mut questions = BTreeMap::new();
307 for ladder in ladders {
308 for (key, template) in &templates {
309 let mut question = template.clone();
310 let Question::Choice { criteria, .. } = &mut question else {
311 bail!("embedded stage question {key:?} is not a choice question");
313 };
314 for tier in ladder.tiers.as_slice() {
315 if criteria
316 .insert(tier.name.clone(), tier.description.clone())
317 .is_some()
318 {
319 bail!(
321 "tier {:?} of ladder {:?} collides with a fixed option of {key:?}",
322 tier.name,
323 ladder.name
324 );
325 }
326 }
327 question
328 .validate()
329 .with_context(|| format!("stage question {key:?} for {:?}", ladder.name))?;
330 questions.insert(format!("{}.{key}", ladder.name), question);
331 }
332 }
333 if effort_advice {
334 for ladder in ladders {
335 effort::add_questions(ladder, &mut questions);
336 }
337 }
338 Ok(questions)
339}
340
341#[must_use]
357pub fn build_route_state(doc: &IssueDoc, max_chars: usize) -> (String, bool) {
358 let mut text = format!("# #{} {}\n\n{}\n", doc.number, doc.title, doc.body.trim());
359 if !doc.comments.is_empty() {
360 text.push_str("\n\n---\n\n## Comments on this issue\n");
361 for comment in &doc.comments {
362 text.push_str(&format!(
363 "\n\n**Comment by {}:**\n\n{}\n",
364 comment.author,
365 comment.body.trim()
366 ));
367 }
368 }
369 truncate_middle(&text, max_chars)
370}
371
372#[derive(Debug, Clone, PartialEq, Serialize)]
374pub struct StageAnswer {
375 pub choice: String,
377 pub confidence: f64,
379 pub probabilities: BTreeMap<String, f64>,
381 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
383 pub effort_by_model: BTreeMap<String, Vec<effort::ModelEffort>>,
384}
385
386#[derive(Debug, Clone, PartialEq, Serialize)]
388pub struct StageAnswers {
389 pub design: StageAnswer,
391 pub implement: StageAnswer,
393 pub review: StageAnswer,
395}
396
397impl StageAnswers {
398 fn get(&self, stage: Stage) -> &StageAnswer {
399 match stage {
400 Stage::Design => &self.design,
401 Stage::Implement => &self.implement,
402 Stage::Review => &self.review,
403 }
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Serialize)]
410pub struct DependencyEntry {
411 #[serde(rename = "ref")]
415 pub item_ref: String,
416 #[serde(skip)]
418 pub url: Option<String>,
419 pub state: ItemState,
422 pub could_be_cheaper: BTreeMap<String, f64>,
427}
428
429#[derive(Debug, Clone, PartialEq, Serialize)]
431pub struct IssueRoute {
432 #[serde(rename = "ref")]
434 pub item_ref: String,
435 pub url: String,
437 pub title: String,
439 pub state: ItemState,
443 #[serde(flatten)]
445 pub outcome: RouteOutcome,
446 #[serde(skip_serializing_if = "std::ops::Not::not")]
448 pub truncated: bool,
449}
450
451#[derive(Debug, Clone, PartialEq, Serialize)]
453pub struct ProviderRoute {
454 pub stages: StageAnswers,
456 pub class: String,
458 pub close_calls: Vec<Stage>,
460}
461
462#[derive(Debug, Clone, PartialEq, Serialize)]
468#[serde(untagged)]
469pub enum RouteOutcome {
470 Routed {
472 providers: BTreeMap<String, ProviderRoute>,
474 depends_on: Vec<DependencyEntry>,
477 #[serde(skip_serializing_if = "Vec::is_empty")]
480 reference_fetch_failures: Vec<ReferenceFetchFailure>,
481 },
482 Failed {
485 error: String,
487 #[serde(skip_serializing_if = "Vec::is_empty")]
489 reference_fetch_failures: Vec<ReferenceFetchFailure>,
490 },
491}
492
493impl IssueRoute {
494 #[must_use]
496 pub fn failed(&self) -> bool {
497 matches!(self.outcome, RouteOutcome::Failed { .. })
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Serialize)]
503pub struct RouteReport {
504 pub model: String,
507 pub issues: Vec<IssueRoute>,
509 #[serde(skip_serializing_if = "Vec::is_empty")]
513 pub ignored_closed: Vec<IgnoredIssue>,
514 pub usage: Usage,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
521pub struct IgnoredIssue {
522 #[serde(rename = "ref")]
524 pub item_ref: String,
525 pub url: String,
527 pub title: String,
529}
530
531#[derive(Debug, Clone)]
533pub struct RouteOptions {
534 pub model: String,
536 pub close_call: f64,
538 pub effort_advice: bool,
540 pub max_input_chars: usize,
542 pub allow_closed: bool,
544 pub ignore_closed: bool,
548}
549
550pub async fn run_route(
573 jev: &JevClient,
574 docs: &[IssueDoc],
575 ladders: &[Ladder],
576 opts: &RouteOptions,
577 dependencies: &OpenDependencies,
578) -> Result<RouteReport> {
579 run_route_with_reference_fetch_failures(
580 jev,
581 docs,
582 ladders,
583 opts,
584 dependencies,
585 &ReferenceFetchFailures::new(),
586 )
587 .await
588}
589
590pub async fn run_route_with_reference_fetch_failures(
593 jev: &JevClient,
594 docs: &[IssueDoc],
595 ladders: &[Ladder],
596 opts: &RouteOptions,
597 dependencies: &OpenDependencies,
598 reference_fetch_failures: &ReferenceFetchFailures,
599) -> Result<RouteReport> {
600 let (docs, ignored_closed) = drop_closed(docs, opts)?;
601 validate_options(&docs, ladders, opts)?;
602 let questions = build_route_questions_for_mode(ladders, opts.effort_advice)?;
603
604 let mut models = BTreeSet::new();
605 let mut usage = Usage::default();
606 let mut issues = Vec::with_capacity(docs.len());
607 for doc in docs {
608 let item_ref = item_ref(doc);
609 let (state, truncated) = build_route_state(doc, opts.max_input_chars);
610 if truncated {
611 warn!(
612 "Issue {item_ref} is longer than {} characters; its input was truncated",
613 opts.max_input_chars
614 );
615 }
616 let citations = dependencies
617 .get(&(doc.project.clone(), doc.number))
618 .map_or(&[][..], Vec::as_slice);
619 let citation_failures = reference_fetch_failures
620 .get(&(doc.project.clone(), doc.number))
621 .cloned()
622 .unwrap_or_default();
623 let mut request_questions = questions.clone();
624 for (i, citation) in citations.iter().enumerate() {
625 request_questions.insert(
626 could_be_cheaper_key(i),
627 could_be_cheaper_question(&citation.raw),
628 );
629 }
630 let request = SystemOneRequest {
631 state: serde_json::Value::String(state),
632 model: opts.model.clone(),
633 questions: request_questions,
634 };
635 let outcome = match jev.system_one(&request).await {
636 Err(err) if is_auth_failure(&err) => {
637 return Err(err).with_context(|| format!("Failed to route issue {item_ref}"));
638 }
639 Err(err) => failed(
640 &item_ref,
641 &err.context("Jev request failed"),
642 &citation_failures,
643 ),
644 Ok(response) => {
645 models.insert(response.model);
646 usage.input_tokens += response.usage.input_tokens;
647 usage.output_tokens += response.usage.output_tokens;
648 match provider_routes(
649 &response.answers,
650 ladders,
651 opts.close_call,
652 opts.effort_advice,
653 ) {
654 Ok(providers) => RouteOutcome::Routed {
655 providers,
656 depends_on: dependency_entries(&item_ref, citations, &response.answers),
657 reference_fetch_failures: citation_failures.clone(),
658 },
659 Err(err) => failed(
660 &item_ref,
661 &err.context("Unexpected Jev answer"),
662 &citation_failures,
663 ),
664 }
665 }
666 };
667 issues.push(IssueRoute {
668 item_ref,
669 url: doc.url.clone(),
670 title: doc.title.clone(),
671 state: doc.state,
672 outcome,
673 truncated,
674 });
675 }
676
677 Ok(RouteReport {
678 model: models.into_iter().collect::<Vec<_>>().join(", "),
679 issues,
680 ignored_closed,
681 usage,
682 })
683}
684
685#[must_use]
688pub fn is_ignored_closed(doc: &IssueDoc, ignore_closed: bool) -> bool {
689 ignore_closed && doc.state == ItemState::Closed
690}
691
692fn item_ref(doc: &IssueDoc) -> String {
694 format!("{}#{}", doc.project, doc.number)
695}
696
697fn drop_closed<'a>(
704 docs: &'a [IssueDoc],
705 opts: &RouteOptions,
706) -> Result<(Vec<&'a IssueDoc>, Vec<IgnoredIssue>)> {
707 if opts.allow_closed && opts.ignore_closed {
708 bail!("--allow-closed and --ignore-closed cannot be used together");
709 }
710 let (open, closed): (Vec<&IssueDoc>, Vec<&IssueDoc>) = docs
711 .iter()
712 .partition(|d| !is_ignored_closed(d, opts.ignore_closed));
713 if open.is_empty() && !closed.is_empty() {
714 bail!(
715 "every requested issue is closed ({}) and --ignore-closed skipped them; nothing to \
716 route",
717 closed
718 .iter()
719 .map(|d| item_ref(d))
720 .collect::<Vec<_>>()
721 .join(", ")
722 );
723 }
724 let ignored = closed
725 .into_iter()
726 .map(|d| IgnoredIssue {
727 item_ref: item_ref(d),
728 url: d.url.clone(),
729 title: d.title.clone(),
730 })
731 .collect();
732 Ok((open, ignored))
733}
734
735fn failed(
738 item_ref: &str,
739 err: &anyhow::Error,
740 reference_fetch_failures: &[ReferenceFetchFailure],
741) -> RouteOutcome {
742 let error = format!("{err:#}");
743 warn!("Could not route issue {item_ref}: {error}");
744 RouteOutcome::Failed {
745 error,
746 reference_fetch_failures: reference_fetch_failures.to_vec(),
747 }
748}
749
750fn validate_options(docs: &[&IssueDoc], ladders: &[Ladder], opts: &RouteOptions) -> Result<()> {
753 if docs.is_empty() {
754 bail!("no issues to route");
755 }
756 if ladders.is_empty() {
757 bail!("no ladders to route against");
758 }
759 let mut seen = BTreeSet::new();
760 for ladder in ladders {
761 if !seen.insert(ladder.name.as_str()) {
762 bail!("ladder {:?} is listed more than once", ladder.name);
763 }
764 }
765 if !(0.0..=1.0).contains(&opts.close_call) {
766 bail!(
767 "the close-call threshold must be between 0 and 1, got {}",
768 opts.close_call
769 );
770 }
771 if opts.max_input_chars == 0 {
772 bail!("the input cap must be at least 1 character");
773 }
774 if !opts.allow_closed {
775 let closed: Vec<String> = docs
776 .iter()
777 .filter(|d| d.state == ItemState::Closed)
778 .map(|d| item_ref(d))
779 .collect();
780 if !closed.is_empty() {
781 bail!(
782 "refusing to route closed issues ({}): their comments often describe how the \
783 work was done, which leaks the answer. Pass --ignore-closed to skip them, or \
784 --allow-closed to route them anyway",
785 closed.join(", ")
786 );
787 }
788 }
789 Ok(())
790}
791
792fn provider_routes(
796 answers: &BTreeMap<String, Answer>,
797 ladders: &[Ladder],
798 close_call: f64,
799 effort_advice: bool,
800) -> Result<BTreeMap<String, ProviderRoute>> {
801 ladders
802 .iter()
803 .map(|ladder| {
804 let mut stages = stage_answers(answers, ladder)?;
805 if effort_advice {
806 for (stage, answer) in [
807 (Stage::Design, &mut stages.design),
808 (Stage::Implement, &mut stages.implement),
809 (Stage::Review, &mut stages.review),
810 ] {
811 answer.effort_by_model =
812 effort::decode(ladder, stage, answer, answers, close_call)?;
813 }
814 }
815 let route = ProviderRoute {
816 class: issue_class(&stages, &ladder.tiers),
817 close_calls: close_calls(&stages, close_call),
818 stages,
819 };
820 Ok((ladder.name.clone(), route))
821 })
822 .collect()
823}
824
825fn stage_answers(answers: &BTreeMap<String, Answer>, ladder: &Ladder) -> Result<StageAnswers> {
828 let tiers = &ladder.tiers;
829 let read = |stage: Stage| -> Result<StageAnswer> {
830 let key = stage.question_key(&ladder.name);
831 let Some(Answer::Choice {
832 choice,
833 confidence,
834 probabilities,
835 }) = answers.get(&key)
836 else {
837 bail!("no choice answer for {key:?}");
838 };
839 let offered = match tiers.rank(choice) {
840 Some(0) => stage == Stage::Design,
841 Some(_) => true,
842 None => false,
843 };
844 if !offered {
845 bail!("{key:?} chose {choice:?}, which was not offered");
846 }
847 Ok(StageAnswer {
848 choice: choice.clone(),
849 confidence: *confidence,
850 probabilities: probabilities.clone(),
851 effort_by_model: BTreeMap::new(),
852 })
853 };
854 Ok(StageAnswers {
855 design: read(Stage::Design)?,
856 implement: read(Stage::Implement)?,
857 review: read(Stage::Review)?,
858 })
859}
860
861fn issue_class(stages: &StageAnswers, tiers: &Tiers) -> String {
864 [&stages.design, &stages.implement]
865 .into_iter()
866 .max_by_key(|a| tiers.rank(&a.choice).unwrap_or_default())
867 .map(|a| a.choice.clone())
868 .unwrap_or_default()
869}
870
871fn close_calls(stages: &StageAnswers, threshold: f64) -> Vec<Stage> {
873 Stage::ALL
874 .into_iter()
875 .filter(|&s| stages.get(s).confidence < threshold)
876 .collect()
877}
878
879fn could_be_cheaper_key(i: usize) -> String {
881 format!("could_be_cheaper_{i}")
882}
883
884fn could_be_cheaper_question(citation: &str) -> Question {
890 Question::Noul {
891 instructions: format!(
892 "This issue cites {citation}, which is still open. If {citation} is resolved, how \
893 likely is it that LESS design work would remain for THIS issue than the current \
894 text implies — as opposed to this issue's own remaining work being unaffected, \
895 because it is already scoped separately, is a parallel/sibling effort, or \
896 {citation} is otherwise not a precondition for finishing this issue's own \
897 remaining work?"
898 ),
899 criteria: None,
900 }
901}
902
903fn dependency_entries(
910 item_ref: &str,
911 citations: &[Citation],
912 answers: &BTreeMap<String, Answer>,
913) -> Vec<DependencyEntry> {
914 citations
915 .iter()
916 .enumerate()
917 .filter_map(|(i, citation)| {
918 if let Some(Answer::Noul { noul }) = answers.get(&could_be_cheaper_key(i)) {
919 return Some(DependencyEntry {
920 item_ref: citation.raw.clone(),
921 url: citation.url.clone(),
922 state: ItemState::Open,
923 could_be_cheaper: BTreeMap::from([("design".to_string(), *noul)]),
924 });
925 }
926 warn!(
927 "Issue {item_ref}: no could_be_cheaper answer for citation {:?}",
928 citation.raw
929 );
930 None
931 })
932 .collect()
933}
934
935#[must_use]
956pub fn render_route_text(report: &RouteReport, max_input_chars: usize) -> String {
957 render_route_text_styled(report, max_input_chars, &[], TerminalStyle::default())
958}
959
960#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
963pub struct TerminalStyle {
964 pub color: bool,
966 pub hyperlinks: bool,
968}
969
970impl TerminalStyle {
971 #[must_use]
973 pub fn new(tty: bool, term: &str, no_color: bool, supports_links: bool) -> Self {
974 let usable = tty && !term.is_empty() && term != "dumb";
975 Self {
976 color: usable && !no_color,
977 hyperlinks: usable && supports_links,
978 }
979 }
980}
981
982#[must_use]
984pub fn render_route_text_styled(
985 report: &RouteReport,
986 max_input_chars: usize,
987 ladders: &[Ladder],
988 style: TerminalStyle,
989) -> String {
990 let mut blocks: Vec<String> = report
991 .issues
992 .iter()
993 .map(|issue| render_issue_block(issue, max_input_chars, ladders, style))
994 .collect();
995 if !report.ignored_closed.is_empty() {
996 let refs: Vec<String> = report
997 .ignored_closed
998 .iter()
999 .map(|issue| hyperlink(&issue.item_ref, Some(&issue.url), style))
1000 .collect();
1001 blocks.push(format!("ignored closed: {}", refs.join(", ")));
1002 }
1003 blocks.push(format!(
1004 "model: {}, usage: {} input tokens, {} output tokens",
1005 report.model, report.usage.input_tokens, report.usage.output_tokens
1006 ));
1007 let mut text = blocks.join("\n\n");
1008 text.push('\n');
1009 text
1010}
1011
1012fn render_issue_block(
1016 issue: &IssueRoute,
1017 max_input_chars: usize,
1018 ladders: &[Ladder],
1019 style: TerminalStyle,
1020) -> String {
1021 let item_ref = hyperlink(&issue.item_ref, Some(&issue.url), style);
1022 let item_ref = if issue.failed() {
1023 item_ref
1024 } else {
1025 colorize(&item_ref, Complexity::Low, style)
1026 };
1027 let header = if issue.state == ItemState::Closed {
1028 format!("{item_ref} — {} (closed)", issue.title)
1029 } else {
1030 format!("{item_ref} — {}", issue.title)
1031 };
1032 let mut lines = vec![header];
1033 match &issue.outcome {
1034 RouteOutcome::Routed {
1035 providers,
1036 depends_on,
1037 reference_fetch_failures,
1038 } => {
1039 for (provider, route) in providers {
1040 let ladder = ladders.iter().find(|ladder| ladder.name == *provider);
1041 lines.extend(render_provider_line(
1042 provider,
1043 route,
1044 providers.len(),
1045 ladder,
1046 style,
1047 ));
1048 }
1049 lines.extend(render_depends_on_lines(depends_on, style));
1050 lines.extend(render_reference_fetch_failure_lines(
1051 reference_fetch_failures,
1052 ));
1053 }
1054 RouteOutcome::Failed {
1055 error,
1056 reference_fetch_failures,
1057 } => {
1058 lines.push(format!(
1059 " {}",
1060 colorize(&format!("failed: {error}"), Complexity::High, style)
1061 ));
1062 lines.extend(render_reference_fetch_failure_lines(
1063 reference_fetch_failures,
1064 ));
1065 }
1066 }
1067 if issue.truncated {
1068 lines.push(format!(
1069 " input truncated at {} characters",
1070 with_thousands(max_input_chars)
1071 ));
1072 }
1073 lines.join("\n")
1074}
1075
1076fn route_has_multi_model_tier(route: &ProviderRoute) -> bool {
1085 [
1086 &route.stages.design,
1087 &route.stages.implement,
1088 &route.stages.review,
1089 ]
1090 .into_iter()
1091 .any(|answer| answer.choice.contains(','))
1092}
1093
1094fn render_provider_line(
1099 provider: &str,
1100 route: &ProviderRoute,
1101 provider_count: usize,
1102 ladder: Option<&Ladder>,
1103 style: TerminalStyle,
1104) -> Vec<String> {
1105 let mut lines = if route_has_multi_model_tier(route) {
1106 render_provider_block(provider, route, provider_count, ladder, style)
1107 } else {
1108 vec![render_provider_line_compact(
1109 provider,
1110 route,
1111 provider_count,
1112 ladder,
1113 style,
1114 )]
1115 };
1116 lines.extend(effort::render(&route.stages, ladder));
1117 lines
1118}
1119
1120fn render_provider_line_compact(
1124 provider: &str,
1125 route: &ProviderRoute,
1126 provider_count: usize,
1127 ladder: Option<&Ladder>,
1128 style: TerminalStyle,
1129) -> String {
1130 let class = color_choice(&route.class, ladder, style);
1131 let lead = if provider_count > 1 {
1132 format!("{provider}: {class}")
1133 } else {
1134 class
1135 };
1136 let clauses: Vec<String> = Stage::ALL
1137 .into_iter()
1138 .map(|stage| render_stage_clause(stage, &route.stages, &route.close_calls, ladder, style))
1139 .collect();
1140 format!(" {lead} — {}", clauses.join(", "))
1141}
1142
1143fn render_stage_clause(
1146 stage: Stage,
1147 stages: &StageAnswers,
1148 close_calls: &[Stage],
1149 ladder: Option<&Ladder>,
1150 style: TerminalStyle,
1151) -> String {
1152 let answer = stages.get(stage);
1153 let choice = color_choice(&answer.choice, ladder, style);
1154 let label = match stage {
1155 Stage::Design if answer.choice == NO_DESIGN => format!(
1156 "design needs {}",
1157 colorize("no further work", Complexity::Low, style)
1158 ),
1159 Stage::Design => format!("design needs {choice}"),
1160 Stage::Implement => format!("implementation {choice}"),
1161 Stage::Review => format!("review {choice}"),
1162 };
1163 format!(
1164 "{label} ({})",
1165 render_stage_confidence(answer, close_calls.contains(&stage))
1166 )
1167}
1168
1169fn render_stage_confidence(answer: &StageAnswer, close_call: bool) -> String {
1172 let mut detail = format!("{:.2}", answer.confidence);
1173 if close_call {
1174 detail.push_str(", close call");
1175 if let Some((name, probability)) = answer
1176 .probabilities
1177 .iter()
1178 .filter(|(name, probability)| name.as_str() != answer.choice && probability.is_finite())
1179 .max_by(|a, b| a.1.total_cmp(b.1).then_with(|| b.0.cmp(a.0)))
1180 {
1181 detail.push_str(&format!(" — {name} {probability:.2}"));
1182 }
1183 }
1184 detail
1185}
1186
1187fn render_provider_block(
1193 provider: &str,
1194 route: &ProviderRoute,
1195 provider_count: usize,
1196 ladder: Option<&Ladder>,
1197 style: TerminalStyle,
1198) -> Vec<String> {
1199 let mut lines = Vec::with_capacity(5);
1200 let indent = if provider_count > 1 {
1201 lines.push(format!(" {provider}:"));
1202 " "
1203 } else {
1204 " "
1205 };
1206 lines.push(format!(
1207 "{indent}class: {}",
1208 color_choice(&route.class, ladder, style)
1209 ));
1210 for stage in Stage::ALL {
1211 lines.push(format!(
1212 "{indent}{}",
1213 render_stage_line(stage, &route.stages, &route.close_calls, ladder, style)
1214 ));
1215 }
1216 lines
1217}
1218
1219fn render_stage_line(
1224 stage: Stage,
1225 stages: &StageAnswers,
1226 close_calls: &[Stage],
1227 ladder: Option<&Ladder>,
1228 style: TerminalStyle,
1229) -> String {
1230 let answer = stages.get(stage);
1231 let choice = color_choice(&answer.choice, ladder, style);
1232 let (label, content) = match stage {
1233 Stage::Design if answer.choice == NO_DESIGN => (
1234 "design",
1235 format!(
1236 "needs {}",
1237 colorize("no further work", Complexity::Low, style)
1238 ),
1239 ),
1240 Stage::Design => ("design", format!("needs {choice}")),
1241 Stage::Implement => ("implementation", choice),
1242 Stage::Review => ("review", choice),
1243 };
1244 format!(
1245 "{label}: {content} ({})",
1246 render_stage_confidence(answer, close_calls.contains(&stage))
1247 )
1248}
1249
1250fn render_depends_on_lines(depends_on: &[DependencyEntry], style: TerminalStyle) -> Vec<String> {
1253 depends_on
1254 .iter()
1255 .map(|dep| {
1256 let item_ref = hyperlink(&dep.item_ref, dep.url.as_deref(), style);
1257 match dep.could_be_cheaper.get("design") {
1258 Some(prob) => format!(
1259 " cites open {item_ref}, which could leave less design work if resolved ({prob:.2})"
1260 ),
1261 None => format!(" cites open {item_ref}"),
1262 }
1263 })
1264 .collect()
1265}
1266
1267fn render_reference_fetch_failure_lines(failures: &[ReferenceFetchFailure]) -> Vec<String> {
1269 failures
1270 .iter()
1271 .map(|failure| {
1272 format!(
1273 " reference fetch failed: {} ({})",
1274 failure.item_ref, failure.error
1275 )
1276 })
1277 .collect()
1278}
1279
1280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1281enum Complexity {
1282 Low,
1283 Medium,
1284 High,
1285}
1286
1287fn choice_complexity(choice: &str, ladder: &Ladder) -> Option<Complexity> {
1289 if choice == NO_DESIGN {
1290 return Some(Complexity::Low);
1291 }
1292 let tiers = ladder.tiers.as_slice();
1293 let index = tiers.iter().position(|tier| tier.name == choice)?;
1294 Some(if index == 0 {
1295 Complexity::Low
1296 } else if index == tiers.len() - 1 {
1297 Complexity::High
1298 } else {
1299 Complexity::Medium
1300 })
1301}
1302
1303fn color_choice(choice: &str, ladder: Option<&Ladder>, style: TerminalStyle) -> String {
1304 ladder
1305 .and_then(|ladder| choice_complexity(choice, ladder))
1306 .map_or_else(
1307 || choice.to_string(),
1308 |level| colorize(choice, level, style),
1309 )
1310}
1311
1312fn colorize(text: &str, level: Complexity, style: TerminalStyle) -> String {
1313 if !style.color {
1314 return text.to_string();
1315 }
1316 let code = match level {
1317 Complexity::Low => 32,
1318 Complexity::Medium => 33,
1319 Complexity::High => 31,
1320 };
1321 format!("\x1b[{code}m{text}\x1b[0m")
1322}
1323
1324fn hyperlink(text: &str, url: Option<&str>, style: TerminalStyle) -> String {
1327 let Some(url) = url.filter(|url| style.hyperlinks && valid_github_item_url(url)) else {
1328 return text.to_string();
1329 };
1330 format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
1331}
1332
1333fn valid_github_item_url(raw: &str) -> bool {
1334 if raw.chars().any(char::is_control) {
1335 return false;
1336 }
1337 let Ok(url) = url::Url::parse(raw) else {
1338 return false;
1339 };
1340 if url.scheme() != "https" || url.host_str() != Some("github.com") {
1341 return false;
1342 }
1343 let Some(segments) = url.path_segments() else {
1344 return false; };
1346 let parts: Vec<_> = segments.collect();
1347 parts.len() == 4
1348 && !parts[0].is_empty()
1349 && !parts[1].is_empty()
1350 && matches!(parts[2], "issues" | "pull")
1351 && parts[3].parse::<u64>().is_ok()
1352}
1353
1354fn with_thousands(n: usize) -> String {
1356 let digits = n.to_string();
1357 let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
1358 for (i, ch) in digits.chars().rev().enumerate() {
1359 if i > 0 && i % 3 == 0 {
1360 grouped.push(',');
1361 }
1362 grouped.push(ch);
1363 }
1364 grouped.chars().rev().collect()
1365}
1366
1367#[cfg(test)]
1368#[allow(clippy::unwrap_used, clippy::expect_used)]
1369mod tests {
1370 use super::*;
1371 use crate::provider::{Comment, GitProvider, ItemKind, ItemRef};
1372
1373 fn doc(number: u64, state: ItemState) -> IssueDoc {
1374 IssueDoc {
1375 provider: GitProvider::GitHub,
1376 project: "rust-works/omni-dev".to_string(),
1377 number,
1378 kind: ItemKind::Issue,
1379 title: "Route issues".to_string(),
1380 state,
1381 body: " The body.\n".to_string(),
1382 comments: vec![],
1383 closed_by: vec![],
1384 url: format!("https://github.com/rust-works/omni-dev/issues/{number}"),
1385 }
1386 }
1387
1388 fn opts() -> RouteOptions {
1389 RouteOptions {
1390 model: "jev-latest".to_string(),
1391 close_call: DEFAULT_CLOSE_CALL,
1392 effort_advice: false,
1393 max_input_chars: DEFAULT_MAX_INPUT_CHARS,
1394 allow_closed: false,
1395 ignore_closed: false,
1396 }
1397 }
1398
1399 fn default_tiers() -> Tiers {
1400 Provider::Anthropic.tiers().unwrap()
1401 }
1402
1403 fn class_only(provider: Provider) -> Result<Ladder> {
1405 let mut ladder = Ladder::builtin(provider)?;
1406 for tier in &mut ladder.tiers.0 {
1407 tier.models = None;
1408 }
1409 Ok(ladder)
1410 }
1411
1412 fn anthropic() -> Vec<Ladder> {
1413 vec![class_only(Provider::Anthropic).unwrap()]
1414 }
1415
1416 fn tier_names(tiers: &Tiers) -> Vec<&str> {
1417 tiers.as_slice().iter().map(|t| t.name.as_str()).collect()
1418 }
1419
1420 #[test]
1425 fn anthropic_tiers_are_sonnet_opus_in_order() {
1426 assert_eq!(tier_names(&default_tiers()), ["sonnet", "opus"]);
1427 }
1428
1429 #[test]
1430 fn anthropic_tier_descriptions_are_the_tested_text() {
1431 let tiers = default_tiers();
1432 let opus = &tiers.as_slice()[1];
1433 assert_eq!(
1434 opus.description,
1435 "Reliable at reasoning across many files, subtle library or platform semantics, \
1436 concurrency and ordering, once the overall direction is set. Can under-explore a \
1437 genuinely open design space."
1438 );
1439 }
1440
1441 #[test]
1442 fn provider_names_are_the_documented_abbreviations() {
1443 assert_eq!(
1444 tier_names(&Provider::OpenAi.tiers().unwrap()),
1445 ["terra", "sol", "astra"]
1446 );
1447 assert_eq!(
1448 tier_names(&Provider::Gemini.tiers().unwrap()),
1449 ["flash", "pro", "deep-think"]
1450 );
1451 assert_eq!(
1452 Provider::ALL.map(Provider::name),
1453 ["anthropic", "openai", "gemini"]
1454 );
1455 for provider in Provider::ALL {
1456 assert_eq!(
1457 provider.to_possible_value().unwrap().get_name(),
1458 provider.name()
1459 );
1460 }
1461 }
1462
1463 #[test]
1477 fn openai_and_gemini_ladders_reuse_the_anthropic_descriptions_rung_for_rung() {
1478 let anthropic = default_tiers();
1479 const RETIRED_ANTHROPIC_THIRD_RUNG: &str =
1480 "Strongest at open-ended design and research: choosing between architectures, \
1481 working in unfamiliar territory with no precedent in the codebase, anticipating \
1482 failure modes, and security-critical judgement.";
1483 for provider in [Provider::OpenAi, Provider::Gemini] {
1484 let tiers = provider.tiers().unwrap();
1485 assert_eq!(tiers.as_slice().len(), 3);
1486 for (rung, reference) in tiers.as_slice().iter().zip(anthropic.as_slice()) {
1487 assert_eq!(
1488 rung.description,
1489 reference.description,
1490 "{}: {} differs from {}",
1491 provider.name(),
1493 rung.name,
1494 reference.name
1495 );
1496 }
1497 assert_eq!(
1498 tiers.as_slice()[2].description,
1499 RETIRED_ANTHROPIC_THIRD_RUNG,
1500 "{}: {} no longer matches the retired anthropic fable rung",
1501 provider.name(), tiers.as_slice()[2].name );
1504 }
1505 }
1506
1507 #[test]
1508 fn ladders_are_named_by_provider_or_a_custom_name() {
1509 let ladder = class_only(Provider::Gemini).unwrap();
1510 assert_eq!(ladder.name, "gemini");
1511 let custom = Ladder::named("mine".to_string(), default_tiers());
1512 assert_eq!(custom.name, "mine");
1513 assert_eq!(custom.tiers, default_tiers());
1514 }
1515
1516 #[test]
1517 fn tiers_need_at_least_two() {
1518 let err = Tiers::parse("tiers:\n - {name: a, description: A}\n").unwrap_err();
1519 assert!(err.to_string().contains("at least 2 tiers"), "{err}");
1520 }
1521
1522 #[test]
1523 fn tiers_reject_the_reserved_none() {
1524 let err = Tiers::parse(
1525 "tiers:\n - {name: a, description: A}\n - {name: none, description: B}\n",
1526 )
1527 .unwrap_err();
1528 assert!(err.to_string().contains("reserved"), "{err}");
1529 }
1530
1531 #[test]
1532 fn tiers_reject_duplicate_names() {
1533 let err =
1534 Tiers::parse("tiers:\n - {name: a, description: A}\n - {name: a, description: B}\n")
1535 .unwrap_err();
1536 assert!(err.to_string().contains("more than once"), "{err}");
1537 }
1538
1539 #[test]
1540 fn tiers_reject_empty_descriptions() {
1541 let err =
1542 Tiers::parse("tiers:\n - {name: a, description: A}\n - {name: b, description: ''}\n")
1543 .unwrap_err();
1544 assert!(err.to_string().contains("non-empty"), "{err}");
1545 }
1546
1547 #[test]
1548 fn tiers_reject_unknown_fields() {
1549 let err = Tiers::parse(
1550 "tiers:\n - {name: a, description: A, rank: 1}\n - {name: b, description: B}\n",
1551 )
1552 .unwrap_err();
1553 assert!(format!("{err:#}").contains("unknown field"), "{err:#}");
1554 }
1555
1556 #[test]
1557 fn tiers_load_reads_a_custom_file() {
1558 let dir = tempfile::tempdir().unwrap();
1559 let path = dir.path().join("tiers.yaml");
1560 std::fs::write(
1561 &path,
1562 "tiers:\n - {name: small, description: S}\n - {name: big, description: B}\n",
1563 )
1564 .unwrap();
1565 let tiers = Tiers::load_file(&path).unwrap();
1566 assert_eq!(tiers.as_slice()[1].name, "big");
1567 }
1568
1569 #[test]
1570 fn tiers_load_names_a_missing_file() {
1571 let err = Tiers::load_file(Path::new("/no/such/tiers.yaml")).unwrap_err();
1572 assert!(err.to_string().contains("Failed to read tiers file"));
1573 }
1574
1575 const BAR: &str = " Choose the least capable class likely to complete this stage correctly \
1578 with no rework, about 9 times in 10. Judge the work that remains given \
1579 the text, not the size of the text.";
1580
1581 fn instructions(questions: &BTreeMap<String, Question>, key: &str) -> String {
1582 let Question::Choice { instructions, .. } = &questions[key] else {
1583 panic!("{key} is not a choice");
1584 };
1585 instructions.clone()
1586 }
1587
1588 fn options(questions: &BTreeMap<String, Question>, key: &str) -> Vec<String> {
1589 let Question::Choice { criteria, .. } = &questions[key] else {
1590 panic!("{key} is not a choice");
1591 };
1592 criteria.keys().cloned().collect()
1593 }
1594
1595 #[test]
1598 fn stage_questions_are_the_tested_wording() {
1599 let questions = build_route_questions(&anthropic()).unwrap();
1600 assert_eq!(
1601 instructions(&questions, "anthropic.stage_design"),
1602 format!(
1603 "Which class should do the design work that remains before implementation can \
1604 start: choosing the approach, settling open questions, and writing a plan?{BAR}"
1605 )
1606 );
1607 assert_eq!(
1608 instructions(&questions, "anthropic.stage_implement"),
1609 format!(
1610 "Assume any remaining design work has been completed well. Which class should \
1611 write the code, tests and docs?{BAR}"
1612 )
1613 );
1614 assert_eq!(
1615 instructions(&questions, "anthropic.stage_review"),
1616 format!(
1617 "Which class should review the finished change before merge, so that mistakes \
1618 the automated tests would miss are caught?{BAR}"
1619 )
1620 );
1621 }
1622
1623 #[test]
1624 fn only_the_design_stage_offers_none() {
1625 let questions = build_route_questions(&anthropic()).unwrap();
1626 assert_eq!(
1627 options(&questions, "anthropic.stage_design"),
1628 ["none", "opus", "sonnet"]
1629 );
1630 assert_eq!(
1631 options(&questions, "anthropic.stage_implement"),
1632 ["opus", "sonnet"]
1633 );
1634 assert_eq!(
1635 options(&questions, "anthropic.stage_review"),
1636 ["opus", "sonnet"]
1637 );
1638 let Question::Choice { criteria, .. } = &questions["anthropic.stage_design"] else {
1639 panic!();
1640 };
1641 assert_eq!(
1642 criteria[NO_DESIGN],
1643 "No design work remains: the text already settles the approach and the open \
1644 questions."
1645 );
1646 }
1647
1648 #[test]
1651 fn questions_are_keyed_per_provider_in_one_map() {
1652 let ladders = [
1653 class_only(Provider::Anthropic).unwrap(),
1654 class_only(Provider::OpenAi).unwrap(),
1655 ];
1656 let questions = build_route_questions(&ladders).unwrap();
1657 let keys: Vec<&String> = questions.keys().collect();
1658 assert_eq!(
1659 keys,
1660 [
1661 "anthropic.stage_design",
1662 "anthropic.stage_implement",
1663 "anthropic.stage_review",
1664 "openai.stage_design",
1665 "openai.stage_implement",
1666 "openai.stage_review",
1667 ]
1668 );
1669 assert_eq!(
1670 options(&questions, "openai.stage_design"),
1671 ["astra", "none", "sol", "terra"]
1672 );
1673 assert_eq!(
1674 instructions(&questions, "openai.stage_review"),
1675 instructions(&questions, "anthropic.stage_review")
1676 );
1677 }
1678
1679 #[test]
1680 fn a_named_ladder_is_keyed_by_its_own_name() {
1681 let ladders = [Ladder::named(
1682 "mine".to_string(),
1683 Tiers::parse("tiers:\n - {name: a, description: A}\n - {name: b, description: B}\n")
1684 .unwrap(),
1685 )];
1686 let questions = build_route_questions(&ladders).unwrap();
1687 assert_eq!(options(&questions, "mine.stage_implement"), ["a", "b"]);
1688 }
1689
1690 #[test]
1693 fn route_state_without_comments_is_title_and_trimmed_body() {
1694 let (state, truncated) = build_route_state(&doc(7, ItemState::Open), 1000);
1695 assert_eq!(state, "# #7 Route issues\n\nThe body.\n");
1696 assert!(!truncated);
1697 }
1698
1699 #[test]
1700 fn route_state_lists_comments_in_order_in_the_tested_format() {
1701 let mut d = doc(7, ItemState::Open);
1702 d.comments = vec![
1703 Comment {
1704 author: "alice".to_string(),
1705 body: "first\n".to_string(),
1706 id: None,
1707 },
1708 Comment {
1709 author: "bob".to_string(),
1710 body: "second".to_string(),
1711 id: None,
1712 },
1713 ];
1714 let (state, _) = build_route_state(&d, 1000);
1715 assert_eq!(
1716 state,
1717 "# #7 Route issues\n\nThe body.\n\n\n---\n\n## Comments on this issue\n\
1718 \n\n**Comment by alice:**\n\nfirst\n\
1719 \n\n**Comment by bob:**\n\nsecond\n"
1720 );
1721 }
1722
1723 #[test]
1724 fn route_state_truncates_the_middle_on_a_char_boundary() {
1725 let mut d = doc(7, ItemState::Open);
1726 d.body = "é".repeat(100);
1727 let (full, _) = build_route_state(&d, usize::MAX);
1728 let (state, truncated) = build_route_state(&d, 20);
1729 assert!(truncated);
1730 let (head, tail) = state
1731 .split_once(&format!("\n\n{TRUNCATION_MARKER}\n\n"))
1732 .unwrap();
1733 assert_eq!(head.chars().count(), 10);
1734 assert_eq!(tail.chars().count(), 10);
1735 assert!(full.starts_with(head));
1736 assert!(full.ends_with(tail));
1737 }
1738
1739 #[test]
1741 fn route_state_truncation_keeps_the_latest_comment() {
1742 let mut d = doc(7, ItemState::Open);
1743 d.body = "x".repeat(10_000);
1744 d.comments = vec![Comment {
1745 author: "maintainer".to_string(),
1746 body: "**Decision**: settled.".to_string(),
1747 id: None,
1748 }];
1749 let (state, truncated) = build_route_state(&d, 1_000);
1750 assert!(truncated);
1751 assert!(state.starts_with("# #7 Route issues"));
1752 assert!(state.ends_with("**Decision**: settled.\n"), "{state}");
1753 }
1754
1755 #[test]
1756 fn route_state_truncation_handles_a_one_char_cap() {
1757 let (state, truncated) = build_route_state(&doc(7, ItemState::Open), 1);
1758 assert!(truncated);
1759 assert_eq!(state, format!("\n\n{TRUNCATION_MARKER}\n\n\n"));
1760 }
1761
1762 #[test]
1763 fn route_state_at_exactly_the_cap_is_not_truncated() {
1764 let d = doc(7, ItemState::Open);
1765 let (full, _) = build_route_state(&d, usize::MAX);
1766 let (state, truncated) = build_route_state(&d, full.chars().count());
1767 assert_eq!(state, full);
1768 assert!(!truncated);
1769 }
1770
1771 fn answer(choice: &str, confidence: f64) -> StageAnswer {
1774 StageAnswer {
1775 effort_by_model: BTreeMap::new(),
1776 choice: choice.to_string(),
1777 confidence,
1778 probabilities: BTreeMap::new(),
1779 }
1780 }
1781
1782 fn stages(design: &str, implement: &str, review: &str) -> StageAnswers {
1783 StageAnswers {
1784 design: answer(design, 0.9),
1785 implement: answer(implement, 0.9),
1786 review: answer(review, 0.9),
1787 }
1788 }
1789
1790 #[test]
1791 fn close_call_shows_the_best_alternative_in_compact_text() {
1792 let mut stages = stages("sol", "terra", "sol");
1793 stages.design.confidence = 0.21;
1794 stages.design.probabilities = BTreeMap::from([
1795 ("none".to_string(), 0.44),
1796 ("sol".to_string(), 0.48),
1797 ("terra".to_string(), 0.08),
1798 ]);
1799
1800 assert_eq!(
1801 render_stage_clause(
1802 Stage::Design,
1803 &stages,
1804 &[Stage::Design],
1805 None,
1806 TerminalStyle::default(),
1807 ),
1808 "design needs sol (0.21, close call — none 0.44)"
1809 );
1810 assert_eq!(
1811 render_stage_clause(Stage::Design, &stages, &[], None, TerminalStyle::default(),),
1812 "design needs sol (0.21)"
1813 );
1814 }
1815
1816 #[test]
1817 fn close_call_uses_a_stable_tiebreaker_and_handles_missing_alternatives() {
1818 let mut answer = answer("sol", 0.21);
1819 answer.probabilities = BTreeMap::from([
1820 ("astra".to_string(), 0.4),
1821 ("sol".to_string(), 0.2),
1822 ("terra".to_string(), 0.4),
1823 ]);
1824 assert_eq!(
1825 render_stage_confidence(&answer, true),
1826 "0.21, close call — astra 0.40"
1827 );
1828 answer.probabilities.clear();
1829 assert_eq!(render_stage_confidence(&answer, true), "0.21, close call");
1830 }
1831
1832 #[test]
1833 fn class_is_the_higher_of_design_and_implement() {
1834 let tiers = default_tiers();
1835 assert_eq!(
1836 issue_class(&stages("opus", "sonnet", "sonnet"), &tiers),
1837 "opus"
1838 );
1839 assert_eq!(
1840 issue_class(&stages("sonnet", "opus", "sonnet"), &tiers),
1841 "opus"
1842 );
1843 }
1844
1845 #[test]
1846 fn class_ignores_review_and_none() {
1847 let tiers = default_tiers();
1848 assert_eq!(
1849 issue_class(&stages("none", "sonnet", "opus"), &tiers),
1850 "sonnet"
1851 );
1852 }
1853
1854 #[test]
1855 fn close_calls_are_below_the_threshold_in_stage_order() {
1856 let mut s = stages("opus", "sonnet", "opus");
1857 s.review.confidence = 0.1;
1858 s.design.confidence = 0.29;
1859 s.implement.confidence = 0.3;
1860 assert_eq!(close_calls(&s, 0.3), [Stage::Design, Stage::Review]);
1861 }
1862
1863 #[test]
1864 fn could_be_cheaper_key_is_stable_and_indexed() {
1865 assert_eq!(could_be_cheaper_key(0), "could_be_cheaper_0");
1866 assert_eq!(could_be_cheaper_key(1), "could_be_cheaper_1");
1867 }
1868
1869 #[test]
1870 fn could_be_cheaper_question_is_the_tested_wording() {
1871 let Question::Noul {
1872 instructions,
1873 criteria,
1874 } = could_be_cheaper_question("#1129")
1875 else {
1876 panic!("expected a noul question");
1877 };
1878 assert_eq!(
1879 instructions,
1880 "This issue cites #1129, which is still open. If #1129 is resolved, how likely is \
1881 it that LESS design work would remain for THIS issue than the current text \
1882 implies — as opposed to this issue's own remaining work being unaffected, because \
1883 it is already scoped separately, is a parallel/sibling effort, or #1129 is \
1884 otherwise not a precondition for finishing this issue's own remaining work?"
1885 );
1886 assert!(criteria.is_none());
1887 }
1888
1889 #[test]
1890 fn dependency_entries_reads_the_noul_answer_per_citation() {
1891 let citations = vec![
1892 Citation {
1893 item_ref: ItemRef {
1894 provider: GitProvider::GitHub,
1895 project: "rust-works/omni-dev".to_string(),
1896 kind: ItemKind::Issue,
1897 number: 1129,
1898 },
1899 raw: "#1129".to_string(),
1900 url: None,
1901 },
1902 Citation {
1903 item_ref: ItemRef {
1904 provider: GitProvider::GitHub,
1905 project: "rust-works/omni-dev".to_string(),
1906 kind: ItemKind::Issue,
1907 number: 1349,
1908 },
1909 raw: "#1349".to_string(),
1910 url: None,
1911 },
1912 ];
1913 let answers = BTreeMap::from([(
1914 "could_be_cheaper_0".to_string(),
1915 Answer::Noul { noul: 0.75 },
1916 )]);
1917 let deps = dependency_entries("o/r#1", &citations, &answers);
1918 assert_eq!(deps.len(), 1, "{deps:?}");
1919 assert_eq!(deps[0].item_ref, "#1129");
1920 assert_eq!(deps[0].state, ItemState::Open);
1921 assert_eq!(deps[0].could_be_cheaper.get("design"), Some(&0.75));
1922 }
1923
1924 #[test]
1925 fn stage_answers_reject_none_outside_design() {
1926 let choice = |c: &str| Answer::Choice {
1927 choice: c.to_string(),
1928 confidence: 0.5,
1929 probabilities: BTreeMap::new(),
1930 };
1931 let answers = BTreeMap::from([
1932 ("anthropic.stage_design".to_string(), choice("none")),
1933 ("anthropic.stage_implement".to_string(), choice("none")),
1934 ("anthropic.stage_review".to_string(), choice("opus")),
1935 ]);
1936 let err = stage_answers(&answers, &anthropic()[0]).unwrap_err();
1937 assert!(err.to_string().contains("not offered"), "{err}");
1938 }
1939
1940 #[test]
1941 fn stage_answers_require_a_choice_per_stage() {
1942 let answers = BTreeMap::from([(
1943 "anthropic.stage_design".to_string(),
1944 Answer::Noul { noul: 0.5 },
1945 )]);
1946 let err = stage_answers(&answers, &anthropic()[0]).unwrap_err();
1947 assert!(err.to_string().contains("no choice answer"), "{err}");
1948 assert!(err.to_string().contains("anthropic.stage_design"), "{err}");
1949 }
1950
1951 #[test]
1954 fn stage_answers_are_read_under_the_ladders_prefix() {
1955 let choice = |c: &str| Answer::Choice {
1956 choice: c.to_string(),
1957 confidence: 0.5,
1958 probabilities: BTreeMap::new(),
1959 };
1960 let answers = BTreeMap::from([
1961 ("anthropic.stage_design".to_string(), choice("none")),
1962 ("anthropic.stage_implement".to_string(), choice("sonnet")),
1963 ("anthropic.stage_review".to_string(), choice("opus")),
1964 ]);
1965 let err = stage_answers(&answers, &class_only(Provider::OpenAi).unwrap()).unwrap_err();
1966 assert!(err.to_string().contains("openai.stage_design"), "{err}");
1967 }
1968
1969 #[test]
1972 fn closed_issues_are_refused_by_default() {
1973 let err =
1974 validate_options(&[&doc(1, ItemState::Closed)], &anthropic(), &opts()).unwrap_err();
1975 assert!(err.to_string().contains("rust-works/omni-dev#1"), "{err}");
1976 assert!(err.to_string().contains("--allow-closed"), "{err}");
1977 assert!(err.to_string().contains("--ignore-closed"), "{err}");
1978 }
1979
1980 #[test]
1983 fn allow_closed_and_ignore_closed_are_mutually_exclusive() {
1984 let mut o = opts();
1985 o.allow_closed = true;
1986 o.ignore_closed = true;
1987 for state in [ItemState::Open, ItemState::Closed] {
1988 let err = drop_closed(&[doc(1, state)], &o).unwrap_err();
1989 assert!(err.to_string().contains("cannot be used together"), "{err}");
1990 }
1991 }
1992
1993 #[test]
1996 fn drop_closed_keeps_every_issue_without_the_flag() {
1997 let docs = [doc(1, ItemState::Open), doc(2, ItemState::Closed)];
1998 let (kept, ignored) = drop_closed(&docs, &opts()).unwrap();
1999 assert_eq!(kept.len(), 2);
2000 assert!(ignored.is_empty());
2001 }
2002
2003 #[test]
2004 fn drop_closed_skips_closed_issues_in_request_order() {
2005 let mut o = opts();
2006 o.ignore_closed = true;
2007 let docs = [
2008 doc(3, ItemState::Closed),
2009 doc(1, ItemState::Open),
2010 doc(2, ItemState::Closed),
2011 ];
2012 let (kept, ignored) = drop_closed(&docs, &o).unwrap();
2013 assert_eq!(kept.iter().map(|d| d.number).collect::<Vec<_>>(), [1]);
2014 assert_eq!(
2015 ignored,
2016 [
2017 IgnoredIssue {
2018 item_ref: "rust-works/omni-dev#3".to_string(),
2019 url: "https://github.com/rust-works/omni-dev/issues/3".to_string(),
2020 title: "Route issues".to_string(),
2021 },
2022 IgnoredIssue {
2023 item_ref: "rust-works/omni-dev#2".to_string(),
2024 url: "https://github.com/rust-works/omni-dev/issues/2".to_string(),
2025 title: "Route issues".to_string(),
2026 },
2027 ]
2028 );
2029 }
2030
2031 #[test]
2032 fn drop_closed_refuses_a_batch_with_no_open_issue_left() {
2033 let mut o = opts();
2034 o.ignore_closed = true;
2035 let docs = [doc(1, ItemState::Closed), doc(2, ItemState::Closed)];
2036 let err = drop_closed(&docs, &o).unwrap_err();
2037 let msg = err.to_string();
2038 assert!(
2039 msg.contains("rust-works/omni-dev#1, rust-works/omni-dev#2"),
2040 "{msg}"
2041 );
2042 assert!(msg.contains("nothing to route"), "{msg}");
2043 }
2044
2045 #[test]
2048 fn drop_closed_passes_an_empty_batch_through() {
2049 let mut o = opts();
2050 o.ignore_closed = true;
2051 let (kept, ignored) = drop_closed(&[], &o).unwrap();
2052 assert!(kept.is_empty());
2053 assert!(ignored.is_empty());
2054 }
2055
2056 #[test]
2057 fn closed_issues_are_allowed_on_request() {
2058 let mut o = opts();
2059 o.allow_closed = true;
2060 validate_options(&[&doc(1, ItemState::Closed)], &anthropic(), &o).unwrap();
2061 }
2062
2063 #[test]
2064 fn empty_runs_and_bad_knobs_are_rejected() {
2065 assert!(validate_options(&[], &anthropic(), &opts()).is_err());
2066 let mut o = opts();
2067 o.close_call = 1.5;
2068 assert!(validate_options(&[&doc(1, ItemState::Open)], &anthropic(), &o).is_err());
2069 let mut o = opts();
2070 o.max_input_chars = 0;
2071 assert!(validate_options(&[&doc(1, ItemState::Open)], &anthropic(), &o).is_err());
2072 }
2073
2074 #[test]
2075 fn empty_and_duplicate_providers_are_rejected() {
2076 let err = validate_options(&[&doc(1, ItemState::Open)], &[], &opts()).unwrap_err();
2077 assert!(err.to_string().contains("no ladders"), "{err}");
2078 let twice = [
2079 class_only(Provider::Gemini).unwrap(),
2080 class_only(Provider::Gemini).unwrap(),
2081 ];
2082 let err = validate_options(&[&doc(1, ItemState::Open)], &twice, &opts()).unwrap_err();
2083 assert!(
2084 err.to_string()
2085 .contains("\"gemini\" is listed more than once"),
2086 "{err}"
2087 );
2088 }
2089
2090 fn choice_json(choice: &str, confidence: f64) -> serde_json::Value {
2093 serde_json::json!({
2094 "type": "choice", "choice": choice, "confidence": confidence,
2095 "probabilities": {choice: confidence}
2096 })
2097 }
2098
2099 fn provider<'a>(outcome: &'a RouteOutcome, name: &str) -> &'a ProviderRoute {
2100 let RouteOutcome::Routed { providers, .. } = outcome else {
2101 panic!("expected a routed issue: {outcome:?}");
2103 };
2104 &providers[name]
2105 }
2106
2107 #[tokio::test]
2108 async fn run_route_asks_three_stages_per_issue_and_sums_usage() {
2109 let server = wiremock::MockServer::start().await;
2110 wiremock::Mock::given(wiremock::matchers::method("POST"))
2111 .and(wiremock::matchers::path("/v1/systemone"))
2112 .and(wiremock::matchers::body_partial_json(serde_json::json!({
2113 "model": "jev-latest",
2114 "state": "# #7 Route issues\n\nThe body.\n",
2115 })))
2116 .respond_with(
2117 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2118 "model": "jev-1.13.0",
2119 "answers": {
2120 "anthropic.stage_design": choice_json("opus", 0.52),
2121 "anthropic.stage_implement": choice_json("sonnet", 0.83),
2122 "anthropic.stage_review": choice_json("opus", 0.21),
2123 },
2124 "usage": {"input_tokens": 100, "output_tokens": 10}
2125 })),
2126 )
2127 .expect(2)
2128 .mount(&server)
2129 .await;
2130 let client = JevClient::new(&server.uri(), "key").unwrap();
2131
2132 let report = run_route(
2133 &client,
2134 &[doc(7, ItemState::Open), doc(7, ItemState::Open)],
2135 &anthropic(),
2136 &opts(),
2137 &OpenDependencies::new(),
2138 )
2139 .await
2140 .unwrap();
2141
2142 assert_eq!(report.model, "jev-1.13.0");
2143 assert_eq!(report.usage.input_tokens, 200);
2144 assert_eq!(report.usage.output_tokens, 20);
2145 let issue = &report.issues[0];
2146 assert_eq!(issue.item_ref, "rust-works/omni-dev#7");
2147 let route = provider(&issue.outcome, "anthropic");
2148 assert_eq!(route.class, "opus");
2149 assert_eq!(route.close_calls, [Stage::Review]);
2150 assert!(!issue.truncated);
2151
2152 let requests = server.received_requests().await.unwrap();
2153 let body: serde_json::Value = requests[0].body_json().unwrap();
2154 let keys: Vec<&String> = body["questions"].as_object().unwrap().keys().collect();
2155 assert_eq!(
2156 keys,
2157 [
2158 "anthropic.stage_design",
2159 "anthropic.stage_implement",
2160 "anthropic.stage_review"
2161 ]
2162 );
2163 }
2164
2165 #[tokio::test]
2169 async fn run_route_ignore_closed_routes_only_open_issues() {
2170 let server = wiremock::MockServer::start().await;
2171 wiremock::Mock::given(wiremock::matchers::method("POST"))
2172 .respond_with(
2173 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2174 "model": "jev-1.13.0",
2175 "answers": {
2176 "anthropic.stage_design": choice_json("opus", 0.52),
2177 "anthropic.stage_implement": choice_json("sonnet", 0.83),
2178 "anthropic.stage_review": choice_json("opus", 0.61),
2179 },
2180 "usage": {"input_tokens": 100, "output_tokens": 10}
2181 })),
2182 )
2183 .expect(2)
2184 .mount(&server)
2185 .await;
2186 let client = JevClient::new(&server.uri(), "key").unwrap();
2187 let mut o = opts();
2188 o.ignore_closed = true;
2189
2190 let report = run_route(
2191 &client,
2192 &[
2193 doc(7, ItemState::Open),
2194 doc(8, ItemState::Closed),
2195 doc(9, ItemState::Open),
2196 ],
2197 &anthropic(),
2198 &o,
2199 &OpenDependencies::new(),
2200 )
2201 .await
2202 .unwrap();
2203
2204 let refs: Vec<&str> = report.issues.iter().map(|i| i.item_ref.as_str()).collect();
2205 assert_eq!(refs, ["rust-works/omni-dev#7", "rust-works/omni-dev#9"]);
2206 let json = serde_json::to_value(&report).unwrap();
2207 assert_eq!(
2208 json["ignored_closed"],
2209 serde_json::json!([{
2210 "ref": "rust-works/omni-dev#8",
2211 "url": "https://github.com/rust-works/omni-dev/issues/8",
2212 "title": "Route issues",
2213 }])
2214 );
2215
2216 let err = run_route(
2217 &client,
2218 &[doc(8, ItemState::Closed)],
2219 &anthropic(),
2220 &o,
2221 &OpenDependencies::new(),
2222 )
2223 .await
2224 .unwrap_err();
2225 assert!(err.to_string().contains("nothing to route"), "{err}");
2226 }
2227
2228 #[test]
2231 fn route_report_omits_an_empty_ignored_closed() {
2232 let report = RouteReport {
2233 model: "m".to_string(),
2234 issues: vec![],
2235 ignored_closed: vec![],
2236 usage: Usage::default(),
2237 };
2238 let json = serde_json::to_value(&report).unwrap();
2239 assert!(json.get("ignored_closed").is_none(), "{json}");
2240 }
2241
2242 #[tokio::test]
2245 async fn run_route_routes_every_provider_in_one_call() {
2246 let server = wiremock::MockServer::start().await;
2247 wiremock::Mock::given(wiremock::matchers::method("POST"))
2248 .respond_with(
2249 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2250 "model": "jev-1.13.0",
2251 "answers": {
2252 "anthropic.stage_design": choice_json("opus", 0.52),
2253 "anthropic.stage_implement": choice_json("sonnet", 0.83),
2254 "anthropic.stage_review": choice_json("opus", 0.9),
2255 "openai.stage_design": choice_json("none", 0.9),
2256 "openai.stage_implement": choice_json("sol", 0.2),
2257 "openai.stage_review": choice_json("astra", 0.9),
2258 "could_be_cheaper_0": {"type": "noul", "noul": 0.4},
2259 },
2260 "usage": {"input_tokens": 10, "output_tokens": 1}
2261 })),
2262 )
2263 .expect(1)
2264 .mount(&server)
2265 .await;
2266 let client = JevClient::new(&server.uri(), "key").unwrap();
2267 let ladders = [
2268 class_only(Provider::Anthropic).unwrap(),
2269 class_only(Provider::OpenAi).unwrap(),
2270 ];
2271 let dependencies = OpenDependencies::from([(
2272 ("rust-works/omni-dev".to_string(), 7),
2273 vec![citation("#1129", 1129)],
2274 )]);
2275 let reference_fetch_failures = ReferenceFetchFailures::from([(
2276 ("rust-works/omni-dev".to_string(), 7),
2277 vec![ReferenceFetchFailure {
2278 item_ref: "#404".to_string(),
2279 error: "not found".to_string(),
2280 }],
2281 )]);
2282
2283 let report = run_route_with_reference_fetch_failures(
2284 &client,
2285 &[doc(7, ItemState::Open)],
2286 &ladders,
2287 &opts(),
2288 &dependencies,
2289 &reference_fetch_failures,
2290 )
2291 .await
2292 .unwrap();
2293
2294 let outcome = &report.issues[0].outcome;
2295 let RouteOutcome::Routed {
2296 providers,
2297 depends_on,
2298 reference_fetch_failures,
2299 ..
2300 } = outcome
2301 else {
2302 panic!("expected a routed issue: {outcome:?}");
2304 };
2305 assert_eq!(
2306 providers.keys().collect::<Vec<_>>(),
2307 ["anthropic", "openai"]
2308 );
2309 assert_eq!(provider(outcome, "anthropic").class, "opus");
2310 assert!(provider(outcome, "anthropic").close_calls.is_empty());
2311 assert_eq!(provider(outcome, "openai").class, "sol");
2312 assert_eq!(provider(outcome, "openai").close_calls, [Stage::Implement]);
2313 assert_eq!(depends_on.len(), 1);
2314 assert_eq!(reference_fetch_failures[0].item_ref, "#404");
2315
2316 let requests = server.received_requests().await.unwrap();
2317 let body: serde_json::Value = requests[0].body_json().unwrap();
2318 let keys: Vec<&String> = body["questions"].as_object().unwrap().keys().collect();
2319 assert_eq!(
2320 keys,
2321 [
2322 "anthropic.stage_design",
2323 "anthropic.stage_implement",
2324 "anthropic.stage_review",
2325 "could_be_cheaper_0",
2326 "openai.stage_design",
2327 "openai.stage_implement",
2328 "openai.stage_review",
2329 ]
2330 );
2331 }
2332
2333 #[tokio::test]
2336 async fn run_route_fails_the_issue_when_one_provider_is_unanswered() {
2337 let server = wiremock::MockServer::start().await;
2338 wiremock::Mock::given(wiremock::matchers::method("POST"))
2339 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(routed_json()))
2340 .mount(&server)
2341 .await;
2342 let client = JevClient::new(&server.uri(), "key").unwrap();
2343 let ladders = [
2344 class_only(Provider::Anthropic).unwrap(),
2345 class_only(Provider::Gemini).unwrap(),
2346 ];
2347 let report = run_route(
2348 &client,
2349 &[doc(7, ItemState::Open)],
2350 &ladders,
2351 &opts(),
2352 &OpenDependencies::new(),
2353 )
2354 .await
2355 .unwrap();
2356 let RouteOutcome::Failed { error, .. } = &report.issues[0].outcome else {
2357 panic!("expected a failed issue: {:?}", report.issues[0]);
2359 };
2360 assert!(error.contains("gemini.stage_design"), "{error}");
2361 }
2362
2363 fn routed_json() -> serde_json::Value {
2364 serde_json::json!({
2365 "model": "jev-1.13.0",
2366 "answers": {
2367 "anthropic.stage_design": choice_json("none", 0.9),
2368 "anthropic.stage_implement": choice_json("sonnet", 0.9),
2369 "anthropic.stage_review": choice_json("opus", 0.9),
2370 },
2371 "usage": {"input_tokens": 5, "output_tokens": 1}
2372 })
2373 }
2374
2375 #[tokio::test]
2377 async fn run_route_keeps_going_past_a_failed_issue() {
2378 let server = wiremock::MockServer::start().await;
2379 wiremock::Mock::given(wiremock::matchers::method("POST"))
2380 .and(wiremock::matchers::body_partial_json(
2381 serde_json::json!({"state": "# #1 Route issues\n\nThe body.\n"}),
2382 ))
2383 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2384 .mount(&server)
2385 .await;
2386 wiremock::Mock::given(wiremock::matchers::method("POST"))
2387 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(routed_json()))
2388 .mount(&server)
2389 .await;
2390 let client = JevClient::new(&server.uri(), "key").unwrap();
2391 let reference_fetch_failures = ReferenceFetchFailures::from([(
2392 ("rust-works/omni-dev".to_string(), 1),
2393 vec![ReferenceFetchFailure {
2394 item_ref: "#404".to_string(),
2395 error: "not found".to_string(),
2396 }],
2397 )]);
2398 let report = run_route_with_reference_fetch_failures(
2399 &client,
2400 &[doc(1, ItemState::Open), doc(2, ItemState::Open)],
2401 &anthropic(),
2402 &opts(),
2403 &OpenDependencies::new(),
2404 &reference_fetch_failures,
2405 )
2406 .await
2407 .unwrap();
2408
2409 assert!(report.issues[0].failed());
2410 let RouteOutcome::Failed {
2411 error,
2412 reference_fetch_failures,
2413 } = &report.issues[0].outcome
2414 else {
2415 panic!();
2416 };
2417 assert!(error.contains("500"), "{error}");
2418 assert_eq!(reference_fetch_failures[0].item_ref, "#404");
2419 let json = serde_json::to_value(&report).unwrap();
2420 assert_eq!(
2421 json["issues"][0]["reference_fetch_failures"][0]["ref"],
2422 "#404"
2423 );
2424 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2425 assert!(
2426 text.contains(" reference fetch failed: #404 (not found)"),
2427 "{text}"
2428 );
2429 assert!(!report.issues[1].failed());
2430 assert_eq!(report.usage.input_tokens, 5);
2431 }
2432
2433 #[tokio::test]
2434 async fn run_route_stops_on_an_auth_failure() {
2435 let server = wiremock::MockServer::start().await;
2436 wiremock::Mock::given(wiremock::matchers::method("POST"))
2437 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("bad key"))
2438 .expect(1)
2439 .mount(&server)
2440 .await;
2441 let client = JevClient::new(&server.uri(), "key").unwrap();
2442 let err = run_route(
2443 &client,
2444 &[doc(3, ItemState::Open), doc(4, ItemState::Open)],
2445 &anthropic(),
2446 &opts(),
2447 &OpenDependencies::new(),
2448 )
2449 .await
2450 .unwrap_err();
2451 assert!(format!("{err:#}").contains("Failed to route issue rust-works/omni-dev#3"));
2452 }
2453
2454 #[tokio::test]
2455 async fn run_route_names_the_issue_on_an_unexpected_answer() {
2456 let server = wiremock::MockServer::start().await;
2457 wiremock::Mock::given(wiremock::matchers::method("POST"))
2458 .respond_with(
2459 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2460 "model": "jev-1.13.0",
2461 "answers": {
2462 "anthropic.stage_design": choice_json("haiku", 0.9),
2463 "anthropic.stage_implement": choice_json("sonnet", 0.9),
2464 "anthropic.stage_review": choice_json("opus", 0.9),
2465 },
2466 })),
2467 )
2468 .mount(&server)
2469 .await;
2470 let client = JevClient::new(&server.uri(), "key").unwrap();
2471 let report = run_route(
2472 &client,
2473 &[doc(9, ItemState::Open)],
2474 &anthropic(),
2475 &opts(),
2476 &OpenDependencies::new(),
2477 )
2478 .await
2479 .unwrap();
2480 let RouteOutcome::Failed { error, .. } = &report.issues[0].outcome else {
2481 panic!("expected a failed issue");
2482 };
2483 assert!(error.contains("Unexpected Jev answer"), "{error}");
2484 assert!(error.contains("not offered"), "{error}");
2485 }
2486
2487 fn citation(raw: &str, number: u64) -> Citation {
2488 Citation {
2489 item_ref: ItemRef {
2490 provider: GitProvider::GitHub,
2491 project: "rust-works/omni-dev".to_string(),
2492 kind: ItemKind::Issue,
2493 number,
2494 },
2495 raw: raw.to_string(),
2496 url: None,
2497 }
2498 }
2499
2500 #[tokio::test]
2501 async fn run_route_asks_could_be_cheaper_for_each_open_citation() {
2502 let server = wiremock::MockServer::start().await;
2503 wiremock::Mock::given(wiremock::matchers::method("POST"))
2504 .respond_with(
2505 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2506 "model": "jev-1.13.0",
2507 "answers": {
2508 "anthropic.stage_design": choice_json("opus", 0.2),
2509 "anthropic.stage_implement": choice_json("sonnet", 0.61),
2510 "anthropic.stage_review": choice_json("opus", 0.9),
2511 "could_be_cheaper_0": {"type": "noul", "noul": 0.75},
2512 },
2513 "usage": {"input_tokens": 10, "output_tokens": 1}
2514 })),
2515 )
2516 .mount(&server)
2517 .await;
2518 let client = JevClient::new(&server.uri(), "key").unwrap();
2519 let dependencies = OpenDependencies::from([(
2520 ("rust-works/omni-dev".to_string(), 7),
2521 vec![citation("#1129", 1129)],
2522 )]);
2523
2524 let report = run_route(
2525 &client,
2526 &[doc(7, ItemState::Open)],
2527 &anthropic(),
2528 &opts(),
2529 &dependencies,
2530 )
2531 .await
2532 .unwrap();
2533
2534 let RouteOutcome::Routed { depends_on, .. } = &report.issues[0].outcome else {
2535 panic!("expected a routed issue: {:?}", report.issues[0]);
2536 };
2537 assert_eq!(depends_on.len(), 1);
2538 assert_eq!(depends_on[0].item_ref, "#1129");
2539 assert_eq!(depends_on[0].state, ItemState::Open);
2540 assert_eq!(depends_on[0].could_be_cheaper.get("design"), Some(&0.75));
2541
2542 let requests = server.received_requests().await.unwrap();
2543 let body: serde_json::Value = requests[0].body_json().unwrap();
2544 let keys: Vec<&String> = body["questions"].as_object().unwrap().keys().collect();
2545 assert_eq!(
2546 keys,
2547 [
2548 "anthropic.stage_design",
2549 "anthropic.stage_implement",
2550 "anthropic.stage_review",
2551 "could_be_cheaper_0",
2552 ]
2553 );
2554 }
2555
2556 #[tokio::test]
2557 async fn run_route_drops_a_dependency_with_no_could_be_cheaper_answer() {
2558 let server = wiremock::MockServer::start().await;
2559 wiremock::Mock::given(wiremock::matchers::method("POST"))
2560 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(routed_json()))
2561 .mount(&server)
2562 .await;
2563 let client = JevClient::new(&server.uri(), "key").unwrap();
2564 let dependencies = OpenDependencies::from([(
2565 ("rust-works/omni-dev".to_string(), 7),
2566 vec![citation("#1129", 1129)],
2567 )]);
2568
2569 let report = run_route(
2570 &client,
2571 &[doc(7, ItemState::Open)],
2572 &anthropic(),
2573 &opts(),
2574 &dependencies,
2575 )
2576 .await
2577 .unwrap();
2578
2579 let RouteOutcome::Routed { depends_on, .. } = &report.issues[0].outcome else {
2582 panic!("expected a routed issue: {:?}", report.issues[0]);
2583 };
2584 assert!(depends_on.is_empty(), "{depends_on:?}");
2585 }
2586
2587 #[test]
2588 fn report_serialises_ref_and_omits_false_truncated() {
2589 let report = RouteReport {
2590 model: "jev-1.13.0".to_string(),
2591 issues: vec![IssueRoute {
2592 item_ref: "o/r#1".to_string(),
2593 url: "u".to_string(),
2594 title: "t".to_string(),
2595 state: ItemState::Open,
2596 outcome: RouteOutcome::Routed {
2597 providers: BTreeMap::from([(
2598 "anthropic".to_string(),
2599 ProviderRoute {
2600 stages: stages("none", "sonnet", "opus"),
2601 class: "sonnet".to_string(),
2602 close_calls: vec![],
2603 },
2604 )]),
2605 depends_on: vec![],
2606 reference_fetch_failures: vec![],
2607 },
2608 truncated: false,
2609 }],
2610 ignored_closed: vec![],
2611 usage: Usage::default(),
2612 };
2613 let value = serde_json::to_value(&report).unwrap();
2614 assert_eq!(value["issues"][0]["ref"], "o/r#1");
2615 assert!(value["issues"][0].get("truncated").is_none());
2616 let anthropic = &value["issues"][0]["providers"]["anthropic"];
2617 assert_eq!(anthropic["stages"]["design"]["choice"], "none");
2618 assert_eq!(anthropic["class"], "sonnet");
2619 assert_eq!(anthropic["close_calls"], serde_json::json!([]));
2620 assert!(value["issues"][0].get("stages").is_none());
2621 assert!(value["issues"][0].get("class").is_none());
2622 assert_eq!(value["issues"][0]["depends_on"], serde_json::json!([]));
2623 assert!(value["issues"][0].get("reference_fetch_failures").is_none());
2624 assert!(value["issues"][0].get("error").is_none());
2625 }
2626
2627 #[test]
2628 fn a_failed_issue_serialises_only_its_error() {
2629 let route = IssueRoute {
2630 item_ref: "o/r#1".to_string(),
2631 url: "u".to_string(),
2632 title: "t".to_string(),
2633 state: ItemState::Open,
2634 outcome: RouteOutcome::Failed {
2635 error: "HTTP 529".to_string(),
2636 reference_fetch_failures: vec![],
2637 },
2638 truncated: false,
2639 };
2640 let value = serde_json::to_value(&route).unwrap();
2641 assert_eq!(value["error"], "HTTP 529");
2642 assert!(value.get("providers").is_none());
2643 assert!(value.get("depends_on").is_none());
2644 }
2645
2646 #[test]
2649 fn render_route_text_matches_the_worked_example() {
2650 let report = RouteReport {
2651 model: "jev-1.13.0".to_string(),
2652 issues: vec![IssueRoute {
2653 item_ref: "rust-works/omni-dev#1641".to_string(),
2654 url: "u".to_string(),
2655 title: "Some issue title".to_string(),
2656 state: ItemState::Open,
2657 outcome: RouteOutcome::Routed {
2658 providers: BTreeMap::from([(
2659 "anthropic".to_string(),
2660 ProviderRoute {
2661 stages: StageAnswers {
2662 design: answer("opus", 0.52),
2663 implement: answer("sonnet", 0.83),
2664 review: answer("opus", 0.41),
2665 },
2666 class: "opus".to_string(),
2667 close_calls: vec![Stage::Review],
2668 },
2669 )]),
2670 depends_on: vec![DependencyEntry {
2671 item_ref: "#1129".to_string(),
2672 url: None,
2673 state: ItemState::Open,
2674 could_be_cheaper: BTreeMap::from([("design".to_string(), 0.75)]),
2675 }],
2676 reference_fetch_failures: vec![],
2677 },
2678 truncated: false,
2679 }],
2680 ignored_closed: vec![],
2681 usage: Usage {
2682 input_tokens: 1432,
2683 output_tokens: 61,
2684 },
2685 };
2686 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2687 assert_eq!(
2688 text,
2689 "rust-works/omni-dev#1641 — Some issue title\n\
2690 \x20\x20opus — design needs opus (0.52), implementation sonnet (0.83), review \
2691 opus (0.41, close call)\n\
2692 \x20\x20cites open #1129, which could leave less design work if resolved (0.75)\n\n\
2693 model: jev-1.13.0, usage: 1432 input tokens, 61 output tokens\n"
2694 );
2695 }
2696
2697 #[test]
2698 fn render_route_text_puts_each_open_citation_on_its_own_line() {
2699 let report = RouteReport {
2700 model: "jev-1.13.0".to_string(),
2701 issues: vec![IssueRoute {
2702 item_ref: "rust-works/omni-dev#1845".to_string(),
2703 url: "u".to_string(),
2704 title: "t".to_string(),
2705 state: ItemState::Open,
2706 outcome: RouteOutcome::Routed {
2707 providers: BTreeMap::from([(
2708 "anthropic".to_string(),
2709 ProviderRoute {
2710 stages: stages("none", "sonnet", "sonnet"),
2711 class: "sonnet".to_string(),
2712 close_calls: vec![],
2713 },
2714 )]),
2715 depends_on: vec![
2716 DependencyEntry {
2717 item_ref: "#1830".to_string(),
2718 url: None,
2719 state: ItemState::Open,
2720 could_be_cheaper: BTreeMap::from([("design".to_string(), 0.46)]),
2721 },
2722 DependencyEntry {
2723 item_ref: "#1831".to_string(),
2724 url: None,
2725 state: ItemState::Open,
2726 could_be_cheaper: BTreeMap::new(),
2727 },
2728 ],
2729 reference_fetch_failures: vec![],
2730 },
2731 truncated: false,
2732 }],
2733 ignored_closed: vec![],
2734 usage: Usage::default(),
2735 };
2736 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2737 assert!(
2738 text.contains(
2739 " cites open #1830, which could leave less design work if resolved (0.46)\n \
2740 cites open #1831\n"
2741 ),
2742 "{text}"
2743 );
2744 assert!(!text.contains("; "), "{text}");
2745 }
2746
2747 #[test]
2748 fn render_route_text_names_each_provider_when_there_are_several() {
2749 let route = |class: &str| ProviderRoute {
2750 stages: stages("none", "sonnet", "sonnet"),
2751 class: class.to_string(),
2752 close_calls: vec![],
2753 };
2754 let report = RouteReport {
2755 model: "jev-1.13.0".to_string(),
2756 issues: vec![IssueRoute {
2757 item_ref: "o/r#1".to_string(),
2758 url: "u".to_string(),
2759 title: "t".to_string(),
2760 state: ItemState::Open,
2761 outcome: RouteOutcome::Routed {
2762 providers: BTreeMap::from([
2763 ("anthropic".to_string(), route("sonnet")),
2764 ("openai".to_string(), route("terra")),
2765 ]),
2766 depends_on: vec![],
2767 reference_fetch_failures: vec![],
2768 },
2769 truncated: false,
2770 }],
2771 ignored_closed: vec![],
2772 usage: Usage::default(),
2773 };
2774 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2775 assert!(text.contains(" anthropic: sonnet —"), "{text}");
2776 assert!(text.contains(" openai: terra —"), "{text}");
2777 }
2778
2779 #[test]
2780 fn render_route_text_reports_a_failed_issue() {
2781 let report = RouteReport {
2782 model: "jev-1.13.0".to_string(),
2783 issues: vec![IssueRoute {
2784 item_ref: "o/r#1".to_string(),
2785 url: "u".to_string(),
2786 title: "t".to_string(),
2787 state: ItemState::Open,
2788 outcome: RouteOutcome::Failed {
2789 error: "HTTP 529".to_string(),
2790 reference_fetch_failures: vec![],
2791 },
2792 truncated: false,
2793 }],
2794 ignored_closed: vec![],
2795 usage: Usage::default(),
2796 };
2797 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2798 assert!(text.contains("o/r#1 — t\n failed: HTTP 529"), "{text}");
2799 }
2800
2801 #[test]
2802 fn render_route_text_marks_a_closed_issue_in_the_header() {
2803 let report = RouteReport {
2804 model: "jev-1.13.0".to_string(),
2805 issues: vec![IssueRoute {
2806 item_ref: "o/r#1".to_string(),
2807 url: "u".to_string(),
2808 title: "t".to_string(),
2809 state: ItemState::Closed,
2810 outcome: RouteOutcome::Failed {
2811 error: "HTTP 529".to_string(),
2812 reference_fetch_failures: vec![],
2813 },
2814 truncated: false,
2815 }],
2816 ignored_closed: vec![],
2817 usage: Usage::default(),
2818 };
2819 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2820 assert!(text.starts_with("o/r#1 — t (closed)\n"), "{text}");
2821 assert!(!text.contains("ignored closed"), "{text}");
2822 }
2823
2824 #[test]
2825 fn render_route_text_lists_ignored_closed_issues_before_the_footer() {
2826 let ignored = |n: u64| IgnoredIssue {
2827 item_ref: format!("o/r#{n}"),
2828 url: format!("https://github.com/o/r/issues/{n}"),
2829 title: "t".to_string(),
2830 };
2831 let report = RouteReport {
2832 model: "jev-1.13.0".to_string(),
2833 issues: vec![],
2834 ignored_closed: vec![ignored(2), ignored(3)],
2835 usage: Usage::default(),
2836 };
2837 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2838 assert!(
2839 text.contains("ignored closed: o/r#2, o/r#3\n\nmodel: jev-1.13.0"),
2840 "{text}"
2841 );
2842 let styled = render_route_text_styled(
2843 &report,
2844 DEFAULT_MAX_INPUT_CHARS,
2845 &[],
2846 TerminalStyle {
2847 color: false,
2848 hyperlinks: true,
2849 },
2850 );
2851 assert!(
2852 styled.contains("https://github.com/o/r/issues/2"),
2853 "{styled}"
2854 );
2855 }
2856
2857 #[test]
2858 fn render_route_text_omits_the_depends_on_sentence_when_empty() {
2859 let report = RouteReport {
2860 model: "jev-1.13.0".to_string(),
2861 issues: vec![IssueRoute {
2862 item_ref: "o/r#1".to_string(),
2863 url: "u".to_string(),
2864 title: "t".to_string(),
2865 state: ItemState::Open,
2866 outcome: RouteOutcome::Routed {
2867 providers: BTreeMap::from([(
2868 "anthropic".to_string(),
2869 ProviderRoute {
2870 stages: stages("none", "sonnet", "sonnet"),
2871 class: "sonnet".to_string(),
2872 close_calls: vec![],
2873 },
2874 )]),
2875 depends_on: vec![],
2876 reference_fetch_failures: vec![],
2877 },
2878 truncated: false,
2879 }],
2880 ignored_closed: vec![],
2881 usage: Usage::default(),
2882 };
2883 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2884 assert!(!text.contains("cites"), "{text}");
2885 }
2886
2887 #[test]
2891 fn render_route_text_cites_a_dependency_with_no_could_be_cheaper_probability() {
2892 let report = RouteReport {
2893 model: "jev-1.13.0".to_string(),
2894 issues: vec![IssueRoute {
2895 item_ref: "o/r#1".to_string(),
2896 url: "u".to_string(),
2897 title: "t".to_string(),
2898 state: ItemState::Open,
2899 outcome: RouteOutcome::Routed {
2900 providers: BTreeMap::from([(
2901 "anthropic".to_string(),
2902 ProviderRoute {
2903 stages: stages("none", "sonnet", "sonnet"),
2904 class: "sonnet".to_string(),
2905 close_calls: vec![],
2906 },
2907 )]),
2908 depends_on: vec![DependencyEntry {
2909 item_ref: "#1129".to_string(),
2910 url: None,
2911 state: ItemState::Open,
2912 could_be_cheaper: BTreeMap::new(),
2913 }],
2914 reference_fetch_failures: vec![],
2915 },
2916 truncated: false,
2917 }],
2918 ignored_closed: vec![],
2919 usage: Usage::default(),
2920 };
2921 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2922 assert!(text.contains("cites open #1129"), "{text}");
2923 assert!(!text.contains("could leave less design work"), "{text}");
2924 }
2925
2926 #[test]
2927 fn render_route_text_notes_a_truncated_issue_and_omits_the_note_otherwise() {
2928 let issue = |truncated: bool| IssueRoute {
2929 item_ref: "o/r#1".to_string(),
2930 url: "u".to_string(),
2931 title: "t".to_string(),
2932 state: ItemState::Open,
2933 outcome: RouteOutcome::Routed {
2934 providers: BTreeMap::from([(
2935 "anthropic".to_string(),
2936 ProviderRoute {
2937 stages: stages("none", "sonnet", "sonnet"),
2938 class: "sonnet".to_string(),
2939 close_calls: vec![],
2940 },
2941 )]),
2942 depends_on: vec![],
2943 reference_fetch_failures: vec![],
2944 },
2945 truncated,
2946 };
2947 let report = |truncated| RouteReport {
2948 model: "jev-1.13.0".to_string(),
2949 issues: vec![issue(truncated)],
2950 ignored_closed: vec![],
2951 usage: Usage::default(),
2952 };
2953 let truncated_text = render_route_text(&report(true), 60_000);
2954 assert!(
2955 truncated_text.contains(" input truncated at 60,000 characters"),
2956 "{truncated_text}"
2957 );
2958 let kept_text = render_route_text(&report(false), 60_000);
2959 assert!(!kept_text.contains("truncated"), "{kept_text}");
2960 }
2961
2962 #[test]
2963 fn render_route_text_notes_no_further_design_work() {
2964 let report = RouteReport {
2965 model: "jev-1.13.0".to_string(),
2966 issues: vec![IssueRoute {
2967 item_ref: "o/r#1".to_string(),
2968 url: "u".to_string(),
2969 title: "t".to_string(),
2970 state: ItemState::Open,
2971 outcome: RouteOutcome::Routed {
2972 providers: BTreeMap::from([(
2973 "anthropic".to_string(),
2974 ProviderRoute {
2975 stages: stages("none", "sonnet", "sonnet"),
2976 class: "sonnet".to_string(),
2977 close_calls: vec![],
2978 },
2979 )]),
2980 depends_on: vec![],
2981 reference_fetch_failures: vec![],
2982 },
2983 truncated: false,
2984 }],
2985 ignored_closed: vec![],
2986 usage: Usage::default(),
2987 };
2988 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2989 assert!(text.contains("design needs no further work"), "{text}");
2990 }
2991
2992 #[test]
2996 fn render_route_text_switches_to_one_line_per_stage_for_multi_model_tiers() {
2997 let multi = "global.anthropic.claude-sonnet-4-6,global.anthropic.claude-sonnet-5";
2998 let report = RouteReport {
2999 model: "jev-1.13.0".to_string(),
3000 issues: vec![IssueRoute {
3001 item_ref: "rust-works/omni-dev#1832".to_string(),
3002 url: "u".to_string(),
3003 title: "feat(drive): banded ranges for drive sheets (#1830)".to_string(),
3004 state: ItemState::Open,
3005 outcome: RouteOutcome::Routed {
3006 providers: BTreeMap::from([(
3007 "anthropic".to_string(),
3008 ProviderRoute {
3009 stages: StageAnswers {
3010 design: answer(NO_DESIGN, 0.94),
3011 implement: answer(multi, 0.94),
3012 review: answer(multi, 0.70),
3013 },
3014 class: multi.to_string(),
3015 close_calls: vec![],
3016 },
3017 )]),
3018 depends_on: vec![DependencyEntry {
3019 item_ref: "#1830".to_string(),
3020 url: None,
3021 state: ItemState::Open,
3022 could_be_cheaper: BTreeMap::from([("design".to_string(), 0.48)]),
3023 }],
3024 reference_fetch_failures: vec![],
3025 },
3026 truncated: false,
3027 }],
3028 ignored_closed: vec![],
3029 usage: Usage::default(),
3030 };
3031 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3032 assert_eq!(
3033 text,
3034 format!(
3035 "rust-works/omni-dev#1832 — feat(drive): banded ranges for drive sheets (#1830)\n\
3036 \x20\x20class: {multi}\n\
3037 \x20\x20design: needs no further work (0.94)\n\
3038 \x20\x20implementation: {multi} (0.94)\n\
3039 \x20\x20review: {multi} (0.70)\n\
3040 \x20\x20cites open #1830, which could leave less design work if resolved (0.48)\n\n\
3041 model: jev-1.13.0, usage: 0 input tokens, 0 output tokens\n"
3042 )
3043 );
3044 }
3045
3046 #[test]
3049 fn render_route_text_keeps_the_compact_line_for_single_word_tiers() {
3050 let report = RouteReport {
3051 model: "jev-1.13.0".to_string(),
3052 issues: vec![IssueRoute {
3053 item_ref: "o/r#1".to_string(),
3054 url: "u".to_string(),
3055 title: "t".to_string(),
3056 state: ItemState::Open,
3057 outcome: RouteOutcome::Routed {
3058 providers: BTreeMap::from([(
3059 "anthropic".to_string(),
3060 ProviderRoute {
3061 stages: stages("opus", "sonnet", "opus"),
3062 class: "opus".to_string(),
3063 close_calls: vec![],
3064 },
3065 )]),
3066 depends_on: vec![],
3067 reference_fetch_failures: vec![],
3068 },
3069 truncated: false,
3070 }],
3071 ignored_closed: vec![],
3072 usage: Usage::default(),
3073 };
3074 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3075 assert!(
3076 text.contains(
3077 " opus — design needs opus (0.90), implementation sonnet (0.90), review \
3078 opus (0.90)"
3079 ),
3080 "{text}"
3081 );
3082 assert!(!text.contains("class:"), "{text}");
3083 }
3084
3085 #[test]
3088 fn render_route_text_headers_the_block_by_provider_when_multi_model_and_multiple_ladders() {
3089 let route = |class: &str| ProviderRoute {
3090 stages: StageAnswers {
3091 design: answer(NO_DESIGN, 0.9),
3092 implement: answer(class, 0.9),
3093 review: answer(class, 0.9),
3094 },
3095 class: class.to_string(),
3096 close_calls: vec![],
3097 };
3098 let report = RouteReport {
3099 model: "jev-1.13.0".to_string(),
3100 issues: vec![IssueRoute {
3101 item_ref: "o/r#1".to_string(),
3102 url: "u".to_string(),
3103 title: "t".to_string(),
3104 state: ItemState::Open,
3105 outcome: RouteOutcome::Routed {
3106 providers: BTreeMap::from([
3107 ("anthropic".to_string(), route("a,b")),
3108 ("openai".to_string(), route("c,d")),
3109 ]),
3110 depends_on: vec![],
3111 reference_fetch_failures: vec![],
3112 },
3113 truncated: false,
3114 }],
3115 ignored_closed: vec![],
3116 usage: Usage::default(),
3117 };
3118 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3119 assert!(text.contains(" anthropic:\n class: a,b\n"), "{text}");
3120 assert!(text.contains(" implementation: a,b (0.90)"), "{text}");
3121 assert!(text.contains(" openai:\n class: c,d\n"), "{text}");
3122 assert!(text.contains(" implementation: c,d (0.90)"), "{text}");
3123 }
3124
3125 #[test]
3129 fn render_route_text_multi_model_layout_is_decided_per_ladder() {
3130 let multi_route = ProviderRoute {
3131 stages: StageAnswers {
3132 design: answer(NO_DESIGN, 0.9),
3133 implement: answer("a,b", 0.9),
3134 review: answer("a,b", 0.9),
3135 },
3136 class: "a,b".to_string(),
3137 close_calls: vec![],
3138 };
3139 let compact_route = ProviderRoute {
3140 stages: stages("none", "terra", "terra"),
3141 class: "terra".to_string(),
3142 close_calls: vec![],
3143 };
3144 let report = RouteReport {
3145 model: "jev-1.13.0".to_string(),
3146 issues: vec![IssueRoute {
3147 item_ref: "o/r#1".to_string(),
3148 url: "u".to_string(),
3149 title: "t".to_string(),
3150 state: ItemState::Open,
3151 outcome: RouteOutcome::Routed {
3152 providers: BTreeMap::from([
3153 ("anthropic".to_string(), multi_route),
3154 ("openai".to_string(), compact_route),
3155 ]),
3156 depends_on: vec![],
3157 reference_fetch_failures: vec![],
3158 },
3159 truncated: false,
3160 }],
3161 ignored_closed: vec![],
3162 usage: Usage::default(),
3163 };
3164 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3165 assert!(text.contains(" anthropic:\n class: a,b\n"), "{text}");
3166 assert!(
3167 text.contains(" openai: terra — design needs no further work"),
3168 "{text}"
3169 );
3170 }
3171
3172 #[test]
3174 fn render_route_text_notes_a_close_call_in_the_multi_line_layout() {
3175 let mut review = answer("a,b", 0.41);
3176 review.probabilities =
3177 BTreeMap::from([("a,b".to_string(), 0.56), ("c,d".to_string(), 0.44)]);
3178 let report = RouteReport {
3179 model: "jev-1.13.0".to_string(),
3180 issues: vec![IssueRoute {
3181 item_ref: "o/r#1".to_string(),
3182 url: "u".to_string(),
3183 title: "t".to_string(),
3184 state: ItemState::Open,
3185 outcome: RouteOutcome::Routed {
3186 providers: BTreeMap::from([(
3187 "anthropic".to_string(),
3188 ProviderRoute {
3189 stages: StageAnswers {
3190 design: answer(NO_DESIGN, 0.9),
3191 implement: answer("a,b", 0.94),
3192 review,
3193 },
3194 class: "a,b".to_string(),
3195 close_calls: vec![Stage::Review],
3196 },
3197 )]),
3198 depends_on: vec![],
3199 reference_fetch_failures: vec![],
3200 },
3201 truncated: false,
3202 }],
3203 ignored_closed: vec![],
3204 usage: Usage::default(),
3205 };
3206 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3207 assert!(
3208 text.contains("review: a,b (0.41, close call — c,d 0.44)"),
3209 "{text}"
3210 );
3211 }
3212
3213 #[test]
3214 fn render_route_text_separates_multiple_issues_with_a_blank_line_in_request_order() {
3215 let issue = |item_ref: &str| IssueRoute {
3216 item_ref: item_ref.to_string(),
3217 url: "u".to_string(),
3218 title: "t".to_string(),
3219 state: ItemState::Open,
3220 outcome: RouteOutcome::Failed {
3221 error: "e".to_string(),
3222 reference_fetch_failures: vec![],
3223 },
3224 truncated: false,
3225 };
3226 let report = RouteReport {
3227 model: "jev-1.13.0".to_string(),
3228 issues: vec![issue("o/r#1"), issue("o/r#2")],
3229 ignored_closed: vec![],
3230 usage: Usage::default(),
3231 };
3232 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3233 let first = text.find("o/r#1").unwrap();
3234 let second = text.find("o/r#2").unwrap();
3235 assert!(first < second, "{text}");
3236 assert!(
3237 text.contains("o/r#1 — t\n failed: e\n\no/r#2 — t\n failed: e"),
3238 "{text}"
3239 );
3240 }
3241
3242 #[test]
3243 fn render_route_text_ends_with_the_model_and_summed_usage() {
3244 let report = RouteReport {
3245 model: "jev-1.13.0".to_string(),
3246 issues: vec![],
3247 ignored_closed: vec![],
3248 usage: Usage {
3249 input_tokens: 10,
3250 output_tokens: 20,
3251 },
3252 };
3253 let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3254 assert_eq!(
3255 text,
3256 "model: jev-1.13.0, usage: 10 input tokens, 20 output tokens\n"
3257 );
3258 }
3259
3260 #[test]
3261 fn terminal_style_requires_a_usable_tty() {
3262 assert_eq!(
3263 TerminalStyle::new(false, "xterm", false, true),
3264 TerminalStyle::default()
3265 );
3266 assert_eq!(
3267 TerminalStyle::new(true, "dumb", false, true),
3268 TerminalStyle::default()
3269 );
3270 assert_eq!(
3271 TerminalStyle::new(true, "", false, true),
3272 TerminalStyle::default()
3273 );
3274 assert_eq!(
3275 TerminalStyle::new(true, "xterm", true, true),
3276 TerminalStyle {
3277 color: false,
3278 hyperlinks: true
3279 }
3280 );
3281 assert_eq!(
3282 TerminalStyle::new(true, "xterm", false, false),
3283 TerminalStyle {
3284 color: true,
3285 hyperlinks: false
3286 }
3287 );
3288 }
3289
3290 #[test]
3291 fn complexity_uses_tier_order_for_built_in_and_custom_ladders() {
3292 let anthropic = class_only(Provider::Anthropic).unwrap();
3293 assert_eq!(
3294 choice_complexity(NO_DESIGN, &anthropic),
3295 Some(Complexity::Low)
3296 );
3297 assert_eq!(
3298 choice_complexity("sonnet", &anthropic),
3299 Some(Complexity::Low)
3300 );
3301 assert_eq!(
3302 choice_complexity("opus", &anthropic),
3303 Some(Complexity::High)
3304 );
3305 let custom = |names: &[&str]| {
3306 use std::fmt::Write;
3307
3308 let mut yaml = String::from("tiers:\n");
3309 for name in names {
3310 writeln!(yaml, " - {{name: {name}, description: work}}").unwrap();
3311 }
3312 Ladder::named("custom".to_string(), Tiers::parse(&yaml).unwrap())
3313 };
3314 let two = custom(&["a", "b"]);
3315 assert_eq!(choice_complexity("a", &two), Some(Complexity::Low));
3316 assert_eq!(choice_complexity("b", &two), Some(Complexity::High));
3317 let four = custom(&["a", "b", "c", "d"]);
3318 assert_eq!(choice_complexity("b", &four), Some(Complexity::Medium));
3319 assert_eq!(choice_complexity("c", &four), Some(Complexity::Medium));
3320 assert_eq!(choice_complexity("d", &four), Some(Complexity::High));
3321 assert_eq!(choice_complexity("unknown", &four), None);
3322 }
3323
3324 #[test]
3325 fn hyperlink_uses_exact_osc_8_framing_and_rejects_unsafe_urls() {
3326 let style = TerminalStyle {
3327 color: false,
3328 hyperlinks: true,
3329 };
3330 let url = "https://github.com/other/repo/pull/42";
3331 assert_eq!(
3332 hyperlink("PR #42", Some(url), style),
3333 format!("\x1b]8;;{url}\x1b\\PR #42\x1b]8;;\x1b\\")
3334 );
3335 for bad in [
3336 "https://example.com/o/r/issues/1",
3337 "http://github.com/o/r/issues/1",
3338 "https://github.com/o/r/issues/1\x1b]8;;evil",
3339 "https://github.com/o/r/issues/nope",
3340 "not a valid url",
3341 ] {
3342 assert_eq!(hyperlink("#1", Some(bad), style), "#1");
3343 }
3344 assert_eq!(hyperlink("#1", None, style), "#1");
3345 }
3346
3347 #[test]
3348 fn colorize_applies_ansi_codes_per_complexity_when_color_enabled() {
3349 let color = TerminalStyle {
3350 color: true,
3351 hyperlinks: false,
3352 };
3353 assert_eq!(colorize("x", Complexity::Low, color), "\x1b[32mx\x1b[0m");
3354 assert_eq!(colorize("x", Complexity::Medium, color), "\x1b[33mx\x1b[0m");
3355 assert_eq!(colorize("x", Complexity::High, color), "\x1b[31mx\x1b[0m");
3356 assert_eq!(
3357 colorize("x", Complexity::Medium, TerminalStyle::default()),
3358 "x"
3359 );
3360 }
3361
3362 #[test]
3363 fn styled_report_colors_classes_stages_and_failure_and_links_refs() {
3364 let issue = IssueRoute {
3365 item_ref: "o/r#1".to_string(),
3366 url: "https://github.com/o/r/issues/1".to_string(),
3367 title: "t".to_string(),
3368 state: ItemState::Open,
3369 outcome: RouteOutcome::Routed {
3370 providers: BTreeMap::from([(
3371 "anthropic".to_string(),
3372 ProviderRoute {
3373 stages: stages(NO_DESIGN, "opus", "sonnet"),
3374 class: "opus".to_string(),
3375 close_calls: vec![],
3376 },
3377 )]),
3378 depends_on: vec![DependencyEntry {
3379 item_ref: "#42".to_string(),
3380 url: Some("https://github.com/other/repo/pull/42".to_string()),
3381 state: ItemState::Open,
3382 could_be_cheaper: BTreeMap::new(),
3383 }],
3384 reference_fetch_failures: vec![],
3385 },
3386 truncated: false,
3387 };
3388 let failed = IssueRoute {
3389 item_ref: "o/r#2".to_string(),
3390 url: "https://github.com/o/r/issues/2".to_string(),
3391 title: "failed".to_string(),
3392 state: ItemState::Open,
3393 outcome: RouteOutcome::Failed {
3394 error: "HTTP 529".to_string(),
3395 reference_fetch_failures: vec![],
3396 },
3397 truncated: false,
3398 };
3399 let report = RouteReport {
3400 model: "jev".to_string(),
3401 issues: vec![issue, failed],
3402 ignored_closed: vec![],
3403 usage: Usage::default(),
3404 };
3405 let style = TerminalStyle {
3406 color: true,
3407 hyperlinks: true,
3408 };
3409 let text = render_route_text_styled(
3410 &report,
3411 DEFAULT_MAX_INPUT_CHARS,
3412 &[class_only(Provider::Anthropic).unwrap()],
3413 style,
3414 );
3415 assert!(
3416 text.contains(
3417 "\x1b[32m\x1b]8;;https://github.com/o/r/issues/1\x1b\\o/r#1\x1b]8;;\x1b\\\x1b[0m"
3418 ),
3419 "{text}"
3420 );
3421 assert!(
3422 text.contains("\x1b[31mopus\x1b[0m — design needs \x1b[32mno further work\x1b[0m"),
3423 "{text}"
3424 );
3425 assert!(text.contains("review \x1b[32msonnet\x1b[0m"), "{text}");
3426 assert!(
3427 text.contains("\x1b]8;;https://github.com/other/repo/pull/42\x1b\\#42\x1b]8;;\x1b\\"),
3428 "{text}"
3429 );
3430 assert!(text.contains("\x1b[31mfailed: HTTP 529\x1b[0m"), "{text}");
3431 let plain = render_route_text_styled(
3432 &report,
3433 DEFAULT_MAX_INPUT_CHARS,
3434 &[],
3435 TerminalStyle::default(),
3436 );
3437 assert_eq!(plain, render_route_text(&report, DEFAULT_MAX_INPUT_CHARS));
3438 assert!(!plain.contains('\x1b'));
3439 let json = serde_json::to_string(&report).unwrap();
3440 assert!(!json.contains("https://github.com/other/repo/pull/42"));
3441 }
3442
3443 #[test]
3444 fn styled_multi_model_layout_colors_the_class_and_stage_choices() {
3445 let tier = "model-a,model-b";
3446 let ladder = Ladder::named(
3447 "custom".to_string(),
3448 Tiers::parse(&format!(
3449 "tiers:\n - {{name: basic, description: work}}\n - {{name: '{tier}', description: work}}\n"
3450 ))
3451 .unwrap(),
3452 );
3453 let report = RouteReport {
3454 model: "jev".to_string(),
3455 issues: vec![IssueRoute {
3456 item_ref: "o/r#1".to_string(),
3457 url: "https://github.com/o/r/issues/1".to_string(),
3458 title: "t".to_string(),
3459 state: ItemState::Open,
3460 outcome: RouteOutcome::Routed {
3461 providers: BTreeMap::from([(
3462 "custom".to_string(),
3463 ProviderRoute {
3464 stages: stages(NO_DESIGN, tier, "basic"),
3465 class: tier.to_string(),
3466 close_calls: vec![],
3467 },
3468 )]),
3469 depends_on: vec![],
3470 reference_fetch_failures: vec![],
3471 },
3472 truncated: false,
3473 }],
3474 ignored_closed: vec![],
3475 usage: Usage::default(),
3476 };
3477 let text = render_route_text_styled(
3478 &report,
3479 DEFAULT_MAX_INPUT_CHARS,
3480 &[ladder],
3481 TerminalStyle {
3482 color: true,
3483 hyperlinks: false,
3484 },
3485 );
3486 assert!(
3487 text.contains(&format!(" class: \x1b[31m{tier}\x1b[0m")),
3488 "{text}"
3489 );
3490 assert!(
3491 text.contains(&format!(" implementation: \x1b[31m{tier}\x1b[0m")),
3492 "{text}"
3493 );
3494 assert!(text.contains(" review: \x1b[32mbasic\x1b[0m"), "{text}");
3495 }
3496
3497 #[test]
3498 fn design_stage_with_a_named_tier_renders_needs_prefix() {
3499 let line = render_stage_line(
3500 Stage::Design,
3501 &stages("opus", "sonnet", "sonnet"),
3502 &[],
3503 None,
3504 TerminalStyle::default(),
3505 );
3506 assert_eq!(line, "design: needs opus (0.90)");
3507 }
3508
3509 #[test]
3510 fn with_thousands_groups_digits() {
3511 assert_eq!(with_thousands(0), "0");
3512 assert_eq!(with_thousands(999), "999");
3513 assert_eq!(with_thousands(60_000), "60,000");
3514 assert_eq!(with_thousands(1_000_000), "1,000,000");
3515 }
3516}