//! `hrdr-agent` — the agentic loop.
//!
//! Drives an OpenAI-compatible model through tool calls until a coding task is
//! complete: stream a turn, execute any requested tools, feed the results back,
//! repeat. Emits [`AgentEvent`]s for a UI (or stdout) to render live.

mod auth;
mod prompt;
mod session;

pub use auth::{auth_file_path, auth_token, load_auth_tokens, save_auth_token};
pub use session::{
    Session, SessionMeta, cwd_slug, list_sessions, resolve_session, sessions_dir, unique_session_id,
};

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use anyhow::{Context, Result, bail};
use futures_util::StreamExt;
use hrdr_llm::{Accumulator, ChatMessage, ChatStream, Client, Role, ToolDef};
use hrdr_tools::{Checkpoints, TodoItem, ToolContext, ToolRegistry};

pub use prompt::{gather_agent_docs, render_system};

/// Events emitted as a turn progresses.
#[derive(Debug, Clone)]
pub enum AgentEvent {
    /// A streamed delta of model "thinking" (reasoning channel).
    Reasoning(String),
    /// A streamed delta of assistant text.
    Text(String),
    /// A tool call is about to run.
    ToolStart {
        id: String,
        name: String,
        args: String,
    },
    /// A chunk of live output streamed by a running tool (e.g. `bash`).
    ToolOutput { id: String, chunk: String },
    /// A tool call finished.
    ToolEnd {
        id: String,
        name: String,
        result: String,
        ok: bool,
    },
    /// Token usage reported for the latest model call (when the server sends it).
    Usage {
        prompt_tokens: u32,
        completion_tokens: u32,
    },
    /// An out-of-band notice from the agent (e.g. a retry or auto-compaction),
    /// surfaced to the user as a system line.
    Notice(String),
    /// A steering message (submitted mid-turn) was just delivered into the
    /// conversation — the frontend shows it as a user message at this point, so
    /// display order matches the model's view.
    Steered(String),
    /// The model produced a final answer with no further tool calls.
    TurnDone,
}

/// A shared FIFO of user messages submitted *during* a running turn ("steering").
/// The frontend pushes to it while a turn runs; [`Agent::run`] drains it before
/// each model request, so a correction reaches the model after the current tool
/// round instead of only after the whole turn finishes.
pub type SteeringQueue = Arc<Mutex<std::collections::VecDeque<String>>>;

/// Create an empty [`SteeringQueue`].
pub fn steering_queue() -> SteeringQueue {
    Arc::new(Mutex::new(std::collections::VecDeque::new()))
}

/// Agent configuration.
#[derive(Debug, Clone)]
pub struct AgentConfig {
    pub base_url: String,
    pub api_key: Option<String>,
    pub model: String,
    pub cwd: PathBuf,
    pub temperature: Option<f32>,
    /// Safety bound on tool-call iterations per user turn.
    pub max_steps: usize,
    /// Named provider preset (e.g. `zen`, `openai`, `local`). Resolved by the
    /// binary into `base_url`/`api_key`/backend behaviour via [`resolve_provider`].
    pub provider: Option<String>,
    /// Model context window in tokens, for the status bar's "X of Y" display.
    /// Probed from the endpoint when unset; set in config to override.
    pub context_window: Option<u32>,
    /// Reasoning-effort label shown in the status bar (e.g. `low`/`medium`/`high`).
    pub effort: Option<String>,
    /// Auto-compaction trigger as a fraction of the context window (`0.0`–`1.0`);
    /// `0` (or any value outside that range) disables it. Default
    /// [`DEFAULT_AUTO_COMPACT`]. The value now gates on/off only; the *trigger
    /// point* is set by [`compaction_reserved`](Self::compaction_reserved).
    pub auto_compact: f64,
    /// Token buffer reserved below the context window: auto-compaction fires when
    /// usage reaches `context_window − compaction_reserved` (opencode's reserved
    /// model). Default [`DEFAULT_COMPACTION_RESERVED`].
    pub compaction_reserved: u32,
    /// Prune old tool-call *output* from the model history before each request:
    /// bodies older than the recent protected window are replaced with a short
    /// placeholder (the tool call + args stay). Cheap, no model call — the first
    /// line of defence against tool output ballooning context, before
    /// compaction. Off leaves every result verbatim. Default `true`. Only the
    /// model-facing history is touched; the UI transcript keeps the full output.
    pub auto_prune: bool,
    /// File checkpointing: `on`, `off`, or `auto` (default) — `auto` enables it
    /// only outside a git repo (git already provides revert).
    pub checkpoints: Option<String>,
    /// User-defined providers from `[providers.<name>]` in config, keyed by name.
    pub providers: HashMap<String, ProviderConfig>,
    /// Extra shell guardrails from `[[guardrails]]` in config, applied on top
    /// of the built-in rules.
    pub guardrails: Vec<GuardrailConfig>,
    /// Let `write`/`edit` touch paths outside the working directory
    /// (default `false`; the system temp dir is always allowed).
    pub allow_outside_cwd: bool,
    /// Post-edit hooks from `[[hooks]]` in config (formatters, mostly).
    pub hooks: Vec<HookConfig>,
    /// Per-tool output byte cap before truncation (`[tool_output] max_bytes`).
    /// Larger `bash`/`grep` output is truncated and the full text saved to disk.
    pub tool_max_bytes: usize,
    /// Per-tool output line cap before truncation (`[tool_output] max_lines`),
    /// applied alongside [`tool_max_bytes`](Self::tool_max_bytes).
    pub tool_max_lines: usize,
    /// Recent turns kept verbatim through compaction (`compaction_tail_turns`).
    /// Default [`DEFAULT_TAIL_TURNS`].
    pub compaction_tail_turns: usize,
    /// Token budget for the verbatim tail kept through compaction
    /// (`preserve_recent_tokens`). Default [`DEFAULT_PRESERVE_RECENT_TOKENS`].
    pub preserve_recent_tokens: u32,
    /// MCP servers from `[[mcp]]` config; connected by [`Agent::connect_mcp`].
    pub mcp: Vec<McpServerConfig>,
    /// Prompt-caching mode: `off`, `on` (alias `ephemeral`), or `auto` (default).
    /// `auto` emits `cache_control` breakpoints for remote endpoints and skips
    /// them for a local server (which may reject the content-parts form). `None`
    /// means `auto`. See [`resolve_cache_mode`].
    pub prompt_cache: Option<String>,
}

/// Default auto-compaction trigger: 85% of the context window (leaves headroom
/// so the next turn doesn't overflow).
pub const DEFAULT_AUTO_COMPACT: f64 = 0.85;

/// Default token buffer reserved below the context window before auto-compaction
/// fires — compaction triggers once usage reaches `context_window − reserved`,
/// leaving room for the next turn's output. Matches pi's `reserveTokens` default.
pub const DEFAULT_COMPACTION_RESERVED: u32 = 16_384;

/// Tool-output pruning keeps the most recent this-many estimated tokens of tool
/// output verbatim; older bodies are cleared. Matches opencode's `PRUNE_PROTECT`.
const PRUNE_PROTECT_TOKENS: u32 = 40_000;
/// Only prune when at least this many tokens would actually be reclaimed —
/// clearing a few small results isn't worth the lost detail. Matches opencode's
/// `PRUNE_MINIMUM`.
const PRUNE_MINIMUM_TOKENS: u32 = 20_000;
/// The most recent this-many turns (user messages) are never pruned, so the
/// model always keeps the tool output it's actively working with.
const PRUNE_KEEP_TURNS: usize = 2;
/// Replacement body for a pruned tool result (the tool call + args are kept).
const PRUNE_PLACEHOLDER: &str = "[old tool output cleared to save context]";

/// With this many tool rounds left in a turn, the model is told to wrap up
/// (appended to the last tool result of that round).
const WRAP_UP_WARNING_ROUNDS: usize = 3;

/// Consecutive identical failures after which the exact same call is refused
/// without executing (small models loop on verbatim retries).
const REPEAT_REFUSE_AFTER: u32 = 2;

/// Anti-loop breaker: tracks the last failed call and how many times the
/// *exact same* call (tool + raw args) has failed in a row. Any intervening
/// different call — or a success — resets it, so a legitimate
/// `test → edit → test` retry cycle is never blocked; only verbatim
/// fail-retry-fail loops are.
#[derive(Default)]
struct RepeatGuard {
    key: Option<u64>,
    failures: u32,
}

fn call_key(name: &str, raw_args: &str) -> u64 {
    use std::hash::{Hash, Hasher};
    let mut h = std::collections::hash_map::DefaultHasher::new();
    name.hash(&mut h);
    raw_args.hash(&mut h);
    h.finish()
}

impl RepeatGuard {
    /// The refusal message when this call must not run again (it already
    /// failed [`REPEAT_REFUSE_AFTER`]+ times in a row), else `None`.
    fn refusal(&self, name: &str, raw_args: &str) -> Option<String> {
        (self.key == Some(call_key(name, raw_args)) && self.failures >= REPEAT_REFUSE_AFTER).then(
            || {
                format!(
                    "refused without running: this exact {name} call already failed {} \
                     times in a row — change the arguments or the approach; if you're \
                     stuck, stop and tell the user what you tried",
                    self.failures
                )
            },
        )
    }

    /// Record a call's outcome; on a repeated failure returns the nudge to
    /// append to the error the model sees.
    fn record(&mut self, name: &str, raw_args: &str, ok: bool) -> Option<String> {
        let k = call_key(name, raw_args);
        if self.key != Some(k) {
            self.key = Some(k);
            self.failures = u32::from(!ok);
            return None;
        }
        if ok {
            self.key = None;
            self.failures = 0;
            return None;
        }
        self.failures += 1;
        Some(format!(
            "\n[note: this exact call has failed {} times in a row — change the input \
             or approach instead of retrying it verbatim]",
            self.failures
        ))
    }
}

impl Default for AgentConfig {
    fn default() -> Self {
        Self {
            base_url: "http://localhost:8080/v1".to_string(),
            api_key: None,
            model: "default".to_string(),
            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
            temperature: None,
            max_steps: 50,
            provider: None,
            context_window: None,
            effort: None,
            auto_compact: DEFAULT_AUTO_COMPACT,
            compaction_reserved: DEFAULT_COMPACTION_RESERVED,
            auto_prune: true,
            checkpoints: None,
            providers: HashMap::new(),
            guardrails: Vec::new(),
            allow_outside_cwd: false,
            hooks: Vec::new(),
            tool_max_bytes: hrdr_tools::DEFAULT_MAX_OUTPUT,
            tool_max_lines: hrdr_tools::DEFAULT_MAX_OUTPUT_LINES,
            compaction_tail_turns: DEFAULT_TAIL_TURNS,
            preserve_recent_tokens: DEFAULT_PRESERVE_RECENT_TOKENS,
            mcp: Vec::new(),
            prompt_cache: None,
        }
    }
}

impl AgentConfig {
    /// Resolve a provider name to a preset: a `[providers.<name>]` entry from
    /// config takes precedence over the built-ins (`zen`/`openai`/`local`).
    pub fn resolve_provider(&self, name: &str) -> Option<ResolvedProvider> {
        let lname = name.trim().to_ascii_lowercase();
        if let Some((_, c)) = self
            .providers
            .iter()
            .find(|(k, _)| k.to_ascii_lowercase() == lname)
        {
            return Some(ResolvedProvider {
                base_url: c.base_url.clone(),
                key_env: c.key_env.clone(),
                api_key: c.api_key.clone(),
                model: c.model.clone(),
                remote: c.remote.unwrap_or(true),
                context_window: c.context_window,
            });
        }
        builtin_provider(name)
    }
}

/// One MCP server from a `[[mcp]]` config entry, registered with its tools
/// namespaced `<name>_<tool>`. Three transports: **stdio** (set `command`)
/// spawns `command args…` with `env`; **HTTP** (set `url`) POSTs to a
/// Streamable-HTTP endpoint with `headers` (e.g. auth); **legacy HTTP+SSE**
/// (set `url` and `transport = "sse"`) opens a persistent SSE stream and POSTs
/// to the server-advertised endpoint. Exactly one of `command`/`url` is
/// required. `disabled = true` keeps the entry but skips it.
#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
pub struct McpServerConfig {
    /// Short name; namespaces the server's tools and labels its errors.
    pub name: String,
    /// stdio transport: executable to spawn (found on `PATH`).
    #[serde(default)]
    pub command: Option<String>,
    /// Arguments passed to `command`.
    #[serde(default)]
    pub args: Vec<String>,
    /// Extra environment variables for the `command` process.
    #[serde(default)]
    pub env: HashMap<String, String>,
    /// HTTP transport: the endpoint URL. Streamable-HTTP by default; legacy
    /// two-endpoint HTTP+SSE when `transport = "sse"`.
    #[serde(default)]
    pub url: Option<String>,
    /// HTTP transport selector: `"http"` (Streamable-HTTP, default) or `"sse"`
    /// (legacy HTTP+SSE). Ignored for the stdio transport.
    #[serde(default)]
    pub transport: Option<String>,
    /// Extra HTTP headers sent with every request (e.g. `Authorization`).
    #[serde(default)]
    pub headers: HashMap<String, String>,
    /// Keep the entry but don't connect.
    #[serde(default)]
    pub disabled: bool,
}

