Skip to main content

agent_doc_core/
model_tier.rs

1//! # Module: model_tier
2//!
3//! ## Spec
4//! - Defines `Tier`: a harness-agnostic complexity bucket (`Auto`, `Low`, `Med`, `High`)
5//!   used to classify task complexity and gate model selection.
6//! - `Tier` derives `PartialOrd` such that `Auto < Low < Med < High`. Gating is a simple
7//!   `>` comparison: a task whose effective tier exceeds the running model's tier should
8//!   prompt the user to switch.
9//! - Defines `ModelConfig` (under `[model]` in the global TOML config) and `TierMap` (per-harness
10//!   tier→model name resolution under `[model.tiers.<harness>]`).
11//! - `detect_harness()` reads environment variables (`CLAUDE_CODE_SESSION`, `CLAUDE_CODE`,
12//!   `CLAUDECODE`, `CODEX_SESSION`, `CODEX_THREAD_ID`, `CODEX_CLI`, `OPENCODE_CLIENT`, `OPENCODE`)
13//!   to identify the active agent harness, falling back to `"default"`.
14//! - `resolve_tier_to_model(tier, harness, config)` maps a `Tier` to the concrete model name
15//!   configured for the given harness, falling back to built-in defaults for the
16//!   `claude-code`, `codex`, `opencode`, and `default` harnesses.
17//! - `tier_from_model_name(name, harness, config)` is the reverse lookup: given a concrete
18//!   model name (e.g., `"opus"`), find the tier it belongs to in the harness mapping.
19//! - `Tier::FromStr` accepts case-insensitive `auto | low | med | high`.
20//!
21//! ## Agentic Contracts
22//! - **Total ordering**: `Tier` implements `PartialOrd` deterministically; gating logic
23//!   is a single comparison and can be safely executed by any model tier.
24//! - **Auto is the lowest**: `Tier::Auto` represents "no preference" and compares less than
25//!   `Low`. The `effective_tier` composition treats `Auto` as "fall through to next source."
26//! - **Built-in defaults**: when no `[model.tiers.<harness>]` section is present, the
27//!   resolver falls back to compiled-in maps for known harnesses. This means a fresh
28//!   install needs zero config for the common case.
29//! - **Reverse lookup is partial**: `tier_from_model_name` returns `None` if the model
30//!   name doesn't appear in any tier slot for the harness. Callers should treat `None`
31//!   as "unknown — leave tier as Auto."
32//!
33//! ## Evals
34//! - `tier_ordering`: `Auto < Low < Med < High` holds for `<`, `>`, `<=`, `>=`.
35//! - `tier_from_str_case_insensitive`: `"LOW"`, `"low"`, `"Low"` all parse to `Tier::Low`.
36//! - `tier_from_str_invalid`: unknown strings return `Err`.
37//! - `harness_detection_default`: with no env vars set, `detect_harness()` returns `"default"`.
38//! - `resolve_builtin_claude_code`: `resolve_tier_to_model(Tier::High, "claude-code", &Config::default())`
39//!   returns `Some("claude-opus-4-7")`.
40//! - `resolve_unknown_harness_uses_default`: an unknown harness falls through to the
41//!   `"default"` built-in map.
42//! - `tier_from_model_name_roundtrip`: `tier_from_model_name("opus", "claude-code", ...)`
43//!   returns `Some(Tier::High)`.
44
45use anyhow::{Result, anyhow};
46use serde::{Deserialize, Serialize};
47use std::collections::BTreeMap;
48use std::str::FromStr;
49
50const CLAUDE_CODE_OPUS_MODEL: &str = "claude-opus-4-7";
51
52/// Harness-agnostic model complexity tier.
53///
54/// Ordering: `Auto < Low < Med < High`. Gating logic uses a simple `>` comparison —
55/// a task whose effective tier exceeds the running model's tier should prompt the
56/// user to switch models.
57#[derive(
58    Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
59)]
60#[serde(rename_all = "lowercase")]
61pub enum Tier {
62    /// No preference; fall through to next source in the precedence chain.
63    #[default]
64    Auto,
65    /// Cheap, fast model — small content additions, simple questions.
66    Low,
67    /// Default working model — multi-section edits, planning, moderate diffs.
68    Med,
69    /// Powerful model — complex debugging, architecture decisions, large code changes.
70    High,
71}
72
73impl std::fmt::Display for Tier {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::Auto => write!(f, "auto"),
77            Self::Low => write!(f, "low"),
78            Self::Med => write!(f, "med"),
79            Self::High => write!(f, "high"),
80        }
81    }
82}
83
84impl FromStr for Tier {
85    type Err = anyhow::Error;
86
87    fn from_str(s: &str) -> Result<Self> {
88        match s.trim().to_ascii_lowercase().as_str() {
89            "auto" => Ok(Self::Auto),
90            "low" => Ok(Self::Low),
91            "med" | "medium" => Ok(Self::Med),
92            "high" => Ok(Self::High),
93            other => Err(anyhow!(
94                "invalid tier `{}`: expected one of auto|low|med|high",
95                other
96            )),
97        }
98    }
99}
100
101/// Per-harness tier → concrete model name map.
102///
103/// Configured under `[model.tiers.<harness>]` in the global config.
104#[derive(Debug, Default, Clone, Serialize, Deserialize)]
105pub struct TierMap {
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub low: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub med: Option<String>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub high: Option<String>,
112}
113
114impl TierMap {
115    pub fn get(&self, tier: Tier) -> Option<&str> {
116        match tier {
117            Tier::Auto => None,
118            Tier::Low => self.low.as_deref(),
119            Tier::Med => self.med.as_deref(),
120            Tier::High => self.high.as_deref(),
121        }
122    }
123
124    /// Reverse lookup: find which tier a concrete model name belongs to.
125    pub fn tier_of(&self, model_name: &str) -> Option<Tier> {
126        if self.low.as_deref() == Some(model_name) {
127            Some(Tier::Low)
128        } else if self.med.as_deref() == Some(model_name) {
129            Some(Tier::Med)
130        } else if self.high.as_deref() == Some(model_name) {
131            Some(Tier::High)
132        } else {
133            None
134        }
135    }
136}
137
138/// Global `[model]` config section.
139#[derive(Debug, Default, Clone, Serialize, Deserialize)]
140pub struct ModelConfig {
141    /// Whether automatic tier-based recommendations are enabled (default: true).
142    #[serde(default = "default_auto")]
143    pub auto: bool,
144    /// Per-harness tier → model name maps. Key is the harness name
145    /// (e.g., `claude-code`, `codex`, `default`).
146    #[serde(default)]
147    pub tiers: BTreeMap<String, TierMap>,
148}
149
150fn default_auto() -> bool {
151    true
152}
153
154/// Built-in tier map for the `claude-code` harness.
155fn builtin_claude_code() -> TierMap {
156    TierMap {
157        low: Some("haiku".to_string()),
158        med: Some("sonnet".to_string()),
159        high: Some(CLAUDE_CODE_OPUS_MODEL.to_string()),
160    }
161}
162
163/// Built-in tier map for the `codex` harness.
164fn builtin_codex() -> TierMap {
165    TierMap {
166        low: Some("gpt-4o-mini".to_string()),
167        med: Some("gpt-4o".to_string()),
168        high: Some("o3".to_string()),
169    }
170}
171
172/// Built-in fallback tier map.
173fn builtin_default() -> TierMap {
174    TierMap {
175        low: Some("haiku".to_string()),
176        med: Some("sonnet".to_string()),
177        high: Some("opus".to_string()),
178    }
179}
180
181/// Return the built-in tier map for a known harness, or `default` for unknowns.
182fn builtin_for(harness: &str) -> TierMap {
183    match harness {
184        "claude-code" => builtin_claude_code(),
185        "codex" => builtin_codex(),
186        _ => builtin_default(),
187    }
188}
189
190pub fn detect_harness() -> String {
191    if ["CLAUDE_CODE_SESSION", "CLAUDE_CODE", "CLAUDECODE"]
192        .iter()
193        .any(|key| std::env::var_os(key).is_some())
194    {
195        "claude-code".to_string()
196    } else if ["CODEX_SESSION", "CODEX_THREAD_ID", "CODEX_CLI", "CODEX"]
197        .iter()
198        .any(|key| std::env::var_os(key).is_some())
199    {
200        "codex".to_string()
201    } else if ["OPENCODE_CLIENT", "OPENCODE"]
202        .iter()
203        .any(|key| std::env::var_os(key).is_some())
204    {
205        "opencode".to_string()
206    } else {
207        "default".to_string()
208    }
209}
210
211pub fn harness_key_for_agent_name(agent_name: &str) -> String {
212    let normalized = agent_name
213        .trim()
214        .to_ascii_lowercase()
215        .replace(['_', ' '], "-");
216    match normalized.as_str() {
217        "claude" | "claude-code" | "claudecode" | "claude-code-cli" => "claude-code".to_string(),
218        "codex" | "codex-cli" | "openai-codex" => "codex".to_string(),
219        "opencode" | "open-code" | "opencode-ai" => "opencode".to_string(),
220        "" => "default".to_string(),
221        other => other.to_string(),
222    }
223}
224
225/// Resolve a `Tier` to a concrete model name for the given harness.
226///
227/// Tries the user's `[model.tiers.<harness>]` config first, then falls back to the
228/// built-in map for the harness. Returns `None` for `Tier::Auto`.
229pub fn resolve_tier_to_model(
230    tier: Tier,
231    harness: &str,
232    model_config: &ModelConfig,
233) -> Option<String> {
234    if matches!(tier, Tier::Auto) {
235        return None;
236    }
237    if let Some(map) = model_config.tiers.get(harness)
238        && let Some(name) = map.get(tier)
239    {
240        return Some(name.to_string());
241    }
242    builtin_for(harness).get(tier).map(|s| s.to_string())
243}
244
245fn claude_code_model_alias(model_name: &str) -> Option<&'static str> {
246    match model_name.trim() {
247        "opus" => Some(CLAUDE_CODE_OPUS_MODEL),
248        _ => None,
249    }
250}
251
252/// Returns `true` when `model_name` lacks a provider prefix required by the
253/// opencode harness. OpenCode expects `provider/model` syntax (e.g.
254/// `zai-coding-plan/glm-5.1`); bare names like `glm-5.1` are ambiguous.
255pub fn is_bare_model_name(model_name: &str) -> bool {
256    let trimmed = model_name.trim();
257    !trimmed.contains('/') || trimmed.starts_with('/')
258}
259
260/// Resolve harness-owned model aliases to the concrete model id agent-doc should
261/// launch and stamp in attribution. The Claude Code `opus` alias is intentionally
262/// versioned here so `claude_model: opus`, `/model opus`, and high-tier fallback
263/// all follow the same current Claude Code definition.
264///
265/// For the opencode harness, bare model names (without a `provider/` prefix)
266/// are rejected with a warning to stderr and returned unchanged so the caller
267/// can still proceed — but probe builders and callers should check
268/// `is_bare_model_name` before relying on the result for dispatch.
269pub fn canonical_model_name(
270    model_name: &str,
271    harness: &str,
272    _model_config: &ModelConfig,
273) -> String {
274    if harness == "claude-code"
275        && let Some(canonical) = claude_code_model_alias(model_name)
276    {
277        return canonical.to_string();
278    }
279    if harness == "opencode" && is_bare_model_name(model_name) {
280        eprintln!(
281            "[model_tier] WARNING: opencode model name {:?} lacks a provider prefix \
282             (expected \"provider/model\", e.g. \"zai-coding-plan/glm-5.1\"). \
283             Dispatch may fail or use a wrong provider.",
284            model_name.trim()
285        );
286    }
287    model_name.to_string()
288}
289
290/// Reverse lookup: given a concrete model name, find its tier in the harness's mapping.
291///
292/// Tries the user's config first, then falls back to the built-in map. Returns `None`
293/// if the model name doesn't appear in any tier slot for the harness.
294pub fn tier_from_model_name(
295    model_name: &str,
296    harness: &str,
297    model_config: &ModelConfig,
298) -> Option<Tier> {
299    if let Some(map) = model_config.tiers.get(harness)
300        && let Some(t) = map.tier_of(model_name)
301    {
302        return Some(t);
303    }
304    builtin_for(harness).tier_of(model_name).or_else(|| {
305        if harness == "claude-code" && claude_code_model_alias(model_name).is_some() {
306            Some(Tier::High)
307        } else {
308            None
309        }
310    })
311}
312
313/// Extract the value inside a `<!-- agent:model -->...<!-- /agent:model -->` component.
314///
315/// Returns the trimmed inner content if the component is present, `None` otherwise.
316/// This uses the existing component parser, so guards against fenced code blocks
317/// and inline code apply automatically.
318pub fn extract_model_component(content: &str) -> Option<String> {
319    let comps = crate::component::parse(content).ok()?;
320    let comp = comps.into_iter().find(|c| c.name == "model")?;
321    let inner = &content[comp.open_end..comp.close_start];
322    let trimmed = inner.trim();
323    if trimmed.is_empty() {
324        None
325    } else {
326        Some(trimmed.to_string())
327    }
328}
329
330/// Resolve a `<!-- agent:model -->` component value to a `Tier`.
331///
332/// Accepts tier names (`auto|low|med|high`) or concrete model names (resolved
333/// via the harness's tier map). Returns `None` if the value is unrecognized.
334pub fn component_value_to_tier(
335    value: &str,
336    harness: &str,
337    model_config: &ModelConfig,
338) -> Option<Tier> {
339    if let Ok(tier) = Tier::from_str(value) {
340        return Some(tier);
341    }
342    tier_from_model_name(value, harness, model_config)
343}
344
345/// Compute a `suggested_tier` from structural diff signals.
346///
347/// This is the deterministic, harness-agnostic heuristic used when no explicit
348/// tier source (inline command, component, frontmatter) is present.
349///
350/// Inputs:
351/// - `diff_type`: classification string from `diff::classify_diff` (e.g., `"simple_question"`)
352/// - `lines_added`: count of `+` lines in the unified diff (excluding `+++`)
353/// - `doc_path`: relative document path; certain prefixes bump the tier
354///
355/// Mapping (primary):
356/// - `simple_question`, `approval`, `boundary_artifact`, `annotation` → `Low`
357/// - `content_addition` < 10 lines → `Low`; ≥ 10 lines → `Med`
358/// - `multi_topic`, `structural_change` → `Med`
359/// - unknown / missing → `Med` (safe default)
360///
361/// Path boost: `tasks/software/` and `src/**/specs/` paths bump one tier (cap `High`).
362pub fn suggested_tier(
363    diff_type: Option<&str>,
364    lines_added: usize,
365    doc_path: &std::path::Path,
366) -> Tier {
367    let base = match diff_type {
368        Some("simple_question")
369        | Some("approval")
370        | Some("boundary_artifact")
371        | Some("annotation") => Tier::Low,
372        Some("content_addition") => {
373            if lines_added < 10 {
374                Tier::Low
375            } else {
376                Tier::Med
377            }
378        }
379        Some("multi_topic") | Some("structural_change") => Tier::Med,
380        _ => Tier::Med,
381    };
382
383    // Path boost: tasks/software/ → bump one tier (cap at High).
384    let path_str = doc_path.to_string_lossy();
385    let boost = path_str.contains("tasks/software/")
386        || path_str.contains("/specs/")
387        || path_str.contains("agent-doc-bugs")
388        || path_str.contains("plan-")
389        || path_str.contains("/plan.md");
390    if boost {
391        match base {
392            Tier::Auto | Tier::Low => Tier::Med,
393            Tier::Med => Tier::High,
394            Tier::High => Tier::High,
395        }
396    } else {
397        base
398    }
399}
400
401/// Result of scanning a unified diff for an inline `/model <x>` command.
402#[derive(Debug, Clone)]
403pub struct ModelSwitchScan {
404    /// The concrete model name from `/model <name>` (e.g., `"opus"`).
405    pub model_switch: Option<String>,
406    /// The resolved tier for the model switch (e.g., `Tier::High` for `opus`).
407    pub model_switch_tier: Option<Tier>,
408    /// The diff text with the `/model <x>` command line(s) stripped.
409    pub stripped_diff: String,
410}
411
412/// Scan a unified diff for an inline `/model <x>` command in user-added lines.
413///
414/// Behavior:
415/// - Only matches `+` lines (user additions), excluding `+++` headers.
416/// - Skips lines inside fenced code blocks (``` or ~~~).
417/// - Skips blockquote lines (`+>`).
418/// - Pattern: line content matches `/model <arg>` (whitespace allowed).
419/// - On match, the line is removed from the returned diff so it does not
420///   propagate to classification or response generation.
421/// - Only the first match is captured; subsequent `/model` lines are still stripped.
422///
423/// The `arg` is parsed via `parse_model_arg`, which accepts both tier names
424/// (`low|med|high`) and concrete model names (`opus|sonnet|...`).
425pub fn scan_model_switch(diff: &str, harness: &str, model_config: &ModelConfig) -> ModelSwitchScan {
426    let mut model_switch: Option<String> = None;
427    let mut model_switch_tier: Option<Tier> = None;
428    let mut kept_lines: Vec<&str> = Vec::with_capacity(diff.lines().count());
429
430    let mut in_fence = false;
431    let mut fence_char = '`';
432    let mut fence_len = 0usize;
433
434    for line in diff.lines() {
435        // Skip unified diff meta-lines unchanged.
436        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
437            kept_lines.push(line);
438            continue;
439        }
440
441        // Strip leading diff marker to inspect content.
442        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
443            &line[1..]
444        } else {
445            line
446        };
447
448        // Track code-fence state across all lines.
449        let trimmed = content.trim_start();
450        if !in_fence {
451            let fc = trimmed.chars().next().unwrap_or('\0');
452            if (fc == '`' || fc == '~')
453                && let fl = trimmed.chars().take_while(|&c| c == fc).count()
454                && fl >= 3
455            {
456                in_fence = true;
457                fence_char = fc;
458                fence_len = fl;
459                kept_lines.push(line);
460                continue;
461            }
462        } else {
463            let fc = trimmed.chars().next().unwrap_or('\0');
464            if fc == fence_char {
465                let fl = trimmed.chars().take_while(|&c| c == fc).count();
466                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
467                    in_fence = false;
468                    kept_lines.push(line);
469                    continue;
470                }
471            }
472        }
473
474        // Only consider `+` lines (excluding `+++`) for stripping.
475        let is_added = line.starts_with('+') && !line.starts_with("+++");
476        if !is_added {
477            kept_lines.push(line);
478            continue;
479        }
480
481        // In a fence — keep as-is (no stripping inside fences).
482        if in_fence {
483            kept_lines.push(line);
484            continue;
485        }
486
487        // Skip blockquotes.
488        if content.starts_with('>') {
489            kept_lines.push(line);
490            continue;
491        }
492
493        // Match `/model <arg>` pattern.
494        let stripped = content.trim_end();
495        if let Some(rest) = stripped.strip_prefix("/model")
496            && let Some(arg) = rest.split_whitespace().next()
497            && !arg.is_empty()
498        {
499            // Parse the arg into (tier, concrete_name).
500            if let Some((tier, name)) = parse_model_arg(arg, harness, model_config) {
501                if model_switch.is_none() {
502                    model_switch = Some(name);
503                    model_switch_tier = Some(tier);
504                }
505                // Drop the line from the diff regardless (always strip /model).
506                continue;
507            }
508            // Unknown arg — still strip the line to avoid /model leaking through.
509            continue;
510        }
511
512        kept_lines.push(line);
513    }
514
515    ModelSwitchScan {
516        model_switch,
517        model_switch_tier,
518        stripped_diff: kept_lines.join("\n"),
519    }
520}
521
522/// Compose the final `effective_tier` from all available sources.
523///
524/// Precedence (highest wins): inline `/model` command, then `<!-- agent:model -->`
525/// component, then `agent_doc_model_tier` frontmatter, then diff heuristic.
526/// `Tier::Auto` is a no-preference sentinel and falls through to the next source.
527pub fn compose_effective_tier(
528    model_switch_tier: Option<Tier>,
529    component_tier: Option<Tier>,
530    frontmatter_tier: Option<Tier>,
531    suggested: Tier,
532) -> Tier {
533    for candidate in [model_switch_tier, component_tier, frontmatter_tier] {
534        if let Some(t) = candidate
535            && !matches!(t, Tier::Auto)
536        {
537            return t;
538        }
539    }
540    suggested
541}
542
543/// Parse a `/model <arg>` argument: either a tier name (`low|med|high`) or a concrete
544/// model name (`opus|sonnet|...`).
545///
546/// Returns the resolved `Tier` and the concrete model name. Tier names resolve
547/// through config/built-ins, and harness-owned aliases such as Claude Code
548/// `opus` resolve to their current concrete model id.
549pub fn parse_model_arg(
550    arg: &str,
551    harness: &str,
552    model_config: &ModelConfig,
553) -> Option<(Tier, String)> {
554    let trimmed = arg.trim();
555    // Try parsing as a tier name first.
556    if let Ok(tier) = Tier::from_str(trimmed) {
557        if matches!(tier, Tier::Auto) {
558            return None;
559        }
560        let name = resolve_tier_to_model(tier, harness, model_config)
561            .unwrap_or_else(|| trimmed.to_string());
562        return Some((tier, name));
563    }
564    // Otherwise treat as a concrete model name and reverse-lookup the tier.
565    if let Some(tier) = tier_from_model_name(trimmed, harness, model_config) {
566        return Some((tier, canonical_model_name(trimmed, harness, model_config)));
567    }
568    // For opencode, reject bare model names (no provider prefix).
569    if harness == "opencode" && !is_bare_model_name(trimmed) {
570        return Some((Tier::Auto, canonical_model_name(trimmed, harness, model_config)));
571    }
572    // Unknown — accept the name but leave tier as Auto so it doesn't gate.
573    None
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use std::sync::Mutex;
580
581    static ENV_LOCK: Mutex<()> = Mutex::new(());
582
583    struct EnvRestore {
584        values: Vec<(&'static str, Option<std::ffi::OsString>)>,
585    }
586
587    impl EnvRestore {
588        fn clear(keys: &[&'static str]) -> Self {
589            let values = keys
590                .iter()
591                .map(|key| (*key, std::env::var_os(key)))
592                .collect::<Vec<_>>();
593            for key in keys {
594                // SAFETY: test holds the shared env lock before constructing this guard.
595                unsafe { std::env::remove_var(key) };
596            }
597            Self { values }
598        }
599    }
600
601    impl Drop for EnvRestore {
602        fn drop(&mut self) {
603            for (key, value) in &self.values {
604                match value {
605                    Some(value) => {
606                        // SAFETY: test holds the shared env lock while the guard is dropped.
607                        unsafe { std::env::set_var(key, value) };
608                    }
609                    None => {
610                        // SAFETY: test holds the shared env lock while the guard is dropped.
611                        unsafe { std::env::remove_var(key) };
612                    }
613                }
614            }
615        }
616    }
617
618    const HARNESS_ENV_KEYS: &[&str] = &[
619        "CLAUDE_CODE_SESSION",
620        "CLAUDE_CODE",
621        "CLAUDECODE",
622        "CODEX_SESSION",
623        "CODEX_THREAD_ID",
624        "CODEX_CLI",
625        "CODEX",
626        "OPENCODE_CLIENT",
627        "OPENCODE",
628    ];
629
630    #[test]
631    fn tier_ordering() {
632        assert!(Tier::Auto < Tier::Low);
633        assert!(Tier::Low < Tier::Med);
634        assert!(Tier::Med < Tier::High);
635        assert!(Tier::High > Tier::Low);
636        assert!(Tier::Med >= Tier::Med);
637    }
638
639    #[test]
640    fn tier_from_str_case_insensitive() {
641        assert_eq!("LOW".parse::<Tier>().unwrap(), Tier::Low);
642        assert_eq!("low".parse::<Tier>().unwrap(), Tier::Low);
643        assert_eq!("Low".parse::<Tier>().unwrap(), Tier::Low);
644        assert_eq!("AUTO".parse::<Tier>().unwrap(), Tier::Auto);
645        assert_eq!("med".parse::<Tier>().unwrap(), Tier::Med);
646        assert_eq!("medium".parse::<Tier>().unwrap(), Tier::Med);
647        assert_eq!("HIGH".parse::<Tier>().unwrap(), Tier::High);
648    }
649
650    #[test]
651    fn tier_from_str_invalid() {
652        assert!("ultra".parse::<Tier>().is_err());
653        assert!("".parse::<Tier>().is_err());
654        assert!("opus".parse::<Tier>().is_err());
655    }
656
657    #[test]
658    fn tier_display() {
659        assert_eq!(Tier::Low.to_string(), "low");
660        assert_eq!(Tier::Med.to_string(), "med");
661        assert_eq!(Tier::High.to_string(), "high");
662        assert_eq!(Tier::Auto.to_string(), "auto");
663    }
664
665    #[test]
666    fn harness_detection_returns_known_value() {
667        // Don't mutate env (Rust 2024 marks env mutators unsafe + tests may run
668        // in parallel). Just assert the function returns one of the known values.
669        let h = detect_harness();
670        assert!(
671            matches!(h.as_str(), "claude-code" | "codex" | "opencode" | "default"),
672            "unexpected harness: {h}"
673        );
674    }
675
676    #[test]
677    fn harness_key_for_agent_name_maps_cli_aliases() {
678        assert_eq!(harness_key_for_agent_name("claude"), "claude-code");
679        assert_eq!(harness_key_for_agent_name("claude_code"), "claude-code");
680        assert_eq!(harness_key_for_agent_name("codex"), "codex");
681        assert_eq!(harness_key_for_agent_name("opencode"), "opencode");
682    }
683
684    #[test]
685    fn harness_detection_recognizes_cli_environment_aliases() {
686        let _env_lock = ENV_LOCK
687            .lock()
688            .unwrap_or_else(|poisoned| poisoned.into_inner());
689        let _restore = EnvRestore::clear(HARNESS_ENV_KEYS);
690
691        // SAFETY: this test holds the shared env lock.
692        unsafe { std::env::set_var("CLAUDE_CODE", "1") };
693        assert_eq!(detect_harness(), "claude-code");
694        // SAFETY: this test holds the shared env lock.
695        unsafe { std::env::remove_var("CLAUDE_CODE") };
696
697        // SAFETY: this test holds the shared env lock.
698        unsafe { std::env::set_var("CODEX_THREAD_ID", "thread-123") };
699        assert_eq!(detect_harness(), "codex");
700        // SAFETY: this test holds the shared env lock.
701        unsafe { std::env::remove_var("CODEX_THREAD_ID") };
702
703        // SAFETY: this test holds the shared env lock.
704        unsafe { std::env::set_var("OPENCODE", "1") };
705        assert_eq!(detect_harness(), "opencode");
706    }
707
708    #[test]
709    fn resolve_builtin_claude_code() {
710        let cfg = ModelConfig::default();
711        assert_eq!(
712            resolve_tier_to_model(Tier::High, "claude-code", &cfg).as_deref(),
713            Some("claude-opus-4-7")
714        );
715        assert_eq!(
716            resolve_tier_to_model(Tier::Med, "claude-code", &cfg).as_deref(),
717            Some("sonnet")
718        );
719        assert_eq!(
720            resolve_tier_to_model(Tier::Low, "claude-code", &cfg).as_deref(),
721            Some("haiku")
722        );
723        assert_eq!(resolve_tier_to_model(Tier::Auto, "claude-code", &cfg), None);
724    }
725
726    #[test]
727    fn resolve_builtin_codex() {
728        let cfg = ModelConfig::default();
729        assert_eq!(
730            resolve_tier_to_model(Tier::High, "codex", &cfg).as_deref(),
731            Some("o3")
732        );
733        assert_eq!(
734            resolve_tier_to_model(Tier::Low, "codex", &cfg).as_deref(),
735            Some("gpt-4o-mini")
736        );
737    }
738
739    #[test]
740    fn resolve_unknown_harness_uses_default() {
741        let cfg = ModelConfig::default();
742        // Unknown harness falls through to the `default` built-in map.
743        assert_eq!(
744            resolve_tier_to_model(Tier::High, "junie", &cfg).as_deref(),
745            Some("opus")
746        );
747    }
748
749    #[test]
750    fn user_config_overrides_builtin() {
751        let mut cfg = ModelConfig::default();
752        let mut tiers = BTreeMap::new();
753        tiers.insert(
754            "claude-code".to_string(),
755            TierMap {
756                low: Some("haiku-3".to_string()),
757                med: Some("sonnet-4".to_string()),
758                high: Some("opus-4-1".to_string()),
759            },
760        );
761        cfg.tiers = tiers;
762        assert_eq!(
763            resolve_tier_to_model(Tier::High, "claude-code", &cfg).as_deref(),
764            Some("opus-4-1")
765        );
766    }
767
768    #[test]
769    fn tier_from_model_name_builtin() {
770        let cfg = ModelConfig::default();
771        assert_eq!(
772            tier_from_model_name("opus", "claude-code", &cfg),
773            Some(Tier::High)
774        );
775        assert_eq!(
776            tier_from_model_name("claude-opus-4-7", "claude-code", &cfg),
777            Some(Tier::High)
778        );
779        assert_eq!(
780            tier_from_model_name("sonnet", "claude-code", &cfg),
781            Some(Tier::Med)
782        );
783        assert_eq!(
784            tier_from_model_name("haiku", "claude-code", &cfg),
785            Some(Tier::Low)
786        );
787        assert_eq!(tier_from_model_name("unknown", "claude-code", &cfg), None);
788    }
789
790    #[test]
791    fn parse_model_arg_tier_name() {
792        let cfg = ModelConfig::default();
793        let (tier, name) = parse_model_arg("high", "claude-code", &cfg).unwrap();
794        assert_eq!(tier, Tier::High);
795        assert_eq!(name, "claude-opus-4-7");
796    }
797
798    #[test]
799    fn parse_model_arg_concrete_name() {
800        let cfg = ModelConfig::default();
801        let (tier, name) = parse_model_arg("opus", "claude-code", &cfg).unwrap();
802        assert_eq!(tier, Tier::High);
803        assert_eq!(name, "claude-opus-4-7");
804    }
805
806    #[test]
807    fn canonical_model_name_expands_claude_code_opus_alias() {
808        let cfg = ModelConfig::default();
809        assert_eq!(
810            canonical_model_name("opus", "claude-code", &cfg),
811            "claude-opus-4-7"
812        );
813        assert_eq!(canonical_model_name("opus", "codex", &cfg), "opus");
814    }
815
816    #[test]
817    fn is_bare_model_name_detects_missing_provider_prefix() {
818        assert!(is_bare_model_name("glm-5.1"));
819        assert!(is_bare_model_name("opus"));
820        assert!(is_bare_model_name("haiku"));
821        assert!(!is_bare_model_name("zai-coding-plan/glm-5.1"));
822        assert!(!is_bare_model_name("anthropic/claude-opus-4-7"));
823        assert!(is_bare_model_name("/leading-slash-only"));
824    }
825
826    #[test]
827    fn canonical_model_name_warns_on_bare_opencode_name() {
828        let cfg = ModelConfig::default();
829        assert_eq!(
830            canonical_model_name("glm-5.1", "opencode", &cfg),
831            "glm-5.1"
832        );
833        assert_eq!(
834            canonical_model_name("zai-coding-plan/glm-5.1", "opencode", &cfg),
835            "zai-coding-plan/glm-5.1"
836        );
837    }
838
839    #[test]
840    fn parse_model_arg_opencode_rejects_bare_name() {
841        let cfg = ModelConfig::default();
842        assert!(parse_model_arg("glm-5.1", "opencode", &cfg).is_none());
843    }
844
845    #[test]
846    fn parse_model_arg_opencode_accepts_provider_prefixed_name() {
847        let cfg = ModelConfig::default();
848        let (tier, name) = parse_model_arg("zai-coding-plan/glm-5.1", "opencode", &cfg).unwrap();
849        assert_eq!(tier, Tier::Auto);
850        assert_eq!(name, "zai-coding-plan/glm-5.1");
851    }
852
853    #[test]
854    fn parse_model_arg_unknown() {
855        let cfg = ModelConfig::default();
856        assert!(parse_model_arg("xyz-3000", "claude-code", &cfg).is_none());
857    }
858
859    #[test]
860    fn parse_model_arg_auto_rejected() {
861        let cfg = ModelConfig::default();
862        assert!(parse_model_arg("auto", "claude-code", &cfg).is_none());
863    }
864
865    #[test]
866    fn extract_model_component_present() {
867        let doc = "# Title\n\n<!-- agent:model -->\nhigh\n<!-- /agent:model -->\n\nbody\n";
868        assert_eq!(extract_model_component(doc).as_deref(), Some("high"));
869    }
870
871    #[test]
872    fn extract_model_component_absent() {
873        let doc = "# Title\n\nbody only\n";
874        assert_eq!(extract_model_component(doc), None);
875    }
876
877    #[test]
878    fn extract_model_component_empty_inner() {
879        let doc = "<!-- agent:model -->\n<!-- /agent:model -->\n";
880        assert_eq!(extract_model_component(doc), None);
881    }
882
883    #[test]
884    fn extract_model_component_concrete_name() {
885        let doc = "<!-- agent:model -->\nopus\n<!-- /agent:model -->\n";
886        assert_eq!(extract_model_component(doc).as_deref(), Some("opus"));
887    }
888
889    #[test]
890    fn component_value_to_tier_tier_name() {
891        let cfg = ModelConfig::default();
892        assert_eq!(
893            component_value_to_tier("high", "claude-code", &cfg),
894            Some(Tier::High)
895        );
896    }
897
898    #[test]
899    fn component_value_to_tier_concrete_name() {
900        let cfg = ModelConfig::default();
901        assert_eq!(
902            component_value_to_tier("opus", "claude-code", &cfg),
903            Some(Tier::High)
904        );
905    }
906
907    #[test]
908    fn component_value_to_tier_unknown() {
909        let cfg = ModelConfig::default();
910        assert_eq!(component_value_to_tier("xyz", "claude-code", &cfg), None);
911    }
912
913    #[test]
914    fn suggested_tier_simple_question() {
915        let path = std::path::Path::new("tasks/research/x.md");
916        assert_eq!(suggested_tier(Some("simple_question"), 1, path), Tier::Low);
917    }
918
919    #[test]
920    fn suggested_tier_small_addition() {
921        let path = std::path::Path::new("tasks/research/x.md");
922        assert_eq!(suggested_tier(Some("content_addition"), 5, path), Tier::Low);
923    }
924
925    #[test]
926    fn suggested_tier_large_addition() {
927        let path = std::path::Path::new("tasks/research/x.md");
928        assert_eq!(
929            suggested_tier(Some("content_addition"), 50, path),
930            Tier::Med
931        );
932    }
933
934    #[test]
935    fn suggested_tier_default_for_unknown() {
936        let path = std::path::Path::new("tasks/research/x.md");
937        assert_eq!(suggested_tier(None, 0, path), Tier::Med);
938    }
939
940    #[test]
941    fn suggested_tier_path_boost_software() {
942        let path = std::path::Path::new("tasks/software/foo.md");
943        // Low gets boosted to Med
944        assert_eq!(suggested_tier(Some("simple_question"), 1, path), Tier::Med);
945        // Med gets boosted to High
946        assert_eq!(
947            suggested_tier(Some("content_addition"), 50, path),
948            Tier::High
949        );
950    }
951
952    #[test]
953    fn suggested_tier_path_boost_caps_at_high() {
954        let path = std::path::Path::new("tasks/software/foo.md");
955        // Already High stays High
956        let t = suggested_tier(Some("content_addition"), 50, path);
957        assert_eq!(t, Tier::High);
958    }
959
960    #[test]
961    fn compose_effective_tier_model_switch_wins() {
962        let t = compose_effective_tier(
963            Some(Tier::High),
964            Some(Tier::Low),
965            Some(Tier::Med),
966            Tier::Low,
967        );
968        assert_eq!(t, Tier::High);
969    }
970
971    #[test]
972    fn compose_effective_tier_component_beats_frontmatter() {
973        let t = compose_effective_tier(None, Some(Tier::High), Some(Tier::Low), Tier::Med);
974        assert_eq!(t, Tier::High);
975    }
976
977    #[test]
978    fn compose_effective_tier_frontmatter_beats_heuristic() {
979        let t = compose_effective_tier(None, None, Some(Tier::High), Tier::Low);
980        assert_eq!(t, Tier::High);
981    }
982
983    #[test]
984    fn compose_effective_tier_falls_through_to_heuristic() {
985        let t = compose_effective_tier(None, None, None, Tier::Med);
986        assert_eq!(t, Tier::Med);
987    }
988
989    #[test]
990    fn scan_model_switch_concrete_name() {
991        let cfg = ModelConfig::default();
992        let diff = "@@ -1,3 +1,4 @@\n context\n+/model opus\n+real edit\n";
993        let result = scan_model_switch(diff, "claude-code", &cfg);
994        assert_eq!(result.model_switch.as_deref(), Some("claude-opus-4-7"));
995        assert_eq!(result.model_switch_tier, Some(Tier::High));
996        assert!(!result.stripped_diff.contains("/model opus"));
997        assert!(result.stripped_diff.contains("real edit"));
998    }
999
1000    #[test]
1001    fn scan_model_switch_tier_name() {
1002        let cfg = ModelConfig::default();
1003        let diff = "+/model high\n+other line\n";
1004        let result = scan_model_switch(diff, "claude-code", &cfg);
1005        assert_eq!(result.model_switch_tier, Some(Tier::High));
1006        assert_eq!(result.model_switch.as_deref(), Some("claude-opus-4-7"));
1007        assert!(!result.stripped_diff.contains("/model high"));
1008    }
1009
1010    #[test]
1011    fn scan_model_switch_haiku() {
1012        let cfg = ModelConfig::default();
1013        let diff = "+/model haiku\n";
1014        let result = scan_model_switch(diff, "claude-code", &cfg);
1015        assert_eq!(result.model_switch_tier, Some(Tier::Low));
1016    }
1017
1018    #[test]
1019    fn scan_model_switch_inside_fenced_code_ignored() {
1020        let cfg = ModelConfig::default();
1021        let diff = "+```\n+/model opus\n+```\n+real line\n";
1022        let result = scan_model_switch(diff, "claude-code", &cfg);
1023        assert_eq!(result.model_switch, None);
1024        assert!(result.stripped_diff.contains("/model opus"));
1025    }
1026
1027    #[test]
1028    fn scan_model_switch_inside_blockquote_ignored() {
1029        let cfg = ModelConfig::default();
1030        let diff = "+> /model opus\n+real line\n";
1031        let result = scan_model_switch(diff, "claude-code", &cfg);
1032        assert_eq!(result.model_switch, None);
1033        assert!(result.stripped_diff.contains("/model opus"));
1034    }
1035
1036    #[test]
1037    fn scan_model_switch_only_added_lines() {
1038        let cfg = ModelConfig::default();
1039        // Context line with /model is NOT a user addition.
1040        let diff = " /model opus\n+real line\n";
1041        let result = scan_model_switch(diff, "claude-code", &cfg);
1042        assert_eq!(result.model_switch, None);
1043    }
1044
1045    #[test]
1046    fn scan_model_switch_no_match() {
1047        let cfg = ModelConfig::default();
1048        let diff = "+just a normal line\n+another\n";
1049        let result = scan_model_switch(diff, "claude-code", &cfg);
1050        assert_eq!(result.model_switch, None);
1051        // Diff is unchanged (modulo trailing newline normalization).
1052        assert!(result.stripped_diff.contains("just a normal line"));
1053        assert!(result.stripped_diff.contains("another"));
1054    }
1055
1056    #[test]
1057    fn scan_model_switch_unknown_arg_still_stripped() {
1058        let cfg = ModelConfig::default();
1059        // Unknown arg → no tier captured but line still stripped.
1060        let diff = "+/model xyz-3000\n+real line\n";
1061        let result = scan_model_switch(diff, "claude-code", &cfg);
1062        assert_eq!(result.model_switch, None);
1063        assert!(!result.stripped_diff.contains("/model xyz-3000"));
1064        assert!(result.stripped_diff.contains("real line"));
1065    }
1066
1067    #[test]
1068    fn scan_model_switch_first_match_wins() {
1069        let cfg = ModelConfig::default();
1070        let diff = "+/model opus\n+/model haiku\n";
1071        let result = scan_model_switch(diff, "claude-code", &cfg);
1072        assert_eq!(result.model_switch.as_deref(), Some("claude-opus-4-7"));
1073        // Both lines stripped.
1074        assert!(!result.stripped_diff.contains("/model"));
1075    }
1076
1077    #[test]
1078    fn compose_effective_tier_auto_falls_through() {
1079        // Auto values should fall through to next source.
1080        let t = compose_effective_tier(
1081            Some(Tier::Auto),
1082            Some(Tier::Auto),
1083            Some(Tier::High),
1084            Tier::Low,
1085        );
1086        assert_eq!(t, Tier::High);
1087    }
1088}