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