/// A user-defined provider from `[providers.<name>]` in config.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ProviderConfig {
    /// OpenAI-compatible base URL (including the `/v1` suffix).
    pub base_url: String,
    /// Environment variable holding the API key (preferred over an inline key).
    #[serde(default)]
    pub key_env: Option<String>,
    /// Inline API key (avoid in shared config; prefer `key_env`).
    #[serde(default)]
    pub api_key: Option<String>,
    /// Default model for this provider.
    #[serde(default)]
    pub model: Option<String>,
    /// Whether this is a remote/hosted provider that needs an API key
    /// (default: true). A local/self-hosted endpoint sets `false` to silence
    /// the missing-key and missing-model warnings.
    #[serde(default)]
    pub remote: Option<bool>,
    /// Model context window (for the status bar's "X of Y").
    #[serde(default)]
    pub context_window: Option<u32>,
}

/// One user-defined shell guardrail from a `[[guardrails]]` config entry:
/// commands matching `pattern` (a regex) are rejected with `message`. Applied
/// on top of the built-in rules (`hrdr_tools::default_guardrails`).
#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
pub struct GuardrailConfig {
    pub pattern: String,
    pub message: String,
}

/// One post-edit hook from a `[[hooks]]` config entry: after tool `on`
/// (`edit`, `write`, or `*`) successfully mutates a file matching
/// `glob`, run shell command `run` (`{path}` is substituted). Formatters,
/// mostly. Failures surface as warnings in the tool result, never as errors.
#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
pub struct HookConfig {
    /// Triggering tool; defaults to `*` (any file-mutating tool).
    #[serde(default = "default_hook_on")]
    pub on: String,
    /// File filter (matched against name and cwd-relative path); absent =
    /// every file.
    #[serde(default)]
    pub glob: Option<String>,
    /// Shell command template; `{path}` becomes the quoted file path.
    pub run: String,
    /// Per-run timeout in milliseconds (default 30000).
    #[serde(default)]
    pub timeout_ms: Option<u64>,
}

fn default_hook_on() -> String {
    "*".to_string()
}

/// A fully-resolved provider preset.
#[derive(Debug, Clone)]
pub struct ResolvedProvider {
    pub base_url: String,
    pub key_env: Option<String>,
    pub api_key: Option<String>,
    pub model: Option<String>,
    pub remote: bool,
    pub context_window: Option<u32>,
}

/// Canonical built-in provider names, in the order the `/login` wizard offers
/// them. Each resolves through [`builtin_provider`]; `local` needs no API key.
pub const BUILTIN_PROVIDERS: &[&str] = &["zen", "openai", "openrouter", "claude", "local"];

/// Resolve the API key for a provider: an inline key wins, then the provider's
/// `key_env` variable, then a credential saved by `/login`. `None` when none is
/// available (a keyless local endpoint, or a remote that hasn't been set up).
pub fn resolve_api_key(provider: &str, p: &ResolvedProvider) -> Option<String> {
    p.api_key
        .clone()
        .or_else(|| p.key_env.as_ref().and_then(|e| std::env::var(e).ok()))
        .or_else(|| auth_token(provider))
}

/// Resolve a built-in provider name (case-insensitive).
///
/// - `zen` / `opencode` — OpenCode Zen gateway (`OPENCODE_API_KEY`).
/// - `openai` — OpenAI (`OPENAI_API_KEY`).
/// - `local` / `infr` — a local OpenAI-compatible server you run yourself.
pub fn builtin_provider(name: &str) -> Option<ResolvedProvider> {
    let (base_url, key_env, remote) = match name.trim().to_ascii_lowercase().as_str() {
        "zen" | "opencode" | "opencode-zen" => {
            ("https://opencode.ai/zen/v1", "OPENCODE_API_KEY", true)
        }
        "openai" => ("https://api.openai.com/v1", "OPENAI_API_KEY", true),
        "openrouter" => ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", true),
        // Anthropic's own host → hrdr uses the native Messages API (`x-api-key`),
        // which unlocks prompt caching (the OpenAI-compat endpoint can't cache).
        "claude" | "anthropic" => ("https://api.anthropic.com/v1", "ANTHROPIC_API_KEY", true),
        "local" | "infr" => ("http://localhost:8080/v1", "HRDR_API_KEY", false),
        _ => return None,
    };
    Some(ResolvedProvider {
        base_url: base_url.to_string(),
        key_env: Some(key_env.to_string()),
        api_key: None,
        model: None,
        remote,
        context_window: None,
    })
}

/// `[tool_output]` config table: per-tool truncation thresholds. Mirrors
/// opencode's `tool_output`. Output over either limit is truncated and (for
/// `bash`/`grep`) the full text is saved to disk.
#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
pub struct ToolOutputConfig {
    /// Max output lines before truncation (default 2000).
    #[serde(default)]
    pub max_lines: Option<usize>,
    /// Max output bytes before truncation (default 51200).
    #[serde(default)]
    pub max_bytes: Option<usize>,
}

/// Subset of config.toml we parse; all fields are optional.
#[derive(serde::Deserialize, Default)]
struct FileConfig {
    base_url: Option<String>,
    api_key: Option<String>,
    model: Option<String>,
    temperature: Option<f32>,
    provider: Option<String>,
    context_window: Option<u32>,
    effort: Option<String>,
    auto_compact: Option<f64>,
    compaction_reserved: Option<u32>,
    auto_prune: Option<bool>,
    checkpoints: Option<String>,
    #[serde(default)]
    providers: HashMap<String, ProviderConfig>,
    #[serde(default)]
    guardrails: Vec<GuardrailConfig>,
    allow_outside_cwd: Option<bool>,
    #[serde(default)]
    hooks: Vec<HookConfig>,
    tool_output: Option<ToolOutputConfig>,
    compaction_tail_turns: Option<usize>,
    preserve_recent_tokens: Option<u32>,
    #[serde(default)]
    mcp: Vec<McpServerConfig>,
    prompt_cache: Option<String>,
}

/// `hrdr`'s config directory — `$XDG_CONFIG_HOME/hrdr`, default
/// `~/.config/hrdr` (cross-platform via home-dir detection). Shared by
/// `config.toml` loading and the global `AGENTS.md` lookup so the two can't
/// diverge.
pub fn config_dir() -> Option<std::path::PathBuf> {
    hjkl_xdg::config_dir("hrdr").ok()
}

/// Path to the user config file (`~/.config/hrdr/config.toml`), if `HOME` is set.
pub fn config_file_path() -> Option<std::path::PathBuf> {
    Some(config_dir()?.join("config.toml"))
}

impl AgentConfig {
    /// Load config with precedence: env > `~/.config/hrdr/config.toml` > built-in
    /// defaults. Lenient: a malformed config file is ignored (treated as absent).
    /// Does NOT auto-write a config file when one is missing.
    pub fn load() -> Self {
        // A malformed file is treated as absent: fall back to defaults, but
        // still layer env vars on top (same as a missing file).
        Self::load_checked().unwrap_or_else(|_| {
            let mut cfg = Self::default();
            cfg.apply_env();
            cfg
        })
    }

    /// Like [`load`](Self::load) but returns an error if the config file exists
    /// and fails to parse (for surfacing a warning + falling back to defaults).
    pub fn load_checked() -> Result<Self> {
        let mut cfg = Self::default();
        if let Some(path) = config_file_path()
            && let Ok(text) = std::fs::read_to_string(&path)
        {
            let fc: FileConfig =
                toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
            cfg.apply_file(fc);
        }
        cfg.apply_env();
        Ok(cfg)
    }

    /// Layer file values over the current config.
    fn apply_file(&mut self, fc: FileConfig) {
        if let Some(v) = fc.base_url {
            self.base_url = v;
        }
        if let Some(v) = fc.api_key {
            self.api_key = Some(v);
        }
        if let Some(v) = fc.model {
            self.model = v;
        }
        if let Some(v) = fc.temperature {
            self.temperature = Some(v);
        }
        if let Some(v) = fc.provider {
            self.provider = Some(v);
        }
        if let Some(v) = fc.context_window {
            self.context_window = Some(v);
        }
        if let Some(v) = fc.effort {
            self.effort = Some(v);
        }
        if let Some(v) = fc.auto_compact {
            self.auto_compact = v;
        }
        if let Some(v) = fc.compaction_reserved {
            self.compaction_reserved = v;
        }
        if let Some(v) = fc.auto_prune {
            self.auto_prune = v;
        }
        if let Some(v) = fc.checkpoints {
            self.checkpoints = Some(v);
        }
        if !fc.providers.is_empty() {
            self.providers = fc.providers;
        }
        if !fc.guardrails.is_empty() {
            self.guardrails = fc.guardrails;
        }
        if let Some(v) = fc.allow_outside_cwd {
            self.allow_outside_cwd = v;
        }
        if !fc.hooks.is_empty() {
            self.hooks = fc.hooks;
        }
        if let Some(to) = fc.tool_output {
            if let Some(v) = to.max_lines {
                self.tool_max_lines = v;
            }
            if let Some(v) = to.max_bytes {
                self.tool_max_bytes = v;
            }
        }
        if let Some(v) = fc.compaction_tail_turns {
            self.compaction_tail_turns = v;
        }
        if let Some(v) = fc.preserve_recent_tokens {
            self.preserve_recent_tokens = v;
        }
        if !fc.mcp.is_empty() {
            self.mcp = fc.mcp;
        }
        if let Some(v) = fc.prompt_cache {
            self.prompt_cache = Some(v);
        }
    }

    /// Layer environment variables over the current config. Every knob is one
    /// row in [`ENV_SETTERS`]; adding a new env var means adding a row there, not
    /// another `if let` here. `HRDR_API_KEY` is special-cased (it has a fallback
    /// var) below.
    fn apply_env(&mut self) {
        for (name, set) in ENV_SETTERS {
            if let Ok(v) = std::env::var(name) {
                set(self, v);
            }
        }
        // HRDR_API_KEY always wins. OPENAI_API_KEY — commonly exported for
        // unrelated tools — is only a last-resort fallback when nothing else
        // set a key; it must not override a config-file key destined for a
        // non-OpenAI endpoint.
        if let Ok(k) = std::env::var("HRDR_API_KEY") {
            self.api_key = Some(k);
        } else if self.api_key.is_none()
            && let Ok(k) = std::env::var("OPENAI_API_KEY")
        {
            self.api_key = Some(k);
        }
    }
}

/// Parse a boolean setting from a config/CLI/env string, accepting the common
/// spellings (`1`/`0`, `true`/`false`, `on`/`off`, `yes`/`no`,
/// case-insensitive). Returns `None` for anything unrecognized so callers can
/// leave the current value unchanged.
pub fn parse_env_bool(v: &str) -> Option<bool> {
    match v.trim().to_ascii_lowercase().as_str() {
        "0" | "false" | "off" | "no" => Some(false),
        "1" | "true" | "on" | "yes" => Some(true),
        _ => None,
    }
}

/// Applies an env var's string value to the config.
type EnvSetter = fn(&mut AgentConfig, String);

/// Env var → setter table used by [`AgentConfig::apply_env`]. Adding a knob is a
/// single row here (non-capturing closures coerce to `fn` pointers). Values that
/// need parsing (numbers, bools) silently keep the current value on a bad parse.
const ENV_SETTERS: &[(&str, EnvSetter)] = &[
    ("HRDR_PROVIDER", |c, v| c.provider = Some(v)),
    ("HRDR_BASE_URL", |c, v| c.base_url = v),
    ("HRDR_MODEL", |c, v| c.model = v),
    ("HRDR_CHECKPOINTS", |c, v| c.checkpoints = Some(v)),
    ("HRDR_ALLOW_OUTSIDE_CWD", |c, v| {
        if let Some(b) = parse_env_bool(&v) {
            c.allow_outside_cwd = b;
        }
    }),
    ("HRDR_AUTO_COMPACT", |c, v| {
        if let Ok(f) = v.parse() {
            c.auto_compact = f;
        }
    }),
    ("HRDR_COMPACTION_RESERVED", |c, v| {
        if let Ok(n) = v.parse() {
            c.compaction_reserved = n;
        }
    }),
    ("HRDR_AUTO_PRUNE", |c, v| {
        if let Some(b) = parse_env_bool(&v) {
            c.auto_prune = b;
        }
    }),
    ("HRDR_PROMPT_CACHE", |c, v| c.prompt_cache = Some(v)),
];

