Skip to main content

agent_doc_model_tier/
lib.rs

1//! # Crate: agent-doc-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
50pub mod context_transcript_io;
51pub mod context_usage;
52
53/// The Claude Code `opus` model alias. It is **deferred**, not version-pinned:
54/// agent-doc passes it through verbatim to `claude --model opus`, which Claude
55/// Code resolves to its current latest opus. Keeping it unversioned means launch
56/// (`--model`) and `### Re:` attribution can never silently lag a Claude Code
57/// opus release — the concrete id is owned by Claude Code, and attribution is
58/// self-stamped by the running agent (see `resolve_agent_model`).
59const CLAUDE_CODE_OPUS_ALIAS: &str = "opus";
60
61/// Harness-agnostic model complexity tier.
62///
63/// Ordering: `Auto < Low < Med < High`. Gating logic uses a simple `>` comparison —
64/// a task whose effective tier exceeds the running model's tier should prompt the
65/// user to switch models.
66#[derive(
67    Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
68)]
69#[serde(rename_all = "lowercase")]
70pub enum Tier {
71    /// No preference; fall through to next source in the precedence chain.
72    #[default]
73    Auto,
74    /// Cheap, fast model — small content additions, simple questions.
75    Low,
76    /// Default working model — multi-section edits, planning, moderate diffs.
77    Med,
78    /// Powerful model — complex debugging, architecture decisions, large code changes.
79    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/// Per-harness tier → concrete model name map.
111///
112/// Configured under `[model.tiers.<harness>]` in the global config.
113#[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    /// Reverse lookup: find which tier a concrete model name belongs to.
134    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/// Global `[model]` config section.
148#[derive(Debug, Default, Clone, Serialize, Deserialize)]
149pub struct ModelConfig {
150    /// Whether automatic tier-based recommendations are enabled (default: true).
151    #[serde(default = "default_auto")]
152    pub auto: bool,
153    /// Per-harness tier → model name maps. Key is the harness name
154    /// (e.g., `claude-code`, `codex`, `default`).
155    #[serde(default)]
156    pub tiers: BTreeMap<String, TierMap>,
157}
158
159fn default_auto() -> bool {
160    true
161}
162
163/// Built-in tier map for the `claude-code` harness.
164fn 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
172/// Built-in tier map for the `codex` harness.
173fn 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
181/// Built-in fallback tier map.
182fn 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
190/// Return the built-in tier map for a known harness, or `default` for unknowns.
191fn 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
314/// Resolve a `Tier` to a concrete model name for the given harness.
315///
316/// Tries the user's `[model.tiers.<harness>]` config first, then falls back to the
317/// built-in map for the harness. Returns `None` for `Tier::Auto`.
318pub 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
334/// True for any model id that names a Claude Code opus: the deferred bare
335/// `opus` alias or a concrete `claude-opus-*` / `opus-*` id a user may pin.
336/// Used for High-tier classification. agent-doc deliberately does not store the
337/// concrete current opus version — Claude Code owns it.
338fn 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
343/// Returns `true` when `model_name` lacks a provider prefix required by the
344/// opencode harness. OpenCode expects `provider/model` syntax (e.g.
345/// `zai-coding-plan/glm-5.1`); bare names like `glm-5.1` are ambiguous.
346pub fn is_bare_model_name(model_name: &str) -> bool {
347    let trimmed = model_name.trim();
348    !trimmed.contains('/') || trimmed.starts_with('/')
349}
350
351/// Resolve harness-owned model aliases to the model id agent-doc should launch
352/// and stamp in attribution. The Claude Code `opus`/`sonnet`/`haiku` aliases are
353/// **deferred**: they pass through unchanged so `claude --model <alias>` resolves
354/// the current latest model itself. agent-doc no longer pins a concrete opus
355/// version — `claude_model: opus`, `/model opus`, and the high-tier fallback all
356/// emit `--model opus`, and attribution self-stamps the running model (see
357/// `resolve_agent_model`). Explicit concrete ids (e.g. `claude-opus-4-8`) pass
358/// through unchanged for users who pin a version.
359///
360/// For the opencode harness, bare model names (without a `provider/` prefix)
361/// are rejected with a warning to stderr and returned unchanged so the caller
362/// can still proceed — but probe builders and callers should check
363/// `is_bare_model_name` before relying on the result for dispatch.
364pub 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
400/// Reverse lookup: given a concrete model name, find its tier in the harness's mapping.
401///
402/// Tries the user's config first, then falls back to the built-in map. Returns `None`
403/// if the model name doesn't appear in any tier slot for the harness.
404pub 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
423/// Extract the value inside a `<!-- agent:model -->...<!-- /agent:model -->` component.
424///
425/// Returns the trimmed inner content if the component is present, `None` otherwise.
426/// This uses the shared markdown overlay parser, so fenced/indented code blocks
427/// do not produce synthetic model components.
428pub 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
447/// Resolve a `<!-- agent:model -->` component value to a `Tier`.
448///
449/// Accepts tier names (`auto|low|med|high`) or concrete model names (resolved
450/// via the harness's tier map). Returns `None` if the value is unrecognized.
451pub 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
462/// Compute a `suggested_tier` from structural diff signals.
463///
464/// This is the deterministic, harness-agnostic heuristic used when no explicit
465/// tier source (inline command, component, frontmatter) is present.
466///
467/// Inputs:
468/// - `diff_type`: classification string from `diff::classify_diff` (e.g., `"simple_question"`)
469/// - `lines_added`: count of `+` lines in the unified diff (excluding `+++`)
470/// - `doc_path`: relative document path; certain prefixes bump the tier
471///
472/// Mapping (primary):
473/// - `simple_question`, `approval`, `boundary_artifact`, `annotation` → `Low`
474/// - `content_addition` < 10 lines → `Low`; ≥ 10 lines → `Med`
475/// - `multi_topic`, `structural_change` → `Med`
476/// - unknown / missing → `Med` (safe default)
477///
478/// Path boost: `tasks/software/` and `src/**/specs/` paths bump one tier (cap `High`).
479pub 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    // Path boost: tasks/software/ → bump one tier (cap at High).
501    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/// Result of scanning a unified diff for an inline `/model <x>` command.
519#[derive(Debug, Clone)]
520pub struct ModelSwitchScan {
521    /// The concrete model name from `/model <name>` (e.g., `"opus"`).
522    pub model_switch: Option<String>,
523    /// The resolved tier for the model switch (e.g., `Tier::High` for `opus`).
524    pub model_switch_tier: Option<Tier>,
525    /// The diff text with the `/model <x>` command line(s) stripped.
526    pub stripped_diff: String,
527}
528
529/// Scan a unified diff for an inline `/model <x>` command in user-added lines.
530///
531/// Behavior:
532/// - Only matches `+` lines (user additions), excluding `+++` headers.
533/// - Skips lines inside fenced code blocks (``` or ~~~).
534/// - Skips blockquote lines (`+>`).
535/// - Pattern: line content matches `/model <arg>` (whitespace allowed).
536/// - On match, the line is removed from the returned diff so it does not
537///   propagate to classification or response generation.
538/// - Only the first match is captured; subsequent `/model` lines are still stripped.
539///
540/// The `arg` is parsed via `parse_model_arg`, which accepts both tier names
541/// (`low|med|high`) and concrete model names (`opus|sonnet|...`).
542pub 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        // Skip unified diff meta-lines unchanged.
553        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
554            kept_lines.push(line);
555            continue;
556        }
557
558        // Strip leading diff marker to inspect content.
559        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
560            &line[1..]
561        } else {
562            line
563        };
564
565        // Track code-fence state across all lines.
566        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        // Only consider `+` lines (excluding `+++`) for stripping.
592        let is_added = line.starts_with('+') && !line.starts_with("+++");
593        if !is_added {
594            kept_lines.push(line);
595            continue;
596        }
597
598        // In a fence — keep as-is (no stripping inside fences).
599        if in_fence {
600            kept_lines.push(line);
601            continue;
602        }
603
604        // Skip blockquotes.
605        if content.starts_with('>') {
606            kept_lines.push(line);
607            continue;
608        }
609
610        // Match `/model <arg>` pattern.
611        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            // Parse the arg into (tier, concrete_name).
617            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                // Drop the line from the diff regardless (always strip /model).
623                continue;
624            }
625            // Unknown arg — still strip the line to avoid /model leaking through.
626            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
639/// Compose the final `effective_tier` from all available sources.
640///
641/// Precedence (highest wins): inline `/model` command, then `<!-- agent:model -->`
642/// component, then `agent_doc_model_tier` frontmatter, then diff heuristic.
643/// `Tier::Auto` is a no-preference sentinel and falls through to the next source.
644pub 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
660/// Parse a `/model <arg>` argument: either a tier name (`low|med|high`) or a concrete
661/// model name (`opus|sonnet|...`).
662///
663/// Returns the resolved `Tier` and the concrete model name. Tier names resolve
664/// through config/built-ins, and harness-owned aliases such as Claude Code
665/// `opus` resolve to their current concrete model id.
666pub fn parse_model_arg(
667    arg: &str,
668    harness: &str,
669    model_config: &ModelConfig,
670) -> Option<(Tier, String)> {
671    let trimmed = arg.trim();
672    // Try parsing as a tier name first.
673    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    // Otherwise treat as a concrete model name and reverse-lookup the tier.
682    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    // For opencode, reject bare model names (no provider prefix).
686    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    // Unknown — accept the name but leave tier as Auto so it doesn't gate.
693    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                // SAFETY: test holds the shared env lock before constructing this guard.
715                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                        // SAFETY: test holds the shared env lock while the guard is dropped.
727                        unsafe { std::env::set_var(key, value) };
728                    }
729                    None => {
730                        // SAFETY: test holds the shared env lock while the guard is dropped.
731                        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        // Don't mutate env (Rust 2024 marks env mutators unsafe + tests may run
788        // in parallel). Just assert the function returns one of the known values.
789        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        // SAFETY: this test holds the shared env lock.
895        unsafe { std::env::set_var("CLAUDE_CODE", "1") };
896        assert_eq!(detect_harness(), "claude-code");
897        // SAFETY: this test holds the shared env lock.
898        unsafe { std::env::remove_var("CLAUDE_CODE") };
899
900        // SAFETY: this test holds the shared env lock.
901        unsafe { std::env::set_var("CODEX_THREAD_ID", "thread-123") };
902        assert_eq!(detect_harness(), "codex");
903        // SAFETY: this test holds the shared env lock.
904        unsafe { std::env::remove_var("CODEX_THREAD_ID") };
905
906        // SAFETY: this test holds the shared env lock.
907        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        // Unknown harness falls through to the `default` built-in map.
946        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        // `opus` is deferred — it passes through verbatim so `claude --model opus`
1013        // resolves the current latest opus itself (no pinned version).
1014        assert_eq!(canonical_model_name("opus", "claude-code", &cfg), "opus");
1015        assert_eq!(canonical_model_name("opus", "codex", &cfg), "opus");
1016        // Concrete pinned ids pass through unchanged for users who want a version.
1017        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        // Deferred alias and any concrete opus id both gate as High tier.
1027        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        // Low gets boosted to Med
1225        assert_eq!(suggested_tier(Some("simple_question"), 1, path), Tier::Med);
1226        // Med gets boosted to High
1227        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        // Already High stays High
1237        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        // Context line with /model is NOT a user addition.
1321        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        // Diff is unchanged (modulo trailing newline normalization).
1333        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        // Unknown arg → no tier captured but line still stripped.
1341        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        // Both lines stripped.
1355        assert!(!result.stripped_diff.contains("/model"));
1356    }
1357
1358    #[test]
1359    fn compose_effective_tier_auto_falls_through() {
1360        // Auto values should fall through to next source.
1361        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}