1use anyhow::{Result, anyhow};
46use serde::{Deserialize, Serialize};
47use std::collections::BTreeMap;
48use std::str::FromStr;
49
50pub mod context_transcript_io;
51pub mod context_usage;
52
53const CLAUDE_CODE_OPUS_ALIAS: &str = "opus";
60
61#[derive(
67 Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
68)]
69#[serde(rename_all = "lowercase")]
70pub enum Tier {
71 #[default]
73 Auto,
74 Low,
76 Med,
78 High,
80}
81
82impl std::fmt::Display for Tier {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Self::Auto => write!(f, "auto"),
86 Self::Low => write!(f, "low"),
87 Self::Med => write!(f, "med"),
88 Self::High => write!(f, "high"),
89 }
90 }
91}
92
93impl FromStr for Tier {
94 type Err = anyhow::Error;
95
96 fn from_str(s: &str) -> Result<Self> {
97 match s.trim().to_ascii_lowercase().as_str() {
98 "auto" => Ok(Self::Auto),
99 "low" => Ok(Self::Low),
100 "med" | "medium" => Ok(Self::Med),
101 "high" => Ok(Self::High),
102 other => Err(anyhow!(
103 "invalid tier `{}`: expected one of auto|low|med|high",
104 other
105 )),
106 }
107 }
108}
109
110#[derive(Debug, Default, Clone, Serialize, Deserialize)]
114pub struct TierMap {
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub low: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub med: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub high: Option<String>,
121}
122
123impl TierMap {
124 pub fn get(&self, tier: Tier) -> Option<&str> {
125 match tier {
126 Tier::Auto => None,
127 Tier::Low => self.low.as_deref(),
128 Tier::Med => self.med.as_deref(),
129 Tier::High => self.high.as_deref(),
130 }
131 }
132
133 pub fn tier_of(&self, model_name: &str) -> Option<Tier> {
135 if self.low.as_deref() == Some(model_name) {
136 Some(Tier::Low)
137 } else if self.med.as_deref() == Some(model_name) {
138 Some(Tier::Med)
139 } else if self.high.as_deref() == Some(model_name) {
140 Some(Tier::High)
141 } else {
142 None
143 }
144 }
145}
146
147#[derive(Debug, Default, Clone, Serialize, Deserialize)]
149pub struct ModelConfig {
150 #[serde(default = "default_auto")]
152 pub auto: bool,
153 #[serde(default)]
156 pub tiers: BTreeMap<String, TierMap>,
157}
158
159fn default_auto() -> bool {
160 true
161}
162
163fn builtin_claude_code() -> TierMap {
165 TierMap {
166 low: Some("haiku".to_string()),
167 med: Some("sonnet".to_string()),
168 high: Some(CLAUDE_CODE_OPUS_ALIAS.to_string()),
169 }
170}
171
172fn builtin_codex() -> TierMap {
174 TierMap {
175 low: Some("gpt-4o-mini".to_string()),
176 med: Some("gpt-4o".to_string()),
177 high: Some("o3".to_string()),
178 }
179}
180
181fn builtin_default() -> TierMap {
183 TierMap {
184 low: Some("haiku".to_string()),
185 med: Some("sonnet".to_string()),
186 high: Some("opus".to_string()),
187 }
188}
189
190fn builtin_for(harness: &str) -> TierMap {
192 match harness {
193 "claude-code" => builtin_claude_code(),
194 "codex" => builtin_codex(),
195 _ => builtin_default(),
196 }
197}
198
199pub fn detect_harness() -> String {
200 if ["CLAUDE_CODE_SESSION", "CLAUDE_CODE", "CLAUDECODE"]
201 .iter()
202 .any(|key| std::env::var_os(key).is_some())
203 {
204 "claude-code".to_string()
205 } else if ["CODEX_SESSION", "CODEX_THREAD_ID", "CODEX_CLI", "CODEX"]
206 .iter()
207 .any(|key| std::env::var_os(key).is_some())
208 {
209 "codex".to_string()
210 } else if ["OPENCODE_CLIENT", "OPENCODE"]
211 .iter()
212 .any(|key| std::env::var_os(key).is_some())
213 {
214 "opencode".to_string()
215 } else {
216 "default".to_string()
217 }
218}
219
220pub fn harness_key_for_agent_name(agent_name: &str) -> String {
221 let normalized = agent_name
222 .trim()
223 .to_ascii_lowercase()
224 .replace(['_', ' '], "-");
225 match normalized.as_str() {
226 "claude" | "claude-code" | "claudecode" | "claude-code-cli" => "claude-code".to_string(),
227 "codex" | "codex-cli" | "openai-codex" => "codex".to_string(),
228 "opencode" | "open-code" | "opencode-ai" => "opencode".to_string(),
229 "" => "default".to_string(),
230 other => other.to_string(),
231 }
232}
233
234pub fn canonical_harness_name(value: &str) -> Option<String> {
235 let normalized = value.trim().to_ascii_lowercase().replace(['_', ' '], "-");
236 match normalized.as_str() {
237 "" | "default" | "generic" => None,
238 "claude" | "claude-code" | "claudecode" | "claude-code-cli" => {
239 Some("claude-code".to_string())
240 }
241 "codex" | "codex-cli" | "openai-codex" => Some("codex".to_string()),
242 "opencode" | "open-code" | "opencode-ai" => Some("opencode".to_string()),
243 other => Some(other.to_string()),
244 }
245}
246
247pub const HARNESS_MISMATCH_WARNING_CODE: &str = "harness_mismatch";
248pub const CODEX_NETWORK_ACCESS_NON_CODEX_HARNESS_WARNING_CODE: &str =
249 "codex_network_access_non_codex_harness";
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct HarnessMismatchWarning {
253 pub code: &'static str,
254 pub message: String,
255 pub document_agent: String,
256 pub active_harness: String,
257}
258
259pub fn harness_mismatch_warning(
260 document_agent: Option<&str>,
261 active_harness: &str,
262) -> Option<HarnessMismatchWarning> {
263 let declared_raw = document_agent?.trim();
264 if declared_raw.is_empty() {
265 return None;
266 }
267 let declared = canonical_harness_name(declared_raw)?;
268 let active = canonical_harness_name(active_harness)?;
269 if declared == active {
270 return None;
271 }
272 Some(HarnessMismatchWarning {
273 code: HARNESS_MISMATCH_WARNING_CODE,
274 message: format!(
275 "Document declares agent: {} but active harness is {}; responses will use the active harness attribution and closeout path.",
276 declared_raw, active
277 ),
278 document_agent: declared_raw.to_string(),
279 active_harness: active,
280 })
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct CodexNetworkAccessNonCodexHarnessWarning {
285 pub code: &'static str,
286 pub message: String,
287 pub document_agent: Option<String>,
288 pub active_harness: String,
289}
290
291pub fn codex_network_access_non_codex_harness_warning(
292 file_display: &str,
293 document_agent: Option<&str>,
294 active_harness: &str,
295 codex_network_access_configured: bool,
296) -> Option<CodexNetworkAccessNonCodexHarnessWarning> {
297 if !codex_network_access_configured
298 || canonical_harness_name(active_harness).as_deref() == Some("codex")
299 {
300 return None;
301 }
302
303 Some(CodexNetworkAccessNonCodexHarnessWarning {
304 code: CODEX_NETWORK_ACCESS_NON_CODEX_HARNESS_WARNING_CODE,
305 message: format!(
306 "{file_display}: `codex_network_access` is Codex-specific and has no effect when the active harness is {active_harness}. \
307 Either remove it from the document frontmatter or switch the agent to codex."
308 ),
309 document_agent: document_agent.map(str::to_string),
310 active_harness: active_harness.to_string(),
311 })
312}
313
314pub fn resolve_tier_to_model(
319 tier: Tier,
320 harness: &str,
321 model_config: &ModelConfig,
322) -> Option<String> {
323 if matches!(tier, Tier::Auto) {
324 return None;
325 }
326 if let Some(map) = model_config.tiers.get(harness)
327 && let Some(name) = map.get(tier)
328 {
329 return Some(name.to_string());
330 }
331 builtin_for(harness).get(tier).map(|s| s.to_string())
332}
333
334fn is_claude_code_opus(model_name: &str) -> bool {
339 let m = model_name.trim();
340 m == CLAUDE_CODE_OPUS_ALIAS || m.starts_with("claude-opus") || m.starts_with("opus-")
341}
342
343pub fn is_bare_model_name(model_name: &str) -> bool {
347 let trimmed = model_name.trim();
348 !trimmed.contains('/') || trimmed.starts_with('/')
349}
350
351pub fn canonical_model_name(
365 model_name: &str,
366 harness: &str,
367 _model_config: &ModelConfig,
368) -> String {
369 if harness == "opencode" && is_bare_model_name(model_name) {
370 eprintln!(
371 "[model_tier] WARNING: opencode model name {:?} lacks a provider prefix \
372 (expected \"provider/model\", e.g. \"zai-coding-plan/glm-5.1\"). \
373 Dispatch may fail or use a wrong provider.",
374 model_name.trim()
375 );
376 }
377 model_name.to_string()
378}
379
380pub fn short_model_name(model_id: &str) -> &str {
381 if let Some(suffix) = model_id.strip_prefix("claude-") {
382 return suffix;
383 }
384 model_id
385}
386
387pub fn resolve_agent_model(
388 frontmatter_model: Option<&str>,
389 harness: &str,
390 model_config: &ModelConfig,
391) -> Option<String> {
392 let model = frontmatter_model?;
393 let canonical = canonical_model_name(model, harness, model_config);
394 if harness == "claude-code" && canonical.trim() == CLAUDE_CODE_OPUS_ALIAS {
395 return None;
396 }
397 Some(short_model_name(&canonical).to_string())
398}
399
400pub fn tier_from_model_name(
405 model_name: &str,
406 harness: &str,
407 model_config: &ModelConfig,
408) -> Option<Tier> {
409 if let Some(map) = model_config.tiers.get(harness)
410 && let Some(t) = map.tier_of(model_name)
411 {
412 return Some(t);
413 }
414 builtin_for(harness).tier_of(model_name).or_else(|| {
415 if harness == "claude-code" && is_claude_code_opus(model_name) {
416 Some(Tier::High)
417 } else {
418 None
419 }
420 })
421}
422
423pub fn extract_model_component(content: &str) -> Option<String> {
429 let comp = agent_doc_markdown_ast::overlay::components(content)
430 .into_iter()
431 .find(|c| c.name == "model")?;
432 let open_end = content[comp.start_byte..].find('\n')? + comp.start_byte + 1;
433 let before_end = content[..comp.end_byte].trim_end_matches('\n');
434 let close_start = before_end.rfind('\n').map_or(comp.start_byte, |i| i + 1);
435 if close_start < open_end {
436 return None;
437 }
438 let inner = &content[open_end..close_start];
439 let trimmed = inner.trim();
440 if trimmed.is_empty() {
441 None
442 } else {
443 Some(trimmed.to_string())
444 }
445}
446
447pub fn component_value_to_tier(
452 value: &str,
453 harness: &str,
454 model_config: &ModelConfig,
455) -> Option<Tier> {
456 if let Ok(tier) = Tier::from_str(value) {
457 return Some(tier);
458 }
459 tier_from_model_name(value, harness, model_config)
460}
461
462pub fn suggested_tier(
480 diff_type: Option<&str>,
481 lines_added: usize,
482 doc_path: &std::path::Path,
483) -> Tier {
484 let base = match diff_type {
485 Some("simple_question")
486 | Some("approval")
487 | Some("boundary_artifact")
488 | Some("annotation") => Tier::Low,
489 Some("content_addition") => {
490 if lines_added < 10 {
491 Tier::Low
492 } else {
493 Tier::Med
494 }
495 }
496 Some("multi_topic") | Some("structural_change") => Tier::Med,
497 _ => Tier::Med,
498 };
499
500 let path_str = doc_path.to_string_lossy();
502 let boost = path_str.contains("tasks/software/")
503 || path_str.contains("/specs/")
504 || path_str.contains("agent-doc-bugs")
505 || path_str.contains("plan-")
506 || path_str.contains("/plan.md");
507 if boost {
508 match base {
509 Tier::Auto | Tier::Low => Tier::Med,
510 Tier::Med => Tier::High,
511 Tier::High => Tier::High,
512 }
513 } else {
514 base
515 }
516}
517
518#[derive(Debug, Clone)]
520pub struct ModelSwitchScan {
521 pub model_switch: Option<String>,
523 pub model_switch_tier: Option<Tier>,
525 pub stripped_diff: String,
527}
528
529pub fn scan_model_switch(diff: &str, harness: &str, model_config: &ModelConfig) -> ModelSwitchScan {
543 let mut model_switch: Option<String> = None;
544 let mut model_switch_tier: Option<Tier> = None;
545 let mut kept_lines: Vec<&str> = Vec::with_capacity(diff.lines().count());
546
547 let mut in_fence = false;
548 let mut fence_char = '`';
549 let mut fence_len = 0usize;
550
551 for line in diff.lines() {
552 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
554 kept_lines.push(line);
555 continue;
556 }
557
558 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
560 &line[1..]
561 } else {
562 line
563 };
564
565 let trimmed = content.trim_start();
567 if !in_fence {
568 let fc = trimmed.chars().next().unwrap_or('\0');
569 if (fc == '`' || fc == '~')
570 && let fl = trimmed.chars().take_while(|&c| c == fc).count()
571 && fl >= 3
572 {
573 in_fence = true;
574 fence_char = fc;
575 fence_len = fl;
576 kept_lines.push(line);
577 continue;
578 }
579 } else {
580 let fc = trimmed.chars().next().unwrap_or('\0');
581 if fc == fence_char {
582 let fl = trimmed.chars().take_while(|&c| c == fc).count();
583 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
584 in_fence = false;
585 kept_lines.push(line);
586 continue;
587 }
588 }
589 }
590
591 let is_added = line.starts_with('+') && !line.starts_with("+++");
593 if !is_added {
594 kept_lines.push(line);
595 continue;
596 }
597
598 if in_fence {
600 kept_lines.push(line);
601 continue;
602 }
603
604 if content.starts_with('>') {
606 kept_lines.push(line);
607 continue;
608 }
609
610 let stripped = content.trim_end();
612 if let Some(rest) = stripped.strip_prefix("/model")
613 && let Some(arg) = rest.split_whitespace().next()
614 && !arg.is_empty()
615 {
616 if let Some((tier, name)) = parse_model_arg(arg, harness, model_config) {
618 if model_switch.is_none() {
619 model_switch = Some(name);
620 model_switch_tier = Some(tier);
621 }
622 continue;
624 }
625 continue;
627 }
628
629 kept_lines.push(line);
630 }
631
632 ModelSwitchScan {
633 model_switch,
634 model_switch_tier,
635 stripped_diff: kept_lines.join("\n"),
636 }
637}
638
639pub fn compose_effective_tier(
645 model_switch_tier: Option<Tier>,
646 component_tier: Option<Tier>,
647 frontmatter_tier: Option<Tier>,
648 suggested: Tier,
649) -> Tier {
650 for candidate in [model_switch_tier, component_tier, frontmatter_tier] {
651 if let Some(t) = candidate
652 && !matches!(t, Tier::Auto)
653 {
654 return t;
655 }
656 }
657 suggested
658}
659
660pub fn parse_model_arg(
667 arg: &str,
668 harness: &str,
669 model_config: &ModelConfig,
670) -> Option<(Tier, String)> {
671 let trimmed = arg.trim();
672 if let Ok(tier) = Tier::from_str(trimmed) {
674 if matches!(tier, Tier::Auto) {
675 return None;
676 }
677 let name = resolve_tier_to_model(tier, harness, model_config)
678 .unwrap_or_else(|| trimmed.to_string());
679 return Some((tier, name));
680 }
681 if let Some(tier) = tier_from_model_name(trimmed, harness, model_config) {
683 return Some((tier, canonical_model_name(trimmed, harness, model_config)));
684 }
685 if harness == "opencode" && !is_bare_model_name(trimmed) {
687 return Some((
688 Tier::Auto,
689 canonical_model_name(trimmed, harness, model_config),
690 ));
691 }
692 None
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use std::sync::Mutex;
700
701 static ENV_LOCK: Mutex<()> = Mutex::new(());
702
703 struct EnvRestore {
704 values: Vec<(&'static str, Option<std::ffi::OsString>)>,
705 }
706
707 impl EnvRestore {
708 fn clear(keys: &[&'static str]) -> Self {
709 let values = keys
710 .iter()
711 .map(|key| (*key, std::env::var_os(key)))
712 .collect::<Vec<_>>();
713 for key in keys {
714 unsafe { std::env::remove_var(key) };
716 }
717 Self { values }
718 }
719 }
720
721 impl Drop for EnvRestore {
722 fn drop(&mut self) {
723 for (key, value) in &self.values {
724 match value {
725 Some(value) => {
726 unsafe { std::env::set_var(key, value) };
728 }
729 None => {
730 unsafe { std::env::remove_var(key) };
732 }
733 }
734 }
735 }
736 }
737
738 const HARNESS_ENV_KEYS: &[&str] = &[
739 "CLAUDE_CODE_SESSION",
740 "CLAUDE_CODE",
741 "CLAUDECODE",
742 "CODEX_SESSION",
743 "CODEX_THREAD_ID",
744 "CODEX_CLI",
745 "CODEX",
746 "OPENCODE_CLIENT",
747 "OPENCODE",
748 ];
749
750 #[test]
751 fn tier_ordering() {
752 assert!(Tier::Auto < Tier::Low);
753 assert!(Tier::Low < Tier::Med);
754 assert!(Tier::Med < Tier::High);
755 assert!(Tier::High > Tier::Low);
756 assert!(Tier::Med >= Tier::Med);
757 }
758
759 #[test]
760 fn tier_from_str_case_insensitive() {
761 assert_eq!("LOW".parse::<Tier>().unwrap(), Tier::Low);
762 assert_eq!("low".parse::<Tier>().unwrap(), Tier::Low);
763 assert_eq!("Low".parse::<Tier>().unwrap(), Tier::Low);
764 assert_eq!("AUTO".parse::<Tier>().unwrap(), Tier::Auto);
765 assert_eq!("med".parse::<Tier>().unwrap(), Tier::Med);
766 assert_eq!("medium".parse::<Tier>().unwrap(), Tier::Med);
767 assert_eq!("HIGH".parse::<Tier>().unwrap(), Tier::High);
768 }
769
770 #[test]
771 fn tier_from_str_invalid() {
772 assert!("ultra".parse::<Tier>().is_err());
773 assert!("".parse::<Tier>().is_err());
774 assert!("opus".parse::<Tier>().is_err());
775 }
776
777 #[test]
778 fn tier_display() {
779 assert_eq!(Tier::Low.to_string(), "low");
780 assert_eq!(Tier::Med.to_string(), "med");
781 assert_eq!(Tier::High.to_string(), "high");
782 assert_eq!(Tier::Auto.to_string(), "auto");
783 }
784
785 #[test]
786 fn harness_detection_returns_known_value() {
787 let h = detect_harness();
790 assert!(
791 matches!(h.as_str(), "claude-code" | "codex" | "opencode" | "default"),
792 "unexpected harness: {h}"
793 );
794 }
795
796 #[test]
797 fn harness_key_for_agent_name_maps_cli_aliases() {
798 assert_eq!(harness_key_for_agent_name("claude"), "claude-code");
799 assert_eq!(harness_key_for_agent_name("claude_code"), "claude-code");
800 assert_eq!(harness_key_for_agent_name("codex"), "codex");
801 assert_eq!(harness_key_for_agent_name("opencode"), "opencode");
802 }
803
804 #[test]
805 fn canonical_harness_name_skips_default_and_normalizes_aliases() {
806 assert_eq!(canonical_harness_name("default"), None);
807 assert_eq!(canonical_harness_name("generic"), None);
808 assert_eq!(
809 canonical_harness_name("claude_code").as_deref(),
810 Some("claude-code")
811 );
812 assert_eq!(
813 canonical_harness_name("openai-codex").as_deref(),
814 Some("codex")
815 );
816 assert_eq!(
817 canonical_harness_name("open-code").as_deref(),
818 Some("opencode")
819 );
820 }
821
822 #[test]
823 fn harness_mismatch_warning_normalizes_aliases() {
824 assert!(
825 harness_mismatch_warning(Some("claude"), "claude-code").is_none(),
826 "claude and claude-code are the same canonical harness"
827 );
828 let warning = harness_mismatch_warning(Some("codex"), "claude-code").unwrap();
829 assert_eq!(warning.code, HARNESS_MISMATCH_WARNING_CODE);
830 assert_eq!(warning.document_agent, "codex");
831 assert_eq!(warning.active_harness, "claude-code");
832 assert!(warning.message.contains("Document declares agent: codex"));
833 }
834
835 #[test]
836 fn harness_mismatch_warning_skips_unknown_active_harness() {
837 assert!(harness_mismatch_warning(Some("codex"), "default").is_none());
838 assert!(harness_mismatch_warning(None, "claude-code").is_none());
839 }
840
841 #[test]
842 fn codex_network_access_warning_fires_for_non_codex_harness() {
843 let warning = codex_network_access_non_codex_harness_warning(
844 "tasks/session.md",
845 Some("opencode"),
846 "opencode",
847 true,
848 )
849 .unwrap();
850
851 assert_eq!(
852 warning.code,
853 CODEX_NETWORK_ACCESS_NON_CODEX_HARNESS_WARNING_CODE
854 );
855 assert_eq!(warning.document_agent.as_deref(), Some("opencode"));
856 assert_eq!(warning.active_harness, "opencode");
857 assert!(warning.message.contains("tasks/session.md"));
858 assert!(
859 warning
860 .message
861 .contains("`codex_network_access` is Codex-specific")
862 );
863 }
864
865 #[test]
866 fn codex_network_access_warning_skips_codex_and_unconfigured() {
867 assert!(
868 codex_network_access_non_codex_harness_warning(
869 "tasks/session.md",
870 Some("codex"),
871 "codex-cli",
872 true,
873 )
874 .is_none()
875 );
876 assert!(
877 codex_network_access_non_codex_harness_warning(
878 "tasks/session.md",
879 Some("opencode"),
880 "opencode",
881 false,
882 )
883 .is_none()
884 );
885 }
886
887 #[test]
888 fn harness_detection_recognizes_cli_environment_aliases() {
889 let _env_lock = ENV_LOCK
890 .lock()
891 .unwrap_or_else(|poisoned| poisoned.into_inner());
892 let _restore = EnvRestore::clear(HARNESS_ENV_KEYS);
893
894 unsafe { std::env::set_var("CLAUDE_CODE", "1") };
896 assert_eq!(detect_harness(), "claude-code");
897 unsafe { std::env::remove_var("CLAUDE_CODE") };
899
900 unsafe { std::env::set_var("CODEX_THREAD_ID", "thread-123") };
902 assert_eq!(detect_harness(), "codex");
903 unsafe { std::env::remove_var("CODEX_THREAD_ID") };
905
906 unsafe { std::env::set_var("OPENCODE", "1") };
908 assert_eq!(detect_harness(), "opencode");
909 }
910
911 #[test]
912 fn resolve_builtin_claude_code() {
913 let cfg = ModelConfig::default();
914 assert_eq!(
915 resolve_tier_to_model(Tier::High, "claude-code", &cfg).as_deref(),
916 Some("opus")
917 );
918 assert_eq!(
919 resolve_tier_to_model(Tier::Med, "claude-code", &cfg).as_deref(),
920 Some("sonnet")
921 );
922 assert_eq!(
923 resolve_tier_to_model(Tier::Low, "claude-code", &cfg).as_deref(),
924 Some("haiku")
925 );
926 assert_eq!(resolve_tier_to_model(Tier::Auto, "claude-code", &cfg), None);
927 }
928
929 #[test]
930 fn resolve_builtin_codex() {
931 let cfg = ModelConfig::default();
932 assert_eq!(
933 resolve_tier_to_model(Tier::High, "codex", &cfg).as_deref(),
934 Some("o3")
935 );
936 assert_eq!(
937 resolve_tier_to_model(Tier::Low, "codex", &cfg).as_deref(),
938 Some("gpt-4o-mini")
939 );
940 }
941
942 #[test]
943 fn resolve_unknown_harness_uses_default() {
944 let cfg = ModelConfig::default();
945 assert_eq!(
947 resolve_tier_to_model(Tier::High, "junie", &cfg).as_deref(),
948 Some("opus")
949 );
950 }
951
952 #[test]
953 fn user_config_overrides_builtin() {
954 let mut cfg = ModelConfig::default();
955 let mut tiers = BTreeMap::new();
956 tiers.insert(
957 "claude-code".to_string(),
958 TierMap {
959 low: Some("haiku-3".to_string()),
960 med: Some("sonnet-4".to_string()),
961 high: Some("opus-4-1".to_string()),
962 },
963 );
964 cfg.tiers = tiers;
965 assert_eq!(
966 resolve_tier_to_model(Tier::High, "claude-code", &cfg).as_deref(),
967 Some("opus-4-1")
968 );
969 }
970
971 #[test]
972 fn tier_from_model_name_builtin() {
973 let cfg = ModelConfig::default();
974 assert_eq!(
975 tier_from_model_name("opus", "claude-code", &cfg),
976 Some(Tier::High)
977 );
978 assert_eq!(
979 tier_from_model_name("claude-opus-4-8", "claude-code", &cfg),
980 Some(Tier::High)
981 );
982 assert_eq!(
983 tier_from_model_name("sonnet", "claude-code", &cfg),
984 Some(Tier::Med)
985 );
986 assert_eq!(
987 tier_from_model_name("haiku", "claude-code", &cfg),
988 Some(Tier::Low)
989 );
990 assert_eq!(tier_from_model_name("unknown", "claude-code", &cfg), None);
991 }
992
993 #[test]
994 fn parse_model_arg_tier_name() {
995 let cfg = ModelConfig::default();
996 let (tier, name) = parse_model_arg("high", "claude-code", &cfg).unwrap();
997 assert_eq!(tier, Tier::High);
998 assert_eq!(name, "opus");
999 }
1000
1001 #[test]
1002 fn parse_model_arg_concrete_name() {
1003 let cfg = ModelConfig::default();
1004 let (tier, name) = parse_model_arg("opus", "claude-code", &cfg).unwrap();
1005 assert_eq!(tier, Tier::High);
1006 assert_eq!(name, "opus");
1007 }
1008
1009 #[test]
1010 fn canonical_model_name_defers_claude_code_opus_alias() {
1011 let cfg = ModelConfig::default();
1012 assert_eq!(canonical_model_name("opus", "claude-code", &cfg), "opus");
1015 assert_eq!(canonical_model_name("opus", "codex", &cfg), "opus");
1016 assert_eq!(
1018 canonical_model_name("claude-opus-4-8", "claude-code", &cfg),
1019 "claude-opus-4-8"
1020 );
1021 }
1022
1023 #[test]
1024 fn tier_from_model_name_classifies_concrete_and_deferred_opus_as_high() {
1025 let cfg = ModelConfig::default();
1026 assert_eq!(
1028 tier_from_model_name("opus", "claude-code", &cfg),
1029 Some(Tier::High)
1030 );
1031 assert_eq!(
1032 tier_from_model_name("claude-opus-4-8", "claude-code", &cfg),
1033 Some(Tier::High)
1034 );
1035 assert_eq!(
1036 tier_from_model_name("claude-opus-4-9", "claude-code", &cfg),
1037 Some(Tier::High)
1038 );
1039 }
1040
1041 #[test]
1042 fn is_bare_model_name_detects_missing_provider_prefix() {
1043 assert!(is_bare_model_name("glm-5.1"));
1044 assert!(is_bare_model_name("opus"));
1045 assert!(is_bare_model_name("haiku"));
1046 assert!(!is_bare_model_name("zai-coding-plan/glm-5.1"));
1047 assert!(!is_bare_model_name("anthropic/claude-opus-4-7"));
1048 assert!(is_bare_model_name("/leading-slash-only"));
1049 }
1050
1051 #[test]
1052 fn canonical_model_name_warns_on_bare_opencode_name() {
1053 let cfg = ModelConfig::default();
1054 assert_eq!(canonical_model_name("glm-5.1", "opencode", &cfg), "glm-5.1");
1055 assert_eq!(
1056 canonical_model_name("zai-coding-plan/glm-5.1", "opencode", &cfg),
1057 "zai-coding-plan/glm-5.1"
1058 );
1059 }
1060
1061 #[test]
1062 fn short_model_name_strips_claude_prefix() {
1063 assert_eq!(short_model_name("claude-sonnet-4-6"), "sonnet-4-6");
1064 assert_eq!(short_model_name("claude-opus-4"), "opus-4");
1065 assert_eq!(short_model_name("claude-haiku-4-5"), "haiku-4-5");
1066 }
1067
1068 #[test]
1069 fn short_model_name_returns_as_is_without_prefix() {
1070 assert_eq!(short_model_name("sonnet-4-6"), "sonnet-4-6");
1071 assert_eq!(short_model_name("gpt-4o"), "gpt-4o");
1072 assert_eq!(short_model_name("gpt-5"), "gpt-5");
1073 assert_eq!(short_model_name("gpt-5.4"), "gpt-5.4");
1074 assert_eq!(short_model_name("opus-4-6"), "opus-4-6");
1075 assert_eq!(short_model_name(""), "");
1076 }
1077
1078 #[test]
1079 fn resolve_agent_model_uses_frontmatter_only() {
1080 let cfg = ModelConfig::default();
1081 let result = resolve_agent_model(Some("claude-opus-4"), "claude-code", &cfg);
1082 assert_eq!(result, Some("opus-4".to_string()));
1083 }
1084
1085 #[test]
1086 fn resolve_agent_model_strips_claude_prefix_from_frontmatter() {
1087 let cfg = ModelConfig::default();
1088 let result = resolve_agent_model(Some("claude-haiku-4-5"), "claude-code", &cfg);
1089 assert_eq!(result, Some("haiku-4-5".to_string()));
1090 }
1091
1092 #[test]
1093 fn resolve_agent_model_defers_claude_code_opus_alias() {
1094 let cfg = ModelConfig::default();
1095 let result = resolve_agent_model(Some("opus"), "claude-code", &cfg);
1096 assert_eq!(result, None);
1097 }
1098
1099 #[test]
1100 fn resolve_agent_model_stamps_pinned_concrete_opus() {
1101 let cfg = ModelConfig::default();
1102 let result = resolve_agent_model(Some("claude-opus-4-8"), "claude-code", &cfg);
1103 assert_eq!(result, Some("opus-4-8".to_string()));
1104 }
1105
1106 #[test]
1107 fn resolve_agent_model_preserves_short_openai_style_name() {
1108 let cfg = ModelConfig::default();
1109 let result = resolve_agent_model(Some("gpt-5"), "codex", &cfg);
1110 assert_eq!(result, Some("gpt-5".to_string()));
1111 }
1112
1113 #[test]
1114 fn resolve_agent_model_none_when_no_frontmatter() {
1115 let cfg = ModelConfig::default();
1116 let result = resolve_agent_model(None, "claude-code", &cfg);
1117 assert_eq!(result, None);
1118 }
1119
1120 #[test]
1121 fn parse_model_arg_opencode_rejects_bare_name() {
1122 let cfg = ModelConfig::default();
1123 assert!(parse_model_arg("glm-5.1", "opencode", &cfg).is_none());
1124 }
1125
1126 #[test]
1127 fn parse_model_arg_opencode_accepts_provider_prefixed_name() {
1128 let cfg = ModelConfig::default();
1129 let (tier, name) = parse_model_arg("zai-coding-plan/glm-5.1", "opencode", &cfg).unwrap();
1130 assert_eq!(tier, Tier::Auto);
1131 assert_eq!(name, "zai-coding-plan/glm-5.1");
1132 }
1133
1134 #[test]
1135 fn parse_model_arg_unknown() {
1136 let cfg = ModelConfig::default();
1137 assert!(parse_model_arg("xyz-3000", "claude-code", &cfg).is_none());
1138 }
1139
1140 #[test]
1141 fn parse_model_arg_auto_rejected() {
1142 let cfg = ModelConfig::default();
1143 assert!(parse_model_arg("auto", "claude-code", &cfg).is_none());
1144 }
1145
1146 #[test]
1147 fn extract_model_component_present() {
1148 let doc = "# Title\n\n<!-- agent:model -->\nhigh\n<!-- /agent:model -->\n\nbody\n";
1149 assert_eq!(extract_model_component(doc).as_deref(), Some("high"));
1150 }
1151
1152 #[test]
1153 fn extract_model_component_absent() {
1154 let doc = "# Title\n\nbody only\n";
1155 assert_eq!(extract_model_component(doc), None);
1156 }
1157
1158 #[test]
1159 fn extract_model_component_empty_inner() {
1160 let doc = "<!-- agent:model -->\n<!-- /agent:model -->\n";
1161 assert_eq!(extract_model_component(doc), None);
1162 }
1163
1164 #[test]
1165 fn extract_model_component_concrete_name() {
1166 let doc = "<!-- agent:model -->\nopus\n<!-- /agent:model -->\n";
1167 assert_eq!(extract_model_component(doc).as_deref(), Some("opus"));
1168 }
1169
1170 #[test]
1171 fn component_value_to_tier_tier_name() {
1172 let cfg = ModelConfig::default();
1173 assert_eq!(
1174 component_value_to_tier("high", "claude-code", &cfg),
1175 Some(Tier::High)
1176 );
1177 }
1178
1179 #[test]
1180 fn component_value_to_tier_concrete_name() {
1181 let cfg = ModelConfig::default();
1182 assert_eq!(
1183 component_value_to_tier("opus", "claude-code", &cfg),
1184 Some(Tier::High)
1185 );
1186 }
1187
1188 #[test]
1189 fn component_value_to_tier_unknown() {
1190 let cfg = ModelConfig::default();
1191 assert_eq!(component_value_to_tier("xyz", "claude-code", &cfg), None);
1192 }
1193
1194 #[test]
1195 fn suggested_tier_simple_question() {
1196 let path = std::path::Path::new("tasks/research/x.md");
1197 assert_eq!(suggested_tier(Some("simple_question"), 1, path), Tier::Low);
1198 }
1199
1200 #[test]
1201 fn suggested_tier_small_addition() {
1202 let path = std::path::Path::new("tasks/research/x.md");
1203 assert_eq!(suggested_tier(Some("content_addition"), 5, path), Tier::Low);
1204 }
1205
1206 #[test]
1207 fn suggested_tier_large_addition() {
1208 let path = std::path::Path::new("tasks/research/x.md");
1209 assert_eq!(
1210 suggested_tier(Some("content_addition"), 50, path),
1211 Tier::Med
1212 );
1213 }
1214
1215 #[test]
1216 fn suggested_tier_default_for_unknown() {
1217 let path = std::path::Path::new("tasks/research/x.md");
1218 assert_eq!(suggested_tier(None, 0, path), Tier::Med);
1219 }
1220
1221 #[test]
1222 fn suggested_tier_path_boost_software() {
1223 let path = std::path::Path::new("tasks/software/foo.md");
1224 assert_eq!(suggested_tier(Some("simple_question"), 1, path), Tier::Med);
1226 assert_eq!(
1228 suggested_tier(Some("content_addition"), 50, path),
1229 Tier::High
1230 );
1231 }
1232
1233 #[test]
1234 fn suggested_tier_path_boost_caps_at_high() {
1235 let path = std::path::Path::new("tasks/software/foo.md");
1236 let t = suggested_tier(Some("content_addition"), 50, path);
1238 assert_eq!(t, Tier::High);
1239 }
1240
1241 #[test]
1242 fn compose_effective_tier_model_switch_wins() {
1243 let t = compose_effective_tier(
1244 Some(Tier::High),
1245 Some(Tier::Low),
1246 Some(Tier::Med),
1247 Tier::Low,
1248 );
1249 assert_eq!(t, Tier::High);
1250 }
1251
1252 #[test]
1253 fn compose_effective_tier_component_beats_frontmatter() {
1254 let t = compose_effective_tier(None, Some(Tier::High), Some(Tier::Low), Tier::Med);
1255 assert_eq!(t, Tier::High);
1256 }
1257
1258 #[test]
1259 fn compose_effective_tier_frontmatter_beats_heuristic() {
1260 let t = compose_effective_tier(None, None, Some(Tier::High), Tier::Low);
1261 assert_eq!(t, Tier::High);
1262 }
1263
1264 #[test]
1265 fn compose_effective_tier_falls_through_to_heuristic() {
1266 let t = compose_effective_tier(None, None, None, Tier::Med);
1267 assert_eq!(t, Tier::Med);
1268 }
1269
1270 #[test]
1271 fn scan_model_switch_concrete_name() {
1272 let cfg = ModelConfig::default();
1273 let diff = "@@ -1,3 +1,4 @@\n context\n+/model opus\n+real edit\n";
1274 let result = scan_model_switch(diff, "claude-code", &cfg);
1275 assert_eq!(result.model_switch.as_deref(), Some("opus"));
1276 assert_eq!(result.model_switch_tier, Some(Tier::High));
1277 assert!(!result.stripped_diff.contains("/model opus"));
1278 assert!(result.stripped_diff.contains("real edit"));
1279 }
1280
1281 #[test]
1282 fn scan_model_switch_tier_name() {
1283 let cfg = ModelConfig::default();
1284 let diff = "+/model high\n+other line\n";
1285 let result = scan_model_switch(diff, "claude-code", &cfg);
1286 assert_eq!(result.model_switch_tier, Some(Tier::High));
1287 assert_eq!(result.model_switch.as_deref(), Some("opus"));
1288 assert!(!result.stripped_diff.contains("/model high"));
1289 }
1290
1291 #[test]
1292 fn scan_model_switch_haiku() {
1293 let cfg = ModelConfig::default();
1294 let diff = "+/model haiku\n";
1295 let result = scan_model_switch(diff, "claude-code", &cfg);
1296 assert_eq!(result.model_switch_tier, Some(Tier::Low));
1297 }
1298
1299 #[test]
1300 fn scan_model_switch_inside_fenced_code_ignored() {
1301 let cfg = ModelConfig::default();
1302 let diff = "+```\n+/model opus\n+```\n+real line\n";
1303 let result = scan_model_switch(diff, "claude-code", &cfg);
1304 assert_eq!(result.model_switch, None);
1305 assert!(result.stripped_diff.contains("/model opus"));
1306 }
1307
1308 #[test]
1309 fn scan_model_switch_inside_blockquote_ignored() {
1310 let cfg = ModelConfig::default();
1311 let diff = "+> /model opus\n+real line\n";
1312 let result = scan_model_switch(diff, "claude-code", &cfg);
1313 assert_eq!(result.model_switch, None);
1314 assert!(result.stripped_diff.contains("/model opus"));
1315 }
1316
1317 #[test]
1318 fn scan_model_switch_only_added_lines() {
1319 let cfg = ModelConfig::default();
1320 let diff = " /model opus\n+real line\n";
1322 let result = scan_model_switch(diff, "claude-code", &cfg);
1323 assert_eq!(result.model_switch, None);
1324 }
1325
1326 #[test]
1327 fn scan_model_switch_no_match() {
1328 let cfg = ModelConfig::default();
1329 let diff = "+just a normal line\n+another\n";
1330 let result = scan_model_switch(diff, "claude-code", &cfg);
1331 assert_eq!(result.model_switch, None);
1332 assert!(result.stripped_diff.contains("just a normal line"));
1334 assert!(result.stripped_diff.contains("another"));
1335 }
1336
1337 #[test]
1338 fn scan_model_switch_unknown_arg_still_stripped() {
1339 let cfg = ModelConfig::default();
1340 let diff = "+/model xyz-3000\n+real line\n";
1342 let result = scan_model_switch(diff, "claude-code", &cfg);
1343 assert_eq!(result.model_switch, None);
1344 assert!(!result.stripped_diff.contains("/model xyz-3000"));
1345 assert!(result.stripped_diff.contains("real line"));
1346 }
1347
1348 #[test]
1349 fn scan_model_switch_first_match_wins() {
1350 let cfg = ModelConfig::default();
1351 let diff = "+/model opus\n+/model haiku\n";
1352 let result = scan_model_switch(diff, "claude-code", &cfg);
1353 assert_eq!(result.model_switch.as_deref(), Some("opus"));
1354 assert!(!result.stripped_diff.contains("/model"));
1356 }
1357
1358 #[test]
1359 fn compose_effective_tier_auto_falls_through() {
1360 let t = compose_effective_tier(
1362 Some(Tier::Auto),
1363 Some(Tier::Auto),
1364 Some(Tier::High),
1365 Tier::Low,
1366 );
1367 assert_eq!(t, Tier::High);
1368 }
1369}