/// Resolve the prompt-cache `setting` (`off`/`on`/`ephemeral`/`auto`; `None` =
/// `auto`) against the endpoint into a concrete [`CacheMode`].
///
/// `auto` enables `cache_control` breakpoints **only for endpoints that consume
/// them safely**:
/// - **OpenRouter**, which normalizes the marker and strips it for models that
///   don't accept it.
/// - **Anthropic's own host** (`api.anthropic.com`), which hrdr talks to over the
///   **native Messages API** — where `cache_control` is the real caching lever.
///
/// It stays **off** for every other endpoint because sending an unknown
/// `cache_control` field is not universally safe:
/// - **OpenAI, Groq, xAI** reject it with a `400` (strict field validation), so
///   a blanket default would break every request.
/// - **DeepSeek, Gemini, OpenAI, Groq, xAI** already cache automatically, so the
///   marker buys nothing.
/// - local servers may reject the content-parts form.
///
/// Force it anywhere with `prompt_cache = "on"` (the caller's responsibility to
/// know the endpoint accepts it).
pub fn resolve_cache_mode(setting: Option<&str>, base_url: &str) -> hrdr_llm::CacheMode {
    use hrdr_llm::CacheMode;
    match setting.map(|s| s.trim().to_ascii_lowercase()).as_deref() {
        Some("off") | Some("none") | Some("false") | Some("no") => CacheMode::Off,
        Some("on") | Some("ephemeral") | Some("true") | Some("yes") => CacheMode::Ephemeral,
        _ if is_openrouter(base_url) || is_anthropic_native(base_url) => CacheMode::Ephemeral,
        _ => CacheMode::Off,
    }
}

/// Whether `base_url` is Anthropic's own host — hrdr speaks the native Messages
/// API there, so `cache_control` breakpoints actually cache (unlike the
/// OpenAI-compat endpoint, which drops them).
fn is_anthropic_native(base_url: &str) -> bool {
    let host = url_host(base_url);
    host == "api.anthropic.com" || host.ends_with(".anthropic.com")
}

/// The host portion of `base_url` (scheme, userinfo, port, and path stripped).
fn url_host(base_url: &str) -> &str {
    let host = base_url
        .split("://")
        .nth(1)
        .unwrap_or(base_url)
        .split('/')
        .next()
        .unwrap_or("")
        .rsplit('@')
        .next()
        .unwrap_or("");
    host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host)
}

/// Whether `base_url` points at OpenRouter — the one endpoint hrdr enables
/// `cache_control` for in `auto` mode (it accepts the marker for the models that
/// benefit and strips it for the rest). Also matches a custom provider pointed
/// at OpenRouter.
fn is_openrouter(base_url: &str) -> bool {
    let host = url_host(base_url);
    host == "openrouter.ai" || host.ends_with(".openrouter.ai")
}

/// A value to persist into the user config file.
pub enum ConfigValue<'a> {
    Str(&'a str),
    Bool(bool),
    Float(f64),
    Int(i64),
}

/// Set `key = value` in the user config file (creating it if needed), preserving
/// existing keys, ordering, and comments. Returns the file path.
pub fn persist_setting(key: &str, value: ConfigValue) -> Result<std::path::PathBuf> {
    let path =
        config_file_path().ok_or_else(|| anyhow::anyhow!("no HOME to locate the config file"))?;
    let mut doc = read_config_doc(&path);
    match value {
        ConfigValue::Str(s) => doc[key] = toml_edit::value(s),
        ConfigValue::Bool(b) => doc[key] = toml_edit::value(b),
        ConfigValue::Float(f) => doc[key] = toml_edit::value(f),
        ConfigValue::Int(i) => doc[key] = toml_edit::value(i),
    }
    write_config_doc(&path, &doc)?;
    Ok(path)
}

/// Remove `key` from the user config file (if present). Returns the file path.
pub fn remove_setting(key: &str) -> Result<std::path::PathBuf> {
    let path =
        config_file_path().ok_or_else(|| anyhow::anyhow!("no HOME to locate the config file"))?;
    let mut doc = read_config_doc(&path);
    doc.remove(key);
    write_config_doc(&path, &doc)?;
    Ok(path)
}

/// Whether `cwd` (or an ancestor) is inside a git repo. `.git` may be a
/// directory (normal) or a file (worktrees/submodules).
pub fn in_git_repo(cwd: &std::path::Path) -> bool {
    cwd.ancestors().any(|d| d.join(".git").exists())
}

/// Directory for this cwd's file checkpoints (`…/hrdr/checkpoints/<cwd-slug>`).
fn checkpoint_dir(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
    hjkl_xdg::data_dir("hrdr")
        .ok()
        .map(|d| d.join("checkpoints").join(cwd_slug(&cwd.to_string_lossy())))
}

fn read_config_doc(path: &std::path::Path) -> toml_edit::DocumentMut {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|s| s.parse::<toml_edit::DocumentMut>().ok())
        .unwrap_or_default()
}

fn write_config_doc(path: &std::path::Path, doc: &toml_edit::DocumentMut) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("creating {}", parent.display()))?;
    }
    std::fs::write(path, doc.to_string()).with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

/// System prompt for the one-off compaction (summarization) call.
const COMPACT_SYSTEM: &str = "\
You are summarizing a software-engineering conversation between a user and an AI \
coding agent so it can continue in a fresh context with nothing important lost. \
Be precise, technical, and exhaustive about concrete details — vague summaries are \
useless here.";

/// User-turn instruction that triggers the structured summary.
const COMPACT_TRIGGER: &str = "\
Summarize the conversation so far. The summary REPLACES the full history, so it must \
let the agent continue seamlessly. Use these sections:

1. **Intent & requirements** — what the user asked for, in their own terms, including \
   explicit constraints and preferences.
2. **Technical context** — languages, frameworks, key APIs, architecture decisions.
3. **Files & code** — every file created or modified (with paths) and the gist of the \
   changes; include important snippets, signatures, and config values verbatim.
4. **Commands & results** — notable commands run and their outcomes (builds, tests, \
   commits, pushes).
5. **Errors & fixes** — problems hit and how they were resolved.
6. **Current state** — what is done and verified vs. in progress.
7. **Pending tasks & next step** — what remains, and the single most immediate next \
   action.

Be specific: prefer exact names, paths, and values over paraphrase. Output only the \
summary.";

/// A running agent: model client + tools + conversation state.
pub struct Agent {
    client: Client,
    tools: ToolRegistry,
    ctx: ToolContext,
    messages: Vec<ChatMessage>,
    max_steps: usize,
    /// Prune old tool output from the history before each request (see
    /// [`AgentConfig::auto_prune`]).
    auto_prune: bool,
    /// Recent turns kept verbatim through compaction ([`AgentConfig::compaction_tail_turns`]).
    compaction_tail_turns: usize,
    /// Token budget for the kept-verbatim compaction tail
    /// ([`AgentConfig::preserve_recent_tokens`]).
    preserve_recent_tokens: u32,
    /// Gathered `AGENTS.md` project instructions for the current cwd, if any.
    project_docs: Option<String>,
    /// File checkpoint store (per-turn pre-images), if a store dir is available.
    checkpoints: Option<Arc<Mutex<Checkpoints>>>,
    /// MCP servers to connect (consumed by [`Self::connect_mcp`]).
    mcp_configs: Vec<McpServerConfig>,
    /// Live MCP connections, kept alive for the process (their tools hold clones
    /// too; dropping these kills the server processes).
    mcp_clients: Vec<Arc<hrdr_tools::McpClient>>,
    /// Raw prompt-cache setting, re-resolved against the endpoint on a provider
    /// switch (see [`resolve_cache_mode`]).
    prompt_cache: Option<String>,
}

impl Agent {
    /// Construct an agent, seeding the system prompt for the default tool set.
    pub fn new(config: AgentConfig) -> Result<Self> {
        let tools = ToolRegistry::with_defaults();
        let mut ctx = ToolContext::new(config.cwd.clone());
        ctx.restrict_to_cwd = !config.allow_outside_cwd;
        ctx.max_output = config.tool_max_bytes;
        ctx.max_output_lines = config.tool_max_lines;
        if !config.hooks.is_empty() {
            // Invalid globs are skipped (lenient, like the rest of config).
            let hooks: Vec<hrdr_tools::Hook> = config
                .hooks
                .iter()
                .filter_map(|h| {
                    let glob = match &h.glob {
                        Some(g) => Some(glob::Pattern::new(g).ok()?),
                        None => None,
                    };
                    Some(hrdr_tools::Hook {
                        on: h.on.clone(),
                        glob,
                        run: h.run.clone(),
                        timeout_ms: h.timeout_ms.unwrap_or(hrdr_tools::DEFAULT_HOOK_TIMEOUT_MS),
                    })
                })
                .collect();
            ctx.hooks = Arc::new(hooks);
        }
        // User guardrails layer on top of the built-in set; an invalid regex
        // is skipped (lenient, like the rest of config parsing).
        if !config.guardrails.is_empty() {
            let mut rails = hrdr_tools::default_guardrails();
            rails.extend(
                config
                    .guardrails
                    .iter()
                    .filter_map(|g| hrdr_tools::Guardrail::new(&g.pattern, &g.message).ok()),
            );
            ctx.guardrails = Arc::new(rails);
        }
        let project_docs = gather_agent_docs(&config.cwd);
        let system = render_system(&tools, &config.cwd, project_docs.as_deref())?;

        // File checkpoint store, keyed by working directory (like sessions).
        // `auto` (default) enables it only outside a git repo — git already
        // provides revert, so checkpointing there is redundant.
        // The bool spellings come from `parse_env_bool` (plus `always`/`never`)
        // so they can't drift from the other on/off knobs; anything else (incl.
        // `auto`) falls through to the git-repo heuristic.
        let enable_checkpoints = config
            .checkpoints
            .as_deref()
            .map(|s| s.trim().to_ascii_lowercase())
            .and_then(|v| match v.as_str() {
                "always" => Some(true),
                "never" => Some(false),
                other => parse_env_bool(other),
            })
            .unwrap_or_else(|| !in_git_repo(&config.cwd));
        let checkpoints = enable_checkpoints
            .then(|| checkpoint_dir(&config.cwd))
            .flatten()
            .and_then(|dir| Checkpoints::open(dir).ok())
            .map(|c| Arc::new(Mutex::new(c)));
        ctx.checkpoints = checkpoints.clone();

        let cache_mode = resolve_cache_mode(config.prompt_cache.as_deref(), &config.base_url);
        let mut client =
            Client::new(config.base_url, config.api_key, config.model).with_cache(cache_mode);
        if let Some(t) = config.temperature {
            client = client.with_temperature(t);
        }
        client.set_effort(config.effort.clone());

        Ok(Self {
            client,
            prompt_cache: config.prompt_cache,
            tools,
            ctx,
            messages: vec![ChatMessage::system(system)],
            max_steps: config.max_steps,
            auto_prune: config.auto_prune,
            compaction_tail_turns: config.compaction_tail_turns,
            preserve_recent_tokens: config.preserve_recent_tokens,
            project_docs,
            checkpoints,
            mcp_configs: config.mcp,
            mcp_clients: Vec::new(),
        })
    }

    /// Connect to the configured `[[mcp]]` servers, registering each server's
    /// tools (namespaced `<name>_<tool>`) into the tool set and re-rendering the
    /// system prompt so they're listed. Resilient: a server that fails to start
    /// or handshake is skipped. Returns one human-readable status line per
    /// server (for the frontend to surface). Call once, after [`Self::new`],
    /// before the first turn; a second call is a no-op (configs are consumed).
    pub async fn connect_mcp(&mut self) -> Vec<String> {
        let configs = std::mem::take(&mut self.mcp_configs);
        let mut notices = Vec::new();
        for cfg in &configs {
            if cfg.disabled {
                continue;
            }
            let pairs = |m: &HashMap<String, String>| -> Vec<(String, String)> {
                m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
            };
            // Transport: `url` → HTTP (Streamable, or legacy SSE when
            // `transport = "sse"`), else `command` → stdio.
            let connected = match (&cfg.url, &cfg.command) {
                (Some(url), _) if cfg.transport.as_deref() == Some("sse") => {
                    hrdr_tools::McpClient::connect_sse(&cfg.name, url, &pairs(&cfg.headers)).await
                }
                (Some(url), _) => {
                    hrdr_tools::McpClient::connect_http(&cfg.name, url, &pairs(&cfg.headers)).await
                }
                (None, Some(cmd)) => {
                    hrdr_tools::McpClient::connect_stdio(
                        &cfg.name,
                        cmd,
                        &cfg.args,
                        &pairs(&cfg.env),
                    )
                    .await
                }
                (None, None) => {
                    notices.push(format!("MCP '{}' skipped: no `command` or `url`", cfg.name));
                    continue;
                }
            };
            match connected {
                Ok((client, tools)) => {
                    let n = tools.len();
                    for t in tools {
                        self.tools.register(t);
                    }
                    self.mcp_clients.push(client);
                    notices.push(format!(
                        "MCP '{}': connected ({n} tool{})",
                        cfg.name,
                        if n == 1 { "" } else { "s" }
                    ));
                }
                Err(e) => notices.push(format!("MCP '{}' failed: {e}", cfg.name)),
            }
        }
        // New tools changed the set the model is told about — rebuild the prompt.
        if !self.mcp_clients.is_empty() {
            self.refresh_system();
        }
        notices
    }

    /// The file checkpoint store, if available (for `/revert` / `/checkpoints`).
    pub fn checkpoints(&self) -> Option<Arc<Mutex<Checkpoints>>> {
        self.checkpoints.clone()
    }

    /// The gathered `AGENTS.md` project instructions for the current cwd, if any.
    pub fn project_docs(&self) -> Option<&str> {
        self.project_docs.as_deref()
    }

    pub fn messages(&self) -> &[ChatMessage] {
        &self.messages
    }

    /// Reset the conversation to a fresh state — as if the agent was just
    /// constructed for the current cwd. Drops all history and **re-reads
    /// `AGENTS.md`**, so an updated or removed project-instructions file is
    /// reflected (the old system prompt is not kept around).
    pub fn clear(&mut self) {
        self.messages.clear();
        self.reset_read_files();
        self.refresh_system();
    }

    /// Forget which files the model has "seen": once the transcript no longer
    /// contains their content (clear/resume/compaction), edits must re-read
    /// first — the read-before-edit gate tracks the model's context, not disk.
    fn reset_read_files(&mut self) {
        if let Ok(mut set) = self.ctx.read_files.lock() {
            set.clear();
        }
    }

    /// Re-gather `AGENTS.md` for the current cwd and rebuild the system prompt
    /// in `messages[0]` (seeding it if the history is empty). Shared by
    /// [`Self::clear`] and [`Self::set_cwd`].
    fn refresh_system(&mut self) {
        self.project_docs = gather_agent_docs(&self.ctx.cwd);
        let Ok(system) = render_system(&self.tools, &self.ctx.cwd, self.project_docs.as_deref())
        else {
            return;
        };
        if self.messages.first().map(|m| m.role == Role::System) == Some(true) {
            self.messages[0] = ChatMessage::system(system);
        } else {
            self.messages.insert(0, ChatMessage::system(system));
        }
    }

    /// A clone of the full message history (for saving a session).
    pub fn messages_owned(&self) -> Vec<ChatMessage> {
        self.messages.clone()
    }

    /// Replace the message history (for resuming a session). Resets the
    /// read-before-edit gate: this conversation didn't read those files.
    pub fn set_messages(&mut self, messages: Vec<ChatMessage>) {
        self.messages = messages;
        self.reset_read_files();
    }

    /// Switch the model for subsequent turns.
    pub fn set_model(&mut self, model: impl Into<String>) {
        self.client.model = model.into();
    }

    /// A clone of the model client (for out-of-band calls like `/models`).
    pub fn client(&self) -> Client {
        self.client.clone()
    }

    /// Probe the current endpoint for the model's advertised context window (the
    /// max context size the API reports — vLLM's `max_model_len`, llama.cpp's
    /// `n_ctx`, etc.). `None` when the server advertises nothing. Used to honor
    /// the API's real limit after a model/provider switch.
    pub async fn probe_context_window(&self) -> Option<u32> {
        self.client.context_window().await
    }

    /// Working directory the tools operate in.
    pub fn cwd(&self) -> std::path::PathBuf {
        self.ctx.cwd.clone()
    }

    /// Change the tools' working directory. Reloads `AGENTS.md` for the new
    /// directory and refreshes the system prompt in place.
    pub fn set_cwd(&mut self, cwd: std::path::PathBuf) {
        self.ctx.cwd = cwd;
        self.refresh_system();
    }

    /// The rendered system prompt currently in effect (message 0).
    pub fn system_prompt(&self) -> Option<String> {
        self.messages
            .first()
            .filter(|m| m.role == Role::System)
            .and_then(|m| m.content.clone())
    }

    /// Active shell guardrails as `(pattern, message)` pairs — built-ins plus
    /// any `[[guardrails]]` config extras (for `/guardrails`).
    pub fn guardrail_specs(&self) -> Vec<(String, String)> {
        self.ctx
            .guardrails
            .iter()
            .map(|g| (g.pattern.as_str().to_string(), g.message.clone()))
            .collect()
    }

    /// Registered tools as `(name, description)` pairs.
    pub fn tools(&self) -> Vec<(String, String)> {
        self.tools
            .defs()
            .into_iter()
            .map(|d| (d.function.name, d.function.description))
            .collect()
    }

    /// Sampling temperature, if set.
    pub fn temperature(&self) -> Option<f32> {
        self.client.temperature
    }

    /// Whether prompt caching is active for the current endpoint (see
    /// [`resolve_cache_mode`]).
    pub fn prompt_cache_active(&self) -> bool {
        resolve_cache_mode(self.prompt_cache.as_deref(), self.client.base_url())
            == hrdr_llm::CacheMode::Ephemeral
    }

    /// Set (or clear) the sampling temperature.
    pub fn set_temperature(&mut self, t: Option<f32>) {
        self.client.temperature = t;
    }

    /// Set (or clear) the reasoning-effort label. Sent as `reasoning_effort` on
    /// each request when it names a known level; other labels are display-only.
    pub fn set_effort(&mut self, effort: Option<String>) {
        self.client.set_effort(effort);
    }

    /// Repoint at a different OpenAI-compatible endpoint + key (provider switch).
    pub fn set_endpoint(&mut self, base_url: impl Into<String>, api_key: Option<String>) {
        let base_url = base_url.into();
        let cache = resolve_cache_mode(self.prompt_cache.as_deref(), &base_url);
        self.client.set_base_url(base_url);
        self.client.set_api_key(api_key);
        self.client.set_cache(cache);
    }

    /// Drop the last user turn (and everything after it) from history, returning
    /// that user message's text so it can be re-sent (`/retry`).
    pub fn rewind_last_user(&mut self) -> Option<String> {
        let idx = self.messages.iter().rposition(|m| m.role == Role::User)?;
        let text = self.messages[idx].content.clone();
        self.messages.truncate(idx);
        text
    }

    /// Shared TODO list, mutated by the `todo` tool.
    pub fn todos(&self) -> Arc<Mutex<Vec<TodoItem>>> {
        self.ctx.todos.clone()
    }

    /// Number of messages currently in history (including the system prompt).
    pub fn message_count(&self) -> usize {
        self.messages.len()
    }

    /// Compact the conversation: ask the model for a structured summary and
    /// replace the history with `[system prompt, summary]`, so the context
    /// shrinks while continuity is preserved (Claude Code / opencode style).
    ///
    /// `instructions` optionally steers the summary's focus. Returns
    /// `(messages_before, messages_after)`; a no-op when there's nothing beyond
    /// the system prompt and one message.
    pub async fn compact(&mut self, instructions: Option<&str>) -> Result<(usize, usize)> {
        let before = self.messages.len();
        if before <= 2 {
            return Ok((before, before));
        }
        // Keep the most recent messages verbatim — compaction usually fires
        // mid-task, and the summary alone loses exactly the detail the model
        // is working with. Only the head (everything older) is summarized.
        let tail_start = compaction_tail_start(
            &self.messages,
            self.compaction_tail_turns,
            self.preserve_recent_tokens,
        );
        if tail_start <= 2 {
            // Nothing meaningful before the tail; compacting would only churn.
            return Ok((before, before));
        }

        // Build a one-off summarization request: a dedicated summarizer system
        // prompt + the conversation so far (minus its own system prompt) + the
        // trigger instruction. No tools — we only want prose back.
        let mut trigger = COMPACT_TRIGGER.to_string();
        if let Some(extra) = instructions.map(str::trim).filter(|s| !s.is_empty()) {
            trigger.push_str("\n\nAdditional instructions for the summary, follow them closely:\n");
            trigger.push_str(extra);
        }
        // When compaction is overflow-triggered, the summarization request is
        // itself near the limit (versus the failed request it only drops the
        // `tools[]` block). If it overflows too, shrink what the summarizer
        // sees and retry: first elide bulky tool results, then keep only the
        // most recent half/quarter/eighth of the conversation.
        let full: Vec<ChatMessage> = self.messages[1..tail_start].to_vec();
        let mut stage = 0usize;
        let summary = loop {
            let history = match stage {
                0 => full.clone(),
                1 => elide_tool_results(&full),
                n => tail_window(&elide_tool_results(&full), 1 << (n - 1)),
            };
            let mut req = Vec::with_capacity(history.len() + 2);
            req.push(ChatMessage::system(COMPACT_SYSTEM.to_string()));
            req.extend(history);
            req.push(ChatMessage::user(trigger.clone()));
            match self.plain_completion(req).await {
                Ok(s) => break s,
                Err(e) if is_context_overflow(&e) && stage < 4 => stage += 1,
                Err(e) => return Err(e),
            }
        };
        if summary.trim().is_empty() {
            bail!("compaction produced an empty summary");
        }

        // Replace history: the original (coding) system prompt, a user
        // message carrying the summary as the continuation seed, then the
        // recent tail verbatim.
        let system = self.messages[0].clone();
        let tail: Vec<ChatMessage> = self.messages[tail_start..].to_vec();
        let continuation = format!(
            "This session is being continued from an earlier conversation that ran out of \
             context. The summary below captures the older part of the conversation; the most \
             recent messages follow it verbatim. Continue from where they leave off without \
             losing any detail.\n\n{summary}"
        );
        let mut messages = Vec::with_capacity(2 + tail.len());
        messages.push(system);
        messages.push(ChatMessage::user(continuation));
        messages.extend(tail);
        self.messages = messages;
        // Most file contents the model had read live only in the summary now;
        // require fresh reads before further edits.
        self.reset_read_files();
        Ok((before, self.messages.len()))
    }

    /// Run one no-tools request to completion, returning the streamed text.
    /// Silent: the shared [`drain_stream`] gets a no-op event sink.
    async fn plain_completion(&self, req: Vec<ChatMessage>) -> Result<String> {
        let mut stream = self.client.chat_stream(req, vec![]).await?;
        let acc = drain_stream(&mut stream, &mut |_| {}).await?;
        Ok(acc.into_message().content.unwrap_or_default())
    }

    /// Drain any steering messages submitted since the last request into the
    /// conversation as user messages, emitting [`AgentEvent::Steered`] for each
    /// so the frontend can display it at delivery time.
    fn drain_steering<F: FnMut(AgentEvent)>(&mut self, steering: &SteeringQueue, on_event: &mut F) {
        let pending: Vec<String> = steering
            .lock()
            .map(|mut q| q.drain(..).collect())
            .unwrap_or_default();
        for msg in pending {
            on_event(AgentEvent::Steered(msg.clone()));
            self.messages.push(ChatMessage::user(msg));
        }
    }

    /// Whether the steering queue has any undelivered messages.
    fn has_steering(steering: &SteeringQueue) -> bool {
        steering.lock().map(|q| !q.is_empty()).unwrap_or(false)
    }

    /// Run one user turn to completion, emitting events as it goes. `steering` is
    /// a shared queue the caller can push to mid-turn (see [`SteeringQueue`]);
    /// pass [`steering_queue()`] when there's no interactive steering.
    pub async fn run<F>(
        &mut self,
        user_input: impl Into<String>,
        steering: SteeringQueue,
        mut on_event: F,
    ) -> Result<()>
    where
        F: FnMut(AgentEvent),
    {
        // A previous turn interrupted mid tool-call can leave the history ending
        // with an assistant `tool_calls` message whose results are missing —
        // strict servers reject that. Backfill stubs before the new user turn.
        repair_dangling_tool_calls(&mut self.messages);
        let user_input = user_input.into();
        if !user_input.trim().is_empty() {
            self.messages.push(ChatMessage::user(user_input));
        }
        // Start a fresh file checkpoint for this turn's edits.
        if let Some(cp) = &self.checkpoints
            && let Ok(mut c) = cp.lock()
        {
            c.begin_turn();
        }
        let defs = self.tools.defs();
        // Allow one automatic compaction per turn when the context overflows.
        let mut overflow_compacted = false;
        // Anti-loop breaker for verbatim retries of a failing call.
        let mut repeat = RepeatGuard::default();

        for step in 0..self.max_steps {
            // Deliver any steering messages submitted since the last request — a
            // mid-turn correction reaches the model after the current tool round.
            self.drain_steering(&steering, &mut on_event);
            // Reclaim stale tool output before building the request — the cheap,
            // no-model-call first line of defence against context ballooning
            // (compaction is the expensive fallback). No-op until there's enough
            // old output to matter.
            if self.auto_prune {
                let reclaimed = prune_tool_messages(
                    &mut self.messages,
                    PRUNE_PROTECT_TOKENS,
                    PRUNE_MINIMUM_TOKENS,
                    PRUNE_KEEP_TURNS,
                );
                if reclaimed > 0 {
                    on_event(AgentEvent::Notice(format!(
                        "pruned ~{reclaimed} tokens of old tool output"
                    )));
                }
            }
            // Stream one assistant turn, accumulating text + tool calls. The
            // connect is retried on transient errors and auto-compacted once on
            // a context-length overflow.
            let mut stream = self
                .connect_stream(&defs, &mut overflow_compacted, &mut on_event)
                .await?;
            let acc = drain_stream(&mut stream, &mut on_event).await?;

            // Emit usage for the status bar + auto-compaction. Prefer the
            // server's reported counts; when it doesn't send any (e.g. a server
            // that ignores `stream_options.include_usage`), fall back to a rough
            // estimate so the context bar and compaction still work — an estimate
            // beats a stale/zero reading, and the overflow-retry path covers any
            // under-estimate.
            let (prompt_tokens, completion_tokens) = match &acc.usage {
                Some(u) => (u.prompt_tokens, u.completion_tokens),
                None => (
                    estimate_tokens_in_messages(&self.messages),
                    estimate_tokens(&acc.content),
                ),
            };
            on_event(AgentEvent::Usage {
                prompt_tokens,
                completion_tokens,
            });

            let assistant = acc.into_message();
            let tool_calls = assistant.tool_calls.clone().unwrap_or_default();
            self.messages.push(assistant);

            if tool_calls.is_empty() {
                // A steering message that arrived during this response continues
                // the turn (deliver it) rather than ending — the next iteration
                // drains it before the request.
                if Self::has_steering(&steering) {
                    continue;
                }
                on_event(AgentEvent::TurnDone);
                return Ok(());
            }

            // Execute the requested tools, feeding results back. Runs of
            // consecutive read-only calls (reads/searches/fetches) execute
            // concurrently; a mutating call is a barrier, run alone — so a
            // read after a write still observes the write, and results always
            // land in call order.
            let mut idx = 0;
            while idx < tool_calls.len() {
                let read_only = self.tools.is_read_only(&tool_calls[idx].function.name);
                let mut end = idx + 1;
                while read_only
                    && end < tool_calls.len()
                    && self.tools.is_read_only(&tool_calls[end].function.name)
                {
                    end += 1;
                }
                let batch = &tool_calls[idx..end];
                idx = end;

                // One path for both: a read-only run executes concurrently, a
                // lone mutating call is a one-element batch. The refusal check,
                // arg parse, streamed output, and in-order results all live in
                // `run_tool_batch`.
                self.run_tool_batch(batch, &mut repeat, &mut on_event).await;
            }

            // Near the budget: tell the model so it wraps up instead of
            // getting cut off mid-plan.
            let remaining = self.max_steps - step - 1;
            if remaining == WRAP_UP_WARNING_ROUNDS
                && let Some(last) = self.messages.last_mut()
                && let Some(content) = &mut last.content
            {
                content.push_str(&format!(
                    "\n\n[note: only {remaining} tool rounds remain this turn — finish up \
                     and summarize]"
                ));
            }
        }

        // Budget exhausted: instead of failing the turn, run one final round
        // with no tools so the model must answer in text.
        on_event(AgentEvent::Notice(format!(
            "tool-round limit reached ({}) — asking the model to wrap up",
            self.max_steps
        )));
        self.messages.push(ChatMessage::user(
            "[The tool-call budget for this turn is exhausted. Do not request more tool \
             calls. Summarize what you accomplished and what remains to be done.]"
                .to_string(),
        ));
        let mut stream = self
            .connect_stream(&[], &mut overflow_compacted, &mut on_event)
            .await?;
        let acc = drain_stream(&mut stream, &mut on_event).await?;
        self.messages.push(acc.into_message());
        on_event(AgentEvent::TurnDone);
        Ok(())
    }

    /// Emit the `ToolEnd` event and push the tool-result message for a
    /// completed call (shared by the sequential and concurrent paths). Feeds
    /// the repeat breaker, appending its nudge to a repeated failure.
    fn finish_tool_call<F: FnMut(AgentEvent)>(
        &mut self,
        call: &hrdr_llm::ToolCall,
        result: Result<String>,
        repeat: &mut RepeatGuard,
        on_event: &mut F,
    ) {
        let (ok, mut body) = match result {
            Ok(s) => (true, s),
            Err(e) => (false, format!("Error: {e}")),
        };
        if let Some(nudge) = repeat.record(&call.function.name, &call.function.arguments, ok) {
            body.push_str(&nudge);
        }
        on_event(AgentEvent::ToolEnd {
            id: call.id.clone(),
            name: call.function.name.clone(),
            result: body.clone(),
            ok,
        });
        self.messages
            .push(ChatMessage::tool_result(call.id.clone(), body));
    }

    /// Run a batch of tool calls, forwarding each call's streamed output as
    /// `ToolOutput` events (attributed by call id) while they run. A read-only
    /// run executes concurrently; a lone mutating call is a one-element batch.
    /// Results are emitted and recorded in call order.
    async fn run_tool_batch<F: FnMut(AgentEvent)>(
        &mut self,
        batch: &[hrdr_llm::ToolCall],
        repeat: &mut RepeatGuard,
        on_event: &mut F,
    ) {
        // One shared (id, chunk) channel; each call gets a private sink whose
        // chunks a forwarder task tags with the call id.
        let (shared_tx, mut shared_rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>();
        let mut futs = Vec::with_capacity(batch.len());
        for call in batch {
            on_event(AgentEvent::ToolStart {
                id: call.id.clone(),
                name: call.function.name.clone(),
                args: call.function.arguments.clone(),
            });
            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
            let fwd_tx = shared_tx.clone();
            let fwd_id = call.id.clone();
            tokio::spawn(async move {
                while let Some(chunk) = rx.recv().await {
                    let _ = fwd_tx.send((fwd_id.clone(), chunk));
                }
            });
            let mut ctx = self.ctx.clone();
            ctx.stream = Some(tx);
            let name = call.function.name.clone();
            let raw_args = call.function.arguments.clone();
            // Cheap clone (Arc-backed registry) so the futures don't borrow
            // `self` — results are recorded with `&mut self` right after.
            let tools = self.tools.clone();
            // A refused call (repeat breaker) resolves immediately instead of
            // executing; boxing keeps the join order == call order.
            let fut: std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send>> =
                match repeat.refusal(&name, &raw_args) {
                    Some(msg) => Box::pin(async move { Err(anyhow::anyhow!(msg)) }),
                    None => Box::pin(async move {
                        let args: serde_json::Value = if raw_args.trim().is_empty() {
                            serde_json::json!({})
                        } else {
                            match serde_json::from_str(&raw_args) {
                                Ok(v) => v,
                                Err(e) => {
                                    return Err(anyhow::anyhow!(
                                        "invalid tool arguments JSON: {e}"
                                    ));
                                }
                            }
                        };
                        tools.execute(&name, args, &ctx).await
                    }),
                };
            futs.push(fut);
        }
        drop(shared_tx); // forwarders hold the remaining senders

        let joined = futures_util::future::join_all(futs);
        tokio::pin!(joined);
        let results = loop {
            tokio::select! {
                r = &mut joined => break r,
                Some((id, chunk)) = shared_rx.recv() => {
                    on_event(AgentEvent::ToolOutput { id, chunk });
                }
            }
        };
        // Drain chunks buffered between the last poll and completion.
        while let Ok((id, chunk)) = shared_rx.try_recv() {
            on_event(AgentEvent::ToolOutput { id, chunk });
        }
        for (call, result) in batch.iter().zip(results) {
            self.finish_tool_call(call, result, repeat, on_event);
        }
    }

    /// Open a chat stream, retrying transient network/server errors with
    /// exponential backoff and auto-compacting once on a context-length
    /// overflow. Emits `Notice` events for each recovery attempt.
    async fn connect_stream<F: FnMut(AgentEvent)>(
        &mut self,
        defs: &[ToolDef],
        overflow_compacted: &mut bool,
        on_event: &mut F,
    ) -> Result<ChatStream> {
        const MAX_RETRIES: usize = 4;
        let mut attempt = 0usize;
        loop {
            match self
                .client
                .chat_stream(self.messages.clone(), defs.to_vec())
                .await
            {
                Ok(stream) => return Ok(stream),
                Err(e) => {
                    // Context overflow → compact once, then retry.
                    if is_context_overflow(&e) && !*overflow_compacted && self.messages.len() > 2 {
                        on_event(AgentEvent::Notice(
                            "context window exceeded — compacting and retrying".to_string(),
                        ));
                        self.compact(None).await?;
                        *overflow_compacted = true;
                        continue;
                    }
                    // Transient network/server error → backoff and retry.
                    if is_transient(&e) && attempt < MAX_RETRIES {
                        attempt += 1;
                        let delay = retry_backoff(attempt);
                        on_event(AgentEvent::Notice(format!(
                            "network error — retrying in {:.0}s (attempt {attempt}/{MAX_RETRIES})",
                            delay.as_secs_f64()
                        )));
                        tokio::time::sleep(delay).await;
                        continue;
                    }
                    return Err(e);
                }
            }
        }
    }
}

// Re-exports consumers need without reaching into sub-crates.
pub use hrdr_llm::ChatMessage as Message;
pub use hrdr_llm::Role as MessageRole;
/// Whether a reasoning-effort label is a level actually sent as `reasoning_effort`
/// (`minimal`/`low`/`medium`/`high`) rather than a display-only label.
pub use hrdr_llm::normalize_effort;
pub use hrdr_tools::TodoItem as Todo;

/// Case-insensitive substring scan of an error's display string against a set
/// of marker phrases — the shared shape of the classifiers below.
fn err_mentions(e: &anyhow::Error, needles: &[&str]) -> bool {
    let msg = e.to_string().to_ascii_lowercase();
    needles.iter().any(|n| msg.contains(n))
}

/// Whether an error looks like a transient network/server failure worth
/// retrying (connection issues `request failed`/`timed out`/…, 429, or 5xx).
fn is_transient(e: &anyhow::Error) -> bool {
    err_mentions(
        e,
        &[
            "request failed", // reqwest send() failure (network)
            "timed out",
            "connection",
            "reset",
            "broken pipe",
            "returned 429", // rate limited
            "returned 500",
            "returned 502",
            "returned 503",
            "returned 504",
        ],
    )
}

/// Whether an error is the server rejecting the request for exceeding the
/// model's context window. The marker phrases are ported from pi's
/// provider-specific overflow patterns (`packages/ai/src/utils/overflow.ts`),
/// covering ~20 OpenAI-compatible backends.
fn is_context_overflow(e: &anyhow::Error) -> bool {
    // Rate-limit / throttling errors sometimes contain overflow-ish wording
    // (e.g. Bedrock's "Throttling: too many tokens") — exclude them first so
    // they retry (via [`is_transient`]) rather than triggering a compaction.
    if err_mentions(
        e,
        &["rate limit", "too many requests", "throttl", "returned 429"],
    ) {
        return false;
    }
    err_mentions(
        e,
        &[
            // Generic phrasings (cover most backends + our own error text).
            "context length",
            "context_length",
            "maximum context",
            "context window",
            "context size",
            "too many tokens",
            "token limit exceeded",
            "reduce the length",
            // Provider-specific (from pi's overflow.ts).
            "prompt is too long",                     // Anthropic
            "request_too_large",                      // Anthropic 413
            "request too large",                      // Anthropic 413 (spaced)
            "returned 413",                           // our formatting of a 413
            "input is too long",                      // Bedrock
            "exceeds the context window",             // OpenAI
            "input token count",                      // Google Gemini
            "maximum prompt length is",               // xAI Grok
            "maximum allowed input length",           // OpenRouter/Poolside
            "longer than the model's context length", // Together AI
            "exceeds the limit of",                   // GitHub Copilot
            "exceeded model token limit",             // Kimi
            "too large for model with",               // Mistral
            "model_context_window_exceeded",          // z.ai
            "configured context size",                // DS4
        ],
    )
}

/// Max bytes of a tool-result body kept when shrinking a compaction request.
const ELIDE_TOOL_RESULT_BYTES: usize = 400;

/// Default recent turns kept verbatim through compaction (`tail_turns`).
/// Matches opencode's `DEFAULT_TAIL_TURNS`.
pub const DEFAULT_TAIL_TURNS: usize = 2;
/// Default token budget for the verbatim tail kept through compaction
/// (`preserve_recent_tokens`). Matches opencode's `MAX_PRESERVE_RECENT_TOKENS`.
pub const DEFAULT_PRESERVE_RECENT_TOKENS: u32 = 8_000;

/// Index where the kept-verbatim tail begins for compaction. Keeps the last
/// `tail_turns` turns (a turn begins at a `role:"user"` message), but no more
/// than `preserve_tokens` estimated tokens — walking newest → oldest, adding
/// whole turns until the budget is hit, always keeping at least the newest
/// turn. Never returns 0 (the system prompt stays); the tail always begins on a
/// user message, so no tool result is orphaned. Everything in `1..start` gets
/// summarized. Mirrors opencode's compaction tail selection.
fn compaction_tail_start(msgs: &[ChatMessage], tail_turns: usize, preserve_tokens: u32) -> usize {
    if tail_turns == 0 {
        return msgs.len();
    }
    // Turn boundaries: user messages after the system prompt.
    let starts: Vec<usize> = (1..msgs.len())
        .filter(|&i| msgs[i].role == Role::User)
        .collect();
    let Some(&newest) = starts.last() else {
        return msgs.len().max(1);
    };
    let candidates = &starts[starts.len().saturating_sub(tail_turns)..];
    let mut tail_start = msgs.len();
    let mut tokens = 0u32;
    for &start in candidates.iter().rev() {
        let turn_tokens = estimate_tokens_in_messages(&msgs[start..tail_start]);
        // Always keep the newest turn; stop before an older turn that busts the
        // budget.
        if start != newest && tokens + turn_tokens > preserve_tokens {
            break;
        }
        tokens += turn_tokens;
        tail_start = start;
    }
    tail_start.max(1)
}

/// Copy of `msgs` with bulky tool-result bodies truncated — tool output is the
/// usual context hog, and the summarizer mostly needs the surrounding turns.
fn elide_tool_results(msgs: &[ChatMessage]) -> Vec<ChatMessage> {
    msgs.iter()
        .map(|m| {
            let Some(c) = &m.content else {
                return m.clone();
            };
            if m.role != Role::Tool || c.len() <= ELIDE_TOOL_RESULT_BYTES {
                return m.clone();
            }
            let cut = hrdr_tools::floor_char_boundary(c, ELIDE_TOOL_RESULT_BYTES);
            let mut m = m.clone();
            m.content = Some(format!(
                "{}\n…[tool output elided for compaction]",
                &c[..cut]
            ));
            m
        })
        .collect()
}

/// Clear the bodies of *old* tool-result messages, keeping the most recent
/// [`PRUNE_PROTECT_TOKENS`] of tool output — plus the last [`PRUNE_KEEP_TURNS`]
/// turns — verbatim. Only `role:"tool"` bodies are replaced (with
/// [`PRUNE_PLACEHOLDER`]); the assistant `tool_calls` metadata and every message
/// stays, so the tool-call ↔ result pairing strict servers require is intact.
///
/// Returns the estimated tokens reclaimed, or `0` when that would be below
/// `minimum_tokens` (in which case nothing is changed — small reclaims aren't
/// worth the lost detail). `protect_tokens` is the recent tool-output window
/// kept verbatim; `keep_turns` the recent turns never touched. Cheap and
/// model-only: the UI transcript keeps the full output; this just bounds what
/// gets re-sent every request.
fn prune_tool_messages(
    messages: &mut [ChatMessage],
    protect_tokens: u32,
    minimum_tokens: u32,
    keep_turns: usize,
) -> u32 {
    let mut turns = 0usize;
    // Cumulative tool-output tokens seen scanning newest → oldest.
    let mut seen_tokens = 0u32;
    let mut reclaimable = 0u32;
    let mut victims: Vec<usize> = Vec::new();
    for i in (0..messages.len()).rev() {
        let m = &messages[i];
        if m.role == Role::User {
            turns += 1;
        }
        // The last few turns are always kept whole — the model is still working
        // with that output.
        if turns < keep_turns {
            continue;
        }
        if m.role != Role::Tool {
            continue;
        }
        let body = m.content.as_deref().unwrap_or_default();
        if body == PRUNE_PLACEHOLDER {
            continue; // already pruned
        }
        let est = estimate_tokens(body);
        seen_tokens += est;
        // Keep the newest window verbatim; everything older is a prune target.
        if seen_tokens <= protect_tokens {
            continue;
        }
        reclaimable += est;
        victims.push(i);
    }
    if reclaimable < minimum_tokens {
        return 0;
    }
    for i in victims {
        messages[i].content = Some(PRUNE_PLACEHOLDER.to_string());
    }
    reclaimable
}

/// The most recent `1/div` of `msgs` (at least two messages), aligned forward
/// past any leading `role:"tool"` results so no result is orphaned from its
/// assistant `tool_calls` message (strict servers reject that).
fn tail_window(msgs: &[ChatMessage], div: usize) -> Vec<ChatMessage> {
    let keep = (msgs.len() / div.max(1)).clamp(2, msgs.len());
    let mut start = msgs.len() - keep;
    while start < msgs.len() && msgs[start].role == Role::Tool {
        start += 1;
    }
    msgs[start..].to_vec()
}

/// Exponential backoff for retry `attempt` (1-based), capped at 8s.
fn retry_backoff(attempt: usize) -> std::time::Duration {
    let secs = 0.5 * 2f64.powi((attempt as i32 - 1).max(0));
    std::time::Duration::from_secs_f64(secs.min(8.0))
}

/// Drain a chat stream into an [`Accumulator`], emitting `Reasoning` and `Text`
/// deltas as they arrive. Shared by the turn loop, the budget-exhausted wrap-up
/// round, and (with a no-op sink) the one-off compaction call.
async fn drain_stream<F: FnMut(AgentEvent)>(
    stream: &mut ChatStream,
    on_event: &mut F,
) -> Result<Accumulator> {
    let mut acc = Accumulator::new();
    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        if let Some(choice) = chunk.choices.first()
            && let Some(r) = &choice.delta.reasoning_content
        {
            on_event(AgentEvent::Reasoning(r.clone()));
        }
        if let Some(text) = acc.push(&chunk) {
            on_event(AgentEvent::Text(text));
        }
    }
    Ok(acc)
}

/// Repair a history left dangling by an interrupted turn. An assistant message
/// with `tool_calls` must be followed by a `role:"tool"` result for every call
/// id, or strict servers (OpenAI, and infr) reject the next request. If the most
/// recent such assistant message is missing any results (the turn was cancelled
/// mid tool-call), append a stub result for each unanswered id. No-op when the
/// last tool-calling turn is already complete.
fn repair_dangling_tool_calls(messages: &mut Vec<ChatMessage>) {
    let Some(idx) = messages
        .iter()
        .rposition(|m| m.role == Role::Assistant && m.tool_calls.is_some())
    else {
        return;
    };
    let call_ids: Vec<String> = messages[idx]
        .tool_calls
        .as_ref()
        .map(|calls| calls.iter().map(|c| c.id.clone()).collect())
        .unwrap_or_default();
    let answered: std::collections::HashSet<&str> = messages[idx + 1..]
        .iter()
        .filter(|m| m.role == Role::Tool)
        .filter_map(|m| m.tool_call_id.as_deref())
        .collect();
    let missing: Vec<String> = call_ids
        .into_iter()
        .filter(|id| !answered.contains(id.as_str()))
        .collect();
    for id in missing {
        messages.push(ChatMessage::tool_result(id, "[interrupted]"));
    }
}

/// Very rough token estimate (~4 characters per token) for `text`. Used only as
/// a fallback when the server reports no usage — good enough for the context bar
/// + auto-compaction, not for billing.
fn estimate_tokens(text: &str) -> u32 {
    (text.len() / 4) as u32
}

/// Estimate the prompt tokens of a whole request: each message's content and any
/// tool-call names/arguments, plus a small per-message overhead for the role and
/// structural tokens the chat template adds.
fn estimate_tokens_in_messages(messages: &[ChatMessage]) -> u32 {
    messages
        .iter()
        .map(|m| {
            let content = m.content.as_deref().map(str::len).unwrap_or(0);
            let calls = m
                .tool_calls
                .as_ref()
                .map(|tcs| {
                    tcs.iter()
                        .map(|c| c.function.name.len() + c.function.arguments.len())
                        .sum::<usize>()
                })
                .unwrap_or(0);
            (content + calls) as u32 / 4 + 4
        })
        .sum()
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::{
        Agent, AgentConfig, AgentEvent, ELIDE_TOOL_RESULT_BYTES, ENV_SETTERS, FileConfig,
        PRUNE_PLACEHOLDER, ProviderConfig, ToolOutputConfig, builtin_provider,
        compaction_tail_start, elide_tool_results, estimate_tokens, estimate_tokens_in_messages,
        in_git_repo, is_context_overflow, is_transient, parse_env_bool, prune_tool_messages,
        repair_dangling_tool_calls, steering_queue, tail_window,
    };
    use crate::cwd_slug;
    use hrdr_llm::{ChatMessage, FunctionCall, Role, ToolCall};

    fn system_prompt(agent: &Agent) -> String {
        agent.messages()[0].content.clone().unwrap_or_default()
    }

    fn assistant_with_calls(ids: &[&str]) -> ChatMessage {
        ChatMessage {
            role: Role::Assistant,
            content: None,
            reasoning_content: None,
            tool_calls: Some(
                ids.iter()
                    .map(|id| ToolCall {
                        id: id.to_string(),
                        kind: "function".to_string(),
                        function: FunctionCall {
                            name: "t".to_string(),
                            arguments: "{}".to_string(),
                        },
                    })
                    .collect(),
            ),
            tool_call_id: None,
            name: None,
        }
    }

    #[test]
    fn repair_backfills_missing_tool_results_after_interrupt() {
        // Interrupted after the first of two calls got its result.
        let mut msgs = vec![
            ChatMessage::user("go"),
            assistant_with_calls(&["a", "b"]),
            ChatMessage::tool_result("a", "done a"),
        ];
        repair_dangling_tool_calls(&mut msgs);
        // A stub was appended for the unanswered "b" — history is now valid.
        assert_eq!(msgs.len(), 4);
        let last = msgs.last().unwrap();
        assert_eq!(last.role, Role::Tool);
        assert_eq!(last.tool_call_id.as_deref(), Some("b"));
        assert_eq!(last.content.as_deref(), Some("[interrupted]"));
    }

    #[test]
    fn repair_is_a_noop_when_all_calls_are_answered() {
        let mut msgs = vec![
            assistant_with_calls(&["a"]),
            ChatMessage::tool_result("a", "done"),
        ];
        let before = msgs.len();
        repair_dangling_tool_calls(&mut msgs);
        assert_eq!(msgs.len(), before);
    }

    #[test]
    fn repair_ignores_history_with_no_tool_calls() {
        let mut msgs = vec![ChatMessage::user("hi"), ChatMessage::assistant("hello")];
        repair_dangling_tool_calls(&mut msgs);
        assert_eq!(msgs.len(), 2);
    }

    #[test]
    fn token_estimate_scales_with_content() {
        use super::{estimate_tokens, estimate_tokens_in_messages};
        // ~4 chars/token.
        assert_eq!(estimate_tokens(&"x".repeat(40)), 10);
        assert_eq!(estimate_tokens(""), 0);
        // Per-message overhead + content; more content ⇒ strictly more tokens.
        let small = estimate_tokens_in_messages(&[ChatMessage::user("hi")]);
        let big = estimate_tokens_in_messages(&[ChatMessage::user("word ".repeat(100))]);
        assert!(big > small);
        assert!(small >= 4, "per-message overhead applies");
    }

    #[test]
    fn clear_rereads_agents_md() {
        let dir = tempfile::tempdir().unwrap();
        let agents_md = dir.path().join("AGENTS.md");
        std::fs::write(&agents_md, "ORIGINAL_MARKER").unwrap();

        let cfg = AgentConfig {
            cwd: dir.path().to_path_buf(),
            checkpoints: Some("off".to_string()), // keep the test hermetic
            ..Default::default()
        };
        let mut agent = Agent::new(cfg).unwrap();
        assert!(system_prompt(&agent).contains("ORIGINAL_MARKER"));

        // An updated AGENTS.md must be reflected after /clear (the bug: the old
        // system prompt was kept, so stale instructions lingered forever).
        std::fs::write(&agents_md, "UPDATED_MARKER").unwrap();
        agent.clear();
        let sys = system_prompt(&agent);
        assert!(sys.contains("UPDATED_MARKER"));
        assert!(!sys.contains("ORIGINAL_MARKER"));

        // Removing AGENTS.md drops it entirely on the next /clear.
        std::fs::remove_file(&agents_md).unwrap();
        agent.clear();
        assert!(!system_prompt(&agent).contains("UPDATED_MARKER"));
    }

    #[test]
    fn drain_steering_injects_messages_and_signals() {
        let cfg = AgentConfig {
            checkpoints: Some("off".to_string()),
            ..Default::default()
        };
        let mut agent = Agent::new(cfg).unwrap();
        let steering = steering_queue();
        {
            let mut q = steering.lock().unwrap();
            q.push_back("use ripgrep instead".to_string());
            q.push_back("and skip the tests".to_string());
        }
        assert!(Agent::has_steering(&steering));

        let mut events = Vec::new();
        agent.drain_steering(&steering, &mut |e| events.push(e));

        // Both messages are appended verbatim as user turns…
        let msgs = agent.messages();
        assert_eq!(
            msgs[msgs.len() - 2].content.as_deref(),
            Some("use ripgrep instead")
        );
        assert_eq!(
            msgs[msgs.len() - 1].content.as_deref(),
            Some("and skip the tests")
        );
        assert!(msgs[msgs.len() - 1].role == Role::User);
        // …a Steered event fires for each (frontends display at delivery)…
        let steered: Vec<_> = events
            .iter()
            .filter_map(|e| match e {
                AgentEvent::Steered(s) => Some(s.as_str()),
                _ => None,
            })
            .collect();
        assert_eq!(steered, ["use ripgrep instead", "and skip the tests"]);
        // …and the queue is drained.
        assert!(!Agent::has_steering(&steering));
    }

    #[test]
    fn classifies_transient_and_overflow_errors() {
        let overflow = anyhow::anyhow!(
            "chat endpoint returned 400 Bad Request: This model's maximum context length is 8192 tokens"
        );
        assert!(is_context_overflow(&overflow));
        assert!(!is_transient(&overflow));

        let rate = anyhow::anyhow!("chat endpoint returned 429 Too Many Requests: slow down");
        assert!(is_transient(&rate));
        assert!(!is_context_overflow(&rate));

        let net = anyhow::anyhow!("chat stream request failed: connection refused");
        assert!(is_transient(&net));

        let plain = anyhow::anyhow!("chat endpoint returned 400 Bad Request: invalid tool schema");
        assert!(!is_transient(&plain));
        assert!(!is_context_overflow(&plain));
    }

    #[test]
    fn zen_builtin_is_remote_with_opencode_key() {
        let p = builtin_provider("ZEN").expect("zen resolves (case-insensitive)");
        assert_eq!(p.base_url, "https://opencode.ai/zen/v1");
        assert_eq!(p.key_env.as_deref(), Some("OPENCODE_API_KEY"));
        assert!(p.remote);
        assert!(builtin_provider("opencode").is_some());
    }

    #[test]
    fn local_builtin_is_not_remote_and_unknown_is_none() {
        assert!(!builtin_provider("local").unwrap().remote);
        assert!(builtin_provider("nope").is_none());
    }

    #[test]
    fn config_provider_overrides_builtin() {
        let mut cfg = AgentConfig::default();
        cfg.providers.insert(
            "zen".to_string(),
            ProviderConfig {
                base_url: "https://my.zen/v1".to_string(),
                key_env: Some("MY_KEY".to_string()),
                api_key: None,
                model: Some("my-model".to_string()),
                remote: Some(true),
                context_window: Some(123),
            },
        );
        // Custom "zen" shadows the built-in; an unknown custom name resolves too.
        let p = cfg.resolve_provider("zen").unwrap();
        assert_eq!(p.base_url, "https://my.zen/v1");
        assert_eq!(p.model.as_deref(), Some("my-model"));
        assert_eq!(p.context_window, Some(123));
        // Built-ins still resolve when not shadowed.
        assert!(cfg.resolve_provider("openai").is_some());
        assert!(cfg.resolve_provider("nope").is_none());
    }

    // ---- parse_env_bool ----

    #[test]
    fn parse_env_bool_recognizes_all_spellings() {
        // falsy
        for s in ["0", "false", "off", "no", "FALSE", "OFF"] {
            assert_eq!(parse_env_bool(s), Some(false), "expected false for {s:?}");
        }
        // truthy
        for s in ["1", "true", "on", "yes", "TRUE", "YES"] {
            assert_eq!(parse_env_bool(s), Some(true), "expected true for {s:?}");
        }
        // unrecognized → None (leave current value unchanged)
        assert_eq!(parse_env_bool("maybe"), None);
        assert_eq!(parse_env_bool(""), None);
        assert_eq!(parse_env_bool("2"), None);
    }

    // ---- ENV_SETTERS ----

    fn find_setter(key: &str) -> fn(&mut AgentConfig, String) {
        ENV_SETTERS
            .iter()
            .find(|(k, _)| *k == key)
            .map(|(_, s)| *s)
            .unwrap_or_else(|| panic!("setter not found for {key}"))
    }

    #[test]
    #[allow(clippy::type_complexity)]
    fn env_setters_string_fields_directly() {
        // Exercise each string-typed setter in ENV_SETTERS by calling the fn
        // pointer directly, without touching process environment.
        let cases: &[(&str, fn(&AgentConfig) -> &str)] = &[
            ("HRDR_PROVIDER", |c| c.provider.as_deref().unwrap_or("")),
            ("HRDR_BASE_URL", |c| &c.base_url),
            ("HRDR_MODEL", |c| &c.model),
            ("HRDR_CHECKPOINTS", |c| {
                c.checkpoints.as_deref().unwrap_or("")
            }),
        ];
        for (key, getter) in cases {
            let setter = find_setter(key);
            let mut cfg = AgentConfig::default();
            setter(&mut cfg, "test_value".to_string());
            assert_eq!(getter(&cfg), "test_value", "setter for {key} did not apply");
        }
    }

    #[test]
    fn env_setter_numeric_ignores_bad_value() {
        // HRDR_AUTO_COMPACT with a non-numeric string must leave the value.
        let setter = find_setter("HRDR_AUTO_COMPACT");
        let mut cfg = AgentConfig::default();
        let original = cfg.auto_compact;
        setter(&mut cfg, "notanumber".to_string());
        assert!(
            (cfg.auto_compact - original).abs() < f64::EPSILON,
            "bad numeric value should be ignored"
        );
    }

    #[test]
    fn env_setter_auto_compact_numeric() {
        let setter = find_setter("HRDR_AUTO_COMPACT");
        let mut cfg = AgentConfig::default();
        setter(&mut cfg, "0.5".to_string());
        assert!((cfg.auto_compact - 0.5).abs() < f64::EPSILON);
    }

    // ---- apply_file ----

    #[test]
    fn apply_file_sets_all_fields() {
        let mut cfg = AgentConfig::default();
        cfg.apply_file(FileConfig {
            base_url: Some("http://custom/v1".to_string()),
            api_key: Some("key123".to_string()),
            model: Some("gpt-4".to_string()),
            temperature: Some(0.5),
            provider: Some("zen".to_string()),
            context_window: Some(8192),
            effort: Some("high".to_string()),
            auto_compact: Some(0.7),
            compaction_reserved: Some(12_345),
            auto_prune: Some(false),
            checkpoints: Some("on".to_string()),
            providers: HashMap::new(),
            guardrails: vec![],
            allow_outside_cwd: Some(true),
            hooks: vec![],
            tool_output: Some(ToolOutputConfig {
                max_lines: Some(500),
                max_bytes: Some(20_000),
            }),
            compaction_tail_turns: Some(4),
            preserve_recent_tokens: Some(12_000),
            mcp: vec![],
            prompt_cache: Some("on".to_string()),
        });
        assert_eq!(cfg.prompt_cache.as_deref(), Some("on"));
        assert_eq!(cfg.tool_max_lines, 500);
        assert_eq!(cfg.tool_max_bytes, 20_000);
        assert_eq!(cfg.compaction_tail_turns, 4);
        assert_eq!(cfg.preserve_recent_tokens, 12_000);
        assert_eq!(cfg.base_url, "http://custom/v1");
        assert_eq!(cfg.api_key.as_deref(), Some("key123"));
        assert_eq!(cfg.model, "gpt-4");
        assert_eq!(cfg.temperature, Some(0.5));
        assert_eq!(cfg.provider.as_deref(), Some("zen"));
        assert_eq!(cfg.context_window, Some(8192));
        assert_eq!(cfg.effort.as_deref(), Some("high"));
        assert!((cfg.auto_compact - 0.7).abs() < f64::EPSILON);
        assert_eq!(cfg.compaction_reserved, 12_345);
        assert!(!cfg.auto_prune);
        assert_eq!(cfg.checkpoints.as_deref(), Some("on"));
        assert!(cfg.allow_outside_cwd);
    }

    #[test]
    fn cache_mode_resolves_setting_and_endpoint() {
        use super::resolve_cache_mode;
        use hrdr_llm::CacheMode;
        // Explicit settings win regardless of endpoint.
        assert_eq!(
            resolve_cache_mode(Some("off"), "https://openrouter.ai/api/v1"),
            CacheMode::Off
        );
        assert_eq!(
            resolve_cache_mode(Some("on"), "https://api.openai.com/v1"),
            CacheMode::Ephemeral
        );
        // auto (None or "auto"): only OpenRouter (which safely consumes the
        // marker); a subdomain counts too.
        assert_eq!(
            resolve_cache_mode(None, "https://openrouter.ai/api/v1"),
            CacheMode::Ephemeral
        );
        assert_eq!(
            resolve_cache_mode(Some("auto"), "https://gateway.openrouter.ai/v1"),
            CacheMode::Ephemeral
        );
        // Direct provider endpoints that reject or ignore the marker → off in
        // auto (they 400 on it or cache automatically). This is the fix for the
        // blanket-remote default.
        assert_eq!(
            resolve_cache_mode(None, "https://api.openai.com/v1"),
            CacheMode::Off
        );
        assert_eq!(
            resolve_cache_mode(None, "https://api.groq.com/openai/v1"),
            CacheMode::Off
        );
        assert_eq!(
            resolve_cache_mode(None, "https://opencode.ai/zen/v1"),
            CacheMode::Off
        );
        // Anthropic's own host → on: hrdr speaks the native Messages API there,
        // where cache_control actually caches.
        assert_eq!(
            resolve_cache_mode(None, "https://api.anthropic.com/v1"),
            CacheMode::Ephemeral
        );
        // Local endpoints stay off; a "not-openrouter.ai.evil.com" host must not
        // match the suffix check.
        assert_eq!(
            resolve_cache_mode(None, "http://127.0.0.1:8080/v1"),
            CacheMode::Off
        );
        assert_eq!(
            resolve_cache_mode(None, "https://openrouter.ai.evil.com/v1"),
            CacheMode::Off
        );
    }

    #[test]
    fn guardrails_parse_from_config_toml() {
        let fc: FileConfig = toml::from_str(
            r#"
            model = "qwen3"

            [[guardrails]]
            pattern = "\\brm\\s+-rf\\b"
            message = "no recursive force-remove"

            [[guardrails]]
            pattern = "\\bnpm\\s+publish\\b"
            message = "publishing is manual"
            "#,
        )
        .unwrap();
        let mut cfg = AgentConfig::default();
        cfg.apply_file(fc);
        assert_eq!(cfg.guardrails.len(), 2);
        assert_eq!(cfg.guardrails[0].message, "no recursive force-remove");
        assert_eq!(cfg.guardrails[1].pattern, r"\bnpm\s+publish\b");
    }

    #[test]
    fn hooks_parse_from_config_toml() {
        let fc: FileConfig = toml::from_str(
            r#"
            [[hooks]]
            on = "edit"
            glob = "*.rs"
            run = "cargo fmt -- {path}"

            [[hooks]]
            run = "prettier --write {path}"
            timeout_ms = 5000
            "#,
        )
        .unwrap();
        let mut cfg = AgentConfig::default();
        cfg.apply_file(fc);
        assert_eq!(cfg.hooks.len(), 2);
        assert_eq!(cfg.hooks[0].on, "edit");
        assert_eq!(cfg.hooks[0].glob.as_deref(), Some("*.rs"));
        assert_eq!(cfg.hooks[1].on, "*"); // default: any file-mutating tool
        assert_eq!(cfg.hooks[1].timeout_ms, Some(5000));
    }

    #[test]
    fn tool_output_parses_from_config_toml() {
        let fc: FileConfig = toml::from_str(
            r#"
            [tool_output]
            max_lines = 1000
            max_bytes = 32768
            "#,
        )
        .unwrap();
        let mut cfg = AgentConfig::default();
        cfg.apply_file(fc);
        assert_eq!(cfg.tool_max_lines, 1000);
        assert_eq!(cfg.tool_max_bytes, 32768);
        // A partial table leaves the unset field at its default.
        let partial: FileConfig = toml::from_str("[tool_output]\nmax_bytes = 100\n").unwrap();
        let mut cfg2 = AgentConfig::default();
        cfg2.apply_file(partial);
        assert_eq!(cfg2.tool_max_bytes, 100);
        assert_eq!(cfg2.tool_max_lines, hrdr_tools::DEFAULT_MAX_OUTPUT_LINES);
    }

    #[test]
    fn mcp_parses_from_config_toml() {
        let fc: FileConfig = toml::from_str(
            r#"
            [[mcp]]
            name = "fs"
            command = "npx"
            args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]

            [[mcp]]
            name = "gh"
            command = "gh-mcp"
            disabled = true
            [mcp.env]
            GITHUB_TOKEN = "secret"

            [[mcp]]
            name = "remote"
            url = "https://example.com/mcp"
            [mcp.headers]
            Authorization = "Bearer xyz"

            [[mcp]]
            name = "legacy"
            url = "https://example.com/sse"
            transport = "sse"
            "#,
        )
        .unwrap();
        let mut cfg = AgentConfig::default();
        cfg.apply_file(fc);
        assert_eq!(cfg.mcp.len(), 4);
        // stdio server.
        assert_eq!(cfg.mcp[0].name, "fs");
        assert_eq!(cfg.mcp[0].command.as_deref(), Some("npx"));
        assert_eq!(cfg.mcp[0].args.len(), 3);
        assert!(cfg.mcp[0].url.is_none());
        assert!(!cfg.mcp[0].disabled);
        assert!(cfg.mcp[1].disabled);
        assert_eq!(
            cfg.mcp[1].env.get("GITHUB_TOKEN").map(String::as_str),
            Some("secret")
        );
        // HTTP (Streamable) server.
        assert_eq!(cfg.mcp[2].url.as_deref(), Some("https://example.com/mcp"));
        assert!(cfg.mcp[2].command.is_none());
        assert!(cfg.mcp[2].transport.is_none());
        assert_eq!(
            cfg.mcp[2].headers.get("Authorization").map(String::as_str),
            Some("Bearer xyz")
        );
        // Legacy HTTP+SSE server.
        assert_eq!(cfg.mcp[3].url.as_deref(), Some("https://example.com/sse"));
        assert_eq!(cfg.mcp[3].transport.as_deref(), Some("sse"));
    }

    // ---- is_transient / is_context_overflow (additional variants) ----

    #[test]
    fn is_transient_more_variants() {
        for msg in [
            "chat stream request failed: connection timed out",
            "broken pipe",
            "chat endpoint returned 502 Bad Gateway: upstream down",
            "chat endpoint returned 503 Service Unavailable",
            "chat endpoint returned 504 Gateway Timeout",
            "connection reset by peer",
        ] {
            assert!(
                is_transient(&anyhow::anyhow!("{msg}")),
                "expected transient for: {msg}"
            );
        }
    }

    #[test]
    fn is_context_overflow_more_variants() {
        for msg in [
            "context window exceeded",
            "too many tokens in the prompt",
            "please reduce the length of the messages",
            "context size limit reached",
            "context_length exceeded",
            // Provider-specific patterns ported from pi.
            "prompt is too long: 213462 tokens > 200000 maximum", // Anthropic
            "request_too_large",                                  // Anthropic 413
            "your input exceeds the context window of this model", // OpenAI
            "the input token count (1196265) exceeds the maximum", // Gemini
            "this model's maximum prompt length is 131072",       // xAI
            "exceeds the maximum allowed input length of 8000 tokens", // OpenRouter
            "is longer than the model's context length (4096 tokens)", // Together
            "prompt token count of 5 exceeds the limit of 4",     // Copilot
            "your request exceeded model token limit",            // Kimi
            "too large for model with 8192 maximum context length", // Mistral
            "model_context_window_exceeded",                      // z.ai
        ] {
            assert!(
                is_context_overflow(&anyhow::anyhow!("{msg}")),
                "expected context overflow for: {msg}"
            );
        }
        // Rate-limit / throttling is NOT overflow, even when it mentions tokens.
        for msg in [
            "chat endpoint returned 429 Too Many Requests: slow down",
            "ThrottlingException: too many tokens, please wait",
            "rate limit exceeded, retry after 20s",
        ] {
            assert!(
                !is_context_overflow(&anyhow::anyhow!("{msg}")),
                "throttling must not be treated as overflow: {msg}"
            );
        }
    }

    // ---- compaction shrink helpers ----

    #[test]
    fn elide_tool_results_truncates_only_bulky_tool_bodies() {
        let big = "x".repeat(ELIDE_TOOL_RESULT_BYTES + 100);
        let msgs = vec![
            ChatMessage::user(big.clone()),
            ChatMessage::tool_result("a", big),
            ChatMessage::tool_result("b", "small"),
        ];
        let out = elide_tool_results(&msgs);
        // User content untouched, small tool result untouched, big one cut.
        assert_eq!(out[0].content, msgs[0].content);
        assert!(out[1].content.as_ref().unwrap().contains("elided"));
        assert!(out[1].content.as_ref().unwrap().len() < msgs[1].content.as_ref().unwrap().len());
        assert_eq!(out[2].content.as_deref(), Some("small"));
    }

    #[test]
    fn tail_window_never_starts_on_a_tool_result() {
        // Halving this history would start the window on a tool result,
        // orphaning it from its assistant tool_calls message.
        let msgs = vec![
            ChatMessage::user("1"),
            ChatMessage::user("2"),
            assistant_with_calls(&["a"]),
            ChatMessage::tool_result("a", "r"),
            ChatMessage::assistant("done"),
            ChatMessage::user("3"),
        ];
        let out = tail_window(&msgs, 2);
        assert!(out[0].role != Role::Tool, "window starts on a tool result");
        assert!(!out.is_empty() && out.len() < msgs.len());
    }

    #[test]
    fn prune_clears_old_tool_output_beyond_protected_window() {
        // Four turns, each with one big tool result (~10k tokens: len/4).
        let big = "x".repeat(40_000);
        assert_eq!(estimate_tokens(&big), 10_000);
        let mut msgs = vec![
            ChatMessage::user("u1"),
            assistant_with_calls(&["a"]),
            ChatMessage::tool_result("a", big.clone()), // 2 — oldest → pruned
            ChatMessage::user("u2"),
            assistant_with_calls(&["b"]),
            ChatMessage::tool_result("b", big.clone()), // 5 — inside protect window
            ChatMessage::user("u3"),
            assistant_with_calls(&["c"]),
            ChatMessage::tool_result("c", big.clone()), // 8 — last-2-turns protected
            ChatMessage::user("u4"),
            assistant_with_calls(&["d"]),
            ChatMessage::tool_result("d", big.clone()), // 11 — current turn protected
        ];
        // Protect window 16k tokens, minimum 8k, keep 2 turns: turn-3/4 output
        // is shielded by the last-2-turns rule, turn-2's 10k fills the window,
        // so only turn-1's 10k (the oldest) is cleared.
        let reclaimed = prune_tool_messages(&mut msgs, 16_000, 8_000, 2);
        assert!(reclaimed >= 8_000);
        assert_eq!(reclaimed, estimate_tokens(&big));
        assert_eq!(msgs[2].content.as_deref(), Some(PRUNE_PLACEHOLDER));
        for kept in [5, 8, 11] {
            assert_eq!(msgs[kept].content.as_deref(), Some(big.as_str()));
        }
        // The assistant tool_calls metadata is never touched.
        assert!(msgs[1].tool_calls.is_some());

        // Idempotent: a second pass finds only the placeholder + kept window.
        assert_eq!(prune_tool_messages(&mut msgs, 16_000, 8_000, 2), 0);
    }

    #[test]
    fn prune_is_a_noop_below_the_minimum() {
        // Protect window (16k) is filled by one 14k result; the only prunable
        // result is 3k tokens — below the 8k minimum, so nothing changes.
        let within = "x".repeat(56_000); // 14k tokens
        let tiny = "x".repeat(12_000); // 3k tokens
        let mut msgs = vec![
            ChatMessage::user("u1"),
            assistant_with_calls(&["a"]),
            ChatMessage::tool_result("a", tiny.clone()), // 2 — 3k prunable
            ChatMessage::user("u2"),
            assistant_with_calls(&["b"]),
            ChatMessage::tool_result("b", within.clone()), // 5 — fills the window
            ChatMessage::user("u3"),
            assistant_with_calls(&["c"]),
            ChatMessage::tool_result("c", "recent".to_string()), // 8 — protected
            ChatMessage::user("u4"),
        ];
        // protect 16k, minimum 8k: `within` fills the window, `tiny` (3k) is the
        // only prunable — below the minimum, so nothing changes.
        assert!(estimate_tokens(&tiny) < 8_000);
        let reclaimed = prune_tool_messages(&mut msgs, 16_000, 8_000, 2);
        assert_eq!(reclaimed, 0);
        assert_eq!(msgs[2].content.as_deref(), Some(tiny.as_str())); // untouched
    }

    #[test]
    fn compaction_tail_start_keeps_turns_within_token_budget() {
        let big = "x".repeat(20_000); // ~5000 tokens each (len/4)
        let msgs = vec![
            ChatMessage::system("sys"),          // 0
            ChatMessage::user("u1"),             // 1
            ChatMessage::assistant(big.clone()), // 2
            ChatMessage::user("u2"),             // 3
            ChatMessage::assistant(big.clone()), // 4
            ChatMessage::user("u3"),             // 5
            ChatMessage::assistant(big.clone()), // 6
        ];
        // Generous budget: keep the last 2 whole turns → tail starts at u2 (3).
        assert_eq!(compaction_tail_start(&msgs, 2, 1_000_000), 3);
        // One turn only → starts at u3 (5).
        assert_eq!(compaction_tail_start(&msgs, 1, 1_000_000), 5);
        // Budget caps it to the newest turn even when tail_turns allows more
        // (each turn is ~5k tokens; two would bust an 8k budget).
        assert_eq!(compaction_tail_start(&msgs, 3, 8_000), 5);
        // tail_turns = 0 keeps nothing verbatim (whole history summarized).
        assert_eq!(compaction_tail_start(&msgs, 0, 8_000), msgs.len());
        // The tail always begins on a user message — never orphans a tool result.
        let start = compaction_tail_start(&msgs, 2, 1_000_000);
        assert_eq!(msgs[start].role, Role::User);
    }

    #[test]
    fn repeat_guard_blocks_verbatim_loops_only() {
        let mut g = super::RepeatGuard::default();
        // First failure: no nudge, no refusal.
        assert!(g.record("edit", "{a}", false).is_none());
        assert!(g.refusal("edit", "{a}").is_none());
        // Second identical failure: nudge; third attempt: refused.
        assert!(g.record("edit", "{a}", false).is_some());
        assert!(g.refusal("edit", "{a}").is_some());
        // A different call resets the streak — the same call may run again…
        assert!(g.record("bash", "{fix}", true).is_none());
        assert!(g.refusal("edit", "{a}").is_none());
        // …so test → edit → test cycles are never blocked.
        assert!(g.record("bash", "{test}", false).is_none());
        assert!(g.record("edit", "{fix2}", true).is_none());
        assert!(g.refusal("bash", "{test}").is_none());
        // Success of the previously failing call clears it too.
        assert!(g.record("edit", "{a}", false).is_none());
        assert!(g.record("edit", "{a}", true).is_none());
        assert!(g.refusal("edit", "{a}").is_none());
        // Different args = different call.
        assert!(g.record("edit", "{x}", false).is_none());
        assert!(g.record("edit", "{y}", false).is_none());
        assert!(g.refusal("edit", "{y}").is_none());
    }

    // ---- repair_dangling_tool_calls (additional cases) ----

    #[test]
    fn repair_no_op_when_all_answered_then_user_turn() {
        // A complete turn followed by a subsequent user message should not get
        // stubs appended — the tool results are all present.
        let mut msgs = vec![
            ChatMessage::user("first"),
            assistant_with_calls(&["a", "b"]),
            ChatMessage::tool_result("a", "done_a"),
            ChatMessage::tool_result("b", "done_b"),
            ChatMessage::user("second"),
        ];
        let before = msgs.len();
        repair_dangling_tool_calls(&mut msgs);
        assert_eq!(
            msgs.len(),
            before,
            "no stubs expected when all calls answered"
        );
    }

    #[test]
    fn repair_partially_answered_three_calls() {
        // Three tool calls; only first two answered → stub for third only.
        let mut msgs = vec![
            ChatMessage::user("go"),
            assistant_with_calls(&["x", "y", "z"]),
            ChatMessage::tool_result("x", "rx"),
            ChatMessage::tool_result("y", "ry"),
        ];
        repair_dangling_tool_calls(&mut msgs);
        assert_eq!(msgs.len(), 5, "exactly one stub expected");
        let stub = msgs.last().unwrap();
        assert_eq!(stub.role, Role::Tool);
        assert_eq!(stub.tool_call_id.as_deref(), Some("z"));
        assert_eq!(stub.content.as_deref(), Some("[interrupted]"));
    }

    // ---- estimate_tokens ----

    #[test]
    fn estimate_tokens_in_messages_per_message_overhead() {
        // Even a message with no content should contribute at least 4 tokens
        // (the per-message overhead the implementation adds).
        use hrdr_llm::Role;
        let msg = ChatMessage {
            role: Role::User,
            content: None,
            reasoning_content: None,
            tool_calls: None,
            tool_call_id: None,
            name: None,
        };
        let estimate = estimate_tokens_in_messages(&[msg]);
        assert!(
            estimate >= 4,
            "per-message overhead must be at least 4, got {estimate}"
        );
    }

    #[test]
    fn estimate_tokens_monotonic_with_content_length() {
        let short = estimate_tokens("hi");
        let long = estimate_tokens(&"word ".repeat(1000));
        assert!(long > short, "longer text must produce more tokens");
    }

    // ---- in_git_repo ----

    #[test]
    fn in_git_repo_detects_git_dir() {
        let dir = tempfile::tempdir().unwrap();
        // Without .git: not a git repo.
        assert!(!in_git_repo(dir.path()));
        // With .git directory: detected.
        std::fs::create_dir(dir.path().join(".git")).unwrap();
        assert!(in_git_repo(dir.path()));
    }

    #[test]
    fn in_git_repo_detected_via_ancestor() {
        let dir = tempfile::tempdir().unwrap();
        let subdir = dir.path().join("a").join("b");
        std::fs::create_dir_all(&subdir).unwrap();
        std::fs::create_dir(dir.path().join(".git")).unwrap();
        // A nested subdirectory should also be considered inside the repo.
        assert!(in_git_repo(&subdir));
    }

    // ---- cwd_slug ----

    #[test]
    fn cwd_slug_sanitizes_path() {
        assert_eq!(cwd_slug("/home/me/projects/foo"), "home-me-projects-foo");
        assert_eq!(cwd_slug("/"), "root");
        assert_eq!(cwd_slug("  "), "root");
        // Consecutive separators collapse to a single dash.
        assert_eq!(cwd_slug("a//b"), "a-b");
    }
}
