//! Agent execution engine.
//!
//! Provides the LLM provider abstraction, context building, tool dispatch,
//! and the core agent run loop that ties everything together.

pub mod anthropic;
pub mod anthropic_wire;
pub mod bedrock;
pub mod builtin_tools;
pub mod channel_tools;
pub mod classifier;
pub mod claude_cli;
pub mod codex;
pub mod context;
pub mod executor;
pub mod exfiltration;
pub mod factory;
pub mod filesystem_tools;
pub mod gemini;
pub mod nearai;
pub mod ollama;
pub mod openai;
pub mod openai_wire;
pub mod output_sanitizer;
pub mod prompt_guard;
pub mod provider;
pub mod sandbox;
pub mod tool_policy;
pub mod tools;
pub mod venice;
pub mod vertex;

use std::sync::Arc;

use futures_util::FutureExt;
use serde_json::Value;
use tracing::warn;

use crate::server::ws::{AgentRunStatus, WsServerState};
pub use executor::execute_run;
pub use provider::{LlmProvider, StreamEvent};
use tokio_util::sync::CancellationToken;
pub use tool_policy::ToolPolicy;

/// Stable categories for configuration failures surfaced through `AgentError`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentConfigurationErrorCode {
    UnknownRoute,
    MissingModel,
    ProviderNotConfigured,
}

impl AgentConfigurationErrorCode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::UnknownRoute => "unknown_route",
            Self::MissingModel => "missing_model",
            Self::ProviderNotConfigured => "provider_not_configured",
        }
    }
}

/// Configuration resolution error with an external-safe display message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentConfigurationError {
    code: AgentConfigurationErrorCode,
    public_message: &'static str,
    operator_hint: String,
}

impl AgentConfigurationError {
    pub fn unknown_route(route_name: &str) -> Self {
        Self {
            code: AgentConfigurationErrorCode::UnknownRoute,
            public_message: "requested route is not configured",
            operator_hint: format!(
                "unknown route \"{route_name}\"; define it in the top-level `routes` config map"
            ),
        }
    }

    pub fn missing_model() -> Self {
        Self {
            code: AgentConfigurationErrorCode::MissingModel,
            public_message: "agent model is not configured",
            operator_hint:
                "no model configured; set `route` or `model` in agent config or defaults \
                 (e.g. `agents.defaults.route: \"fast\"` or \
                 `agents.defaults.model: \"provider:model\"`)"
                    .to_string(),
        }
    }

    pub fn provider_not_configured() -> Self {
        Self {
            code: AgentConfigurationErrorCode::ProviderNotConfigured,
            public_message: "no LLM provider is configured",
            operator_hint: "no LLM provider is configured; examples include setting \
                 ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, NEARAI_API_KEY, or VENICE_API_KEY; \
                 configuring Ollama; configuring an authProfile (anthropic.authProfile, \
                 google.authProfile, codex.authProfile); or configuring AWS Bedrock \
                 (AWS_REGION + credentials), Vertex AI (VERTEX_PROJECT_ID), or the \
                 Claude CLI provider"
                .to_string(),
        }
    }

    pub fn code(&self) -> AgentConfigurationErrorCode {
        self.code
    }

    pub fn public_message(&self) -> &'static str {
        self.public_message
    }

    #[cfg(test)]
    pub(crate) fn operator_hint(&self) -> &str {
        &self.operator_hint
    }

    pub fn log_operator_hint(&self) {
        warn!(
            code = self.code.as_str(),
            operator_hint = %self.operator_hint,
            "agent configuration error"
        );
    }
}

impl std::fmt::Display for AgentConfigurationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.public_message)
    }
}

impl std::error::Error for AgentConfigurationError {}

/// Errors that can occur during agent execution.
#[derive(Debug, thiserror::Error)]
pub enum AgentError {
    #[error("LLM provider error: {0}")]
    Provider(String),

    #[error("{0}")]
    Configuration(AgentConfigurationError),

    #[error("session not found: {0}")]
    SessionNotFound(String),

    #[error("session store error: {0}")]
    SessionStore(String),

    #[error("tool execution error: {0}")]
    ToolExecution(String),

    #[error("invalid API key: {0}")]
    InvalidApiKey(String),

    #[error("invalid base URL: {0}")]
    InvalidBaseUrl(String),

    #[error("run cancelled")]
    Cancelled,

    #[error("max turns exceeded ({0})")]
    MaxTurns(u32),

    #[error("streaming error: {0}")]
    Stream(String),

    #[error("delivery error: {0}")]
    Delivery(String),

    #[error("classifier blocked message ({0}): {1}")]
    ClassifierBlocked(String, String),
}

impl AgentError {
    pub fn log_configuration_hint(&self) {
        if let Self::Configuration(error) = self {
            error.log_operator_hint();
        }
    }

    /// Stable wire-format error code for typed errors. Returns `None`
    /// for free-form variants without a documented code.
    pub fn wire_code(&self) -> Option<&'static str> {
        match self {
            Self::Configuration(error) => Some(error.code().as_str()),
            _ => None,
        }
    }
}

/// Configuration for an agent run.
#[derive(Debug, Clone)]
pub struct AgentConfig {
    /// LLM model identifier (e.g., "claude-sonnet-4-6").
    pub model: String,
    /// Optional system prompt prepended to context.
    pub system: Option<String>,
    /// Maximum agentic turns (LLM round-trips). Default 25.
    pub max_turns: u32,
    /// Maximum output tokens per LLM call. Default 8192.
    pub max_tokens: u32,
    /// Sampling temperature. None means provider default.
    pub temperature: Option<f64>,
    /// Whether to deliver the final message via the channel pipeline.
    pub deliver: bool,
    /// Tool policy controlling which tools this agent may invoke.
    pub tool_policy: ToolPolicy,
    /// When `true`, exfiltration-sensitive tools (those that send data to
    /// external services) are blocked at both the definition and dispatch
    /// levels.  This prevents prompt-injection attacks from silently
    /// exfiltrating user data. Default: `false`.
    pub exfiltration_guard: bool,
    /// Prompt guard configuration for defense-in-depth filtering.
    pub prompt_guard: prompt_guard::PromptGuardConfig,
    /// OS-level sandbox configuration for tool subprocess execution.
    pub process_sandbox: sandbox::ProcessSandboxConfig,
    /// Output sanitizer configuration for safe web rendering (CSP, HTML/Markdown
    /// sanitization).
    pub output_sanitizer: output_sanitizer::OutputSanitizerConfig,
    /// Inbound message classifier configuration (off by default).
    pub classifier: Option<classifier::ClassifierConfig>,
    /// Provider-specific parameters injected into the request body.
    ///
    /// Populated from the `venice_parameters` key in WS/HTTP requests.
    /// The Venice provider writes this as `body["venice_parameters"]`;
    /// other providers ignore it.
    pub extra: Option<serde_json::Value>,
}

impl Default for AgentConfig {
    fn default() -> Self {
        Self {
            model: String::new(),
            system: None,
            max_turns: 25,
            max_tokens: 8192,
            temperature: None,
            deliver: false,
            tool_policy: ToolPolicy::default(),
            exfiltration_guard: false,
            prompt_guard: prompt_guard::PromptGuardConfig::default(),
            process_sandbox: sandbox::ProcessSandboxConfig::default(),
            output_sanitizer: output_sanitizer::OutputSanitizerConfig::default(),
            classifier: None,
            extra: None,
        }
    }
}

/// Apply agent config overrides from the global configuration object.
///
/// This allows runtime toggles for safety features, tool policy, and classifier
/// settings, with optional per-agent overrides.
pub fn apply_agent_config_from_settings(
    config: &mut AgentConfig,
    settings: &Value,
    agent_id: Option<&str>,
) {
    // Global classifier config (top-level)
    if let Some(classifier_value) = settings.get("classifier") {
        match serde_json::from_value(classifier_value.clone()) {
            Ok(cfg) => config.classifier = Some(cfg),
            Err(e) => {
                warn!(error = %e, "invalid classifier config; classifier disabled");
                config.classifier = None;
            }
        }
    }

    let agents = match settings.get("agents").and_then(|v| v.as_object()) {
        Some(a) => a,
        None => return,
    };

    // Global prompt guard/output sanitizer defaults
    if let Some(pg_value) = agents.get("promptGuard") {
        match serde_json::from_value(pg_value.clone()) {
            Ok(pg_cfg) => {
                config.prompt_guard = pg_cfg;
            }
            Err(e) => {
                warn!(error = %e, "invalid agents.promptGuard config; using defaults");
            }
        }
    }

    let output_value = agents.get("outputSanitizer");
    if let Some(os_value) = output_value {
        match serde_json::from_value(os_value.clone()) {
            Ok(os_cfg) => {
                config.output_sanitizer = os_cfg;
            }
            Err(e) => {
                warn!(error = %e, "invalid agents.outputSanitizer config; using defaults");
            }
        }
    }

    // Apply defaults from agents.defaults
    if let Some(defaults) = agents.get("defaults").and_then(|v| v.as_object()) {
        apply_agent_overrides(config, defaults);
    }

    // Apply per-agent overrides (by id or default entry)
    if let Some(entry) = select_agent_entry(agents, agent_id) {
        apply_agent_overrides(config, entry);
    }
}

fn select_agent_entry<'a>(
    agents: &'a serde_json::Map<String, Value>,
    agent_id: Option<&str>,
) -> Option<&'a serde_json::Map<String, Value>> {
    let list = agents.get("list").and_then(|v| v.as_array())?;

    if let Some(id) = agent_id {
        if let Some(found) = list.iter().find(|entry| {
            entry
                .get("id")
                .and_then(|v| v.as_str())
                .is_some_and(|v| v == id)
        }) {
            return found.as_object();
        }
    }

    if let Some(default_entry) = list.iter().find(|entry| {
        entry
            .get("default")
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
    }) {
        return default_entry.as_object();
    }

    if let Some(id) = agent_id {
        warn!(agent_id = %id, "agentId not found and no default agent configured");
    }

    None
}

/// Per-request and per-session overrides for model resolution.
/// Passed by callers to provide request-level and session-level selectors.
#[derive(Debug, Default)]
pub struct ModelResolutionOverrides<'a> {
    pub request_route: Option<&'a str>,
    pub request_model: Option<&'a str>,
    pub session_route: Option<&'a str>,
    pub session_model: Option<&'a str>,
}

/// Resolve the agent's model from the route/model precedence chain.
///
/// Checks scopes in order: request → session → agent → defaults.
/// Within each scope, `route` is checked before `model`.
/// Request-level and session-level selectors can be passed for
/// per-request and per-session overrides.
pub fn resolve_agent_model(
    config: &mut AgentConfig,
    settings: &Value,
    agent_id: Option<&str>,
    overrides: &ModelResolutionOverrides<'_>,
) -> Result<(), AgentError> {
    let routes = crate::config::routes::load_routes(settings);

    let agents = settings.get("agents").and_then(|v| v.as_object());

    // Extract selectors from defaults
    let defaults = agents.and_then(|a| a.get("defaults").and_then(|v| v.as_object()));
    let defaults_route = defaults.and_then(|d| d.get("route").and_then(|v| v.as_str()));
    let defaults_model = defaults.and_then(|d| d.get("model").and_then(|v| v.as_str()));

    // Extract selectors from agent entry
    let agent_entry = agents.and_then(|a| select_agent_entry(a, agent_id));
    let agent_route = agent_entry.and_then(|e| e.get("route").and_then(|v| v.as_str()));
    let agent_model = agent_entry.and_then(|e| e.get("model").and_then(|v| v.as_str()));

    let inputs = crate::config::routes::RouteResolutionInputs {
        request: crate::config::routes::SelectorLevel {
            route: overrides.request_route,
            model: overrides.request_model,
        },
        session: crate::config::routes::SelectorLevel {
            route: overrides.session_route,
            model: overrides.session_model,
        },
        agent: crate::config::routes::SelectorLevel {
            route: agent_route,
            model: agent_model,
        },
        defaults: crate::config::routes::SelectorLevel {
            route: defaults_route,
            model: defaults_model,
        },
    };

    let resolved = crate::config::routes::resolve_execution_target(&routes, &inputs)?;
    config.model = resolved.model;
    Ok(())
}

fn apply_agent_overrides(config: &mut AgentConfig, agent_obj: &serde_json::Map<String, Value>) {
    // Model resolution is handled separately by resolve_agent_model;
    // skip the model field here.

    if let Some(system) = agent_obj.get("system").and_then(|v| v.as_str()) {
        if !system.trim().is_empty() {
            config.system = Some(system.to_string());
        }
    }

    if let Some(max_turns) = agent_obj.get("maxTurns").and_then(|v| v.as_u64()) {
        if max_turns > 0 {
            config.max_turns = max_turns.min(u32::MAX as u64) as u32;
        }
    }

    if let Some(max_tokens) = agent_obj.get("maxTokens").and_then(|v| v.as_u64()) {
        if max_tokens > 0 {
            config.max_tokens = max_tokens.min(u32::MAX as u64) as u32;
        }
    }

    if let Some(temp) = agent_obj.get("temperature").and_then(|v| v.as_f64()) {
        config.temperature = Some(temp);
    }

    if let Some(deliver) = agent_obj.get("deliver").and_then(|v| v.as_bool()) {
        config.deliver = deliver;
    }

    // Tool policy config (preferred: tools.policy/list)
    if let Some(tools_cfg) = agent_obj.get("tools") {
        config.tool_policy = ToolPolicy::from_config(Some(tools_cfg));
    } else if let Some(policy_str) = agent_obj.get("toolPolicy").and_then(|v| v.as_str()) {
        if let Some(policy) = parse_tool_policy_string(policy_str) {
            config.tool_policy = policy;
        }
    }

    if let Some(exfiltration_guard) = agent_obj.get("exfiltrationGuard").and_then(|v| v.as_bool()) {
        config.exfiltration_guard = exfiltration_guard;
    }

    if let Some(pg_value) = agent_obj.get("promptGuard") {
        match serde_json::from_value(pg_value.clone()) {
            Ok(pg_cfg) => config.prompt_guard = pg_cfg,
            Err(e) => warn!(error = %e, "invalid agent promptGuard config; using defaults"),
        }
    }

    if let Some(os_value) = agent_obj.get("outputSanitizer") {
        match serde_json::from_value(os_value.clone()) {
            Ok(os_cfg) => config.output_sanitizer = os_cfg,
            Err(e) => warn!(error = %e, "invalid agent outputSanitizer config; using defaults"),
        }
    }

    if let Some(sandbox_value) = agent_obj.get("sandbox") {
        // Mirror the promptGuard / outputSanitizer pattern: surface
        // deserialize errors so an operator-tuned sandbox section
        // doesn't silently fall back to Self::default() (losing
        // allowed_paths, network_access, env_filter together) when a
        // typo or wrong-type value slips past the schema-time
        // validator. The fallback still applies — defense-in-depth
        // is degraded — but the operator now sees the cause.
        match serde_json::from_value(sandbox_value.clone()) {
            Ok(cfg) => config.process_sandbox = cfg,
            Err(e) => warn!(
                error = %e,
                "invalid agent sandbox config; using defaults (defense-in-depth degraded)"
            ),
        }
    }

    if let Some(classifier_value) = agent_obj.get("classifier") {
        match serde_json::from_value(classifier_value.clone()) {
            Ok(cfg) => config.classifier = Some(cfg),
            Err(e) => warn!(error = %e, "invalid agent classifier config; classifier disabled"),
        }
    }
}

fn parse_tool_policy_string(value: &str) -> Option<ToolPolicy> {
    let normalized = value.to_lowercase().replace('_', "");
    match normalized.as_str() {
        "allowall" => Some(ToolPolicy::AllowAll),
        "allowlist" | "denylist" => {
            warn!(
                policy = %value,
                "toolPolicy requires tools.list; ignoring string-only policy"
            );
            None
        }
        _ => {
            warn!(policy = %value, "unrecognized toolPolicy value; ignoring");
            None
        }
    }
}

/// Spawn an agent run as a background tokio task.
///
/// Called from `handle_agent` and `handle_chat_send` after creating the `AgentRun`.
/// The task runs `execute_run()` and handles errors/panics.
///
/// A secondary supervisor task monitors the `JoinHandle` so that even if the
/// inner task panics in a way that `catch_unwind` does not capture (e.g. a
/// double-panic or a panic in the error-handling code itself), the run is
/// still marked as failed instead of staying in `Running` state forever.
pub fn spawn_run(
    run_id: String,
    session_key: String,
    config: AgentConfig,
    state: Arc<WsServerState>,
    provider: Arc<dyn LlmProvider>,
    cancel_token: CancellationToken,
) -> tokio::task::JoinHandle<()> {
    let supervisor_state = Arc::clone(&state);
    let supervisor_run_id = run_id.clone();

    let handle = tokio::spawn(async move {
        let result: Result<Result<(), AgentError>, _> = std::panic::AssertUnwindSafe(execute_run(
            run_id.clone(),
            session_key,
            config,
            state.clone(),
            provider,
            cancel_token,
        ))
        .catch_unwind()
        .await;

        match result {
            Ok(Ok(())) => { /* marked completed inside execute_run */ }
            Ok(Err(AgentError::Cancelled)) => {
                // Ensure marked as cancelled (may already be set by chat.abort)
                let mut registry = state.agent_run_registry.lock();
                if !registry
                    .get(&run_id)
                    .is_some_and(|r| r.status == AgentRunStatus::Cancelled)
                {
                    registry.mark_cancelled(&run_id);
                }
            }
            Ok(Err(e)) => {
                tracing::error!(run_id = %run_id, error = %e, "agent run failed");
                let mut registry = state.agent_run_registry.lock();
                registry.mark_failed(&run_id, e.to_string());
            }
            Err(panic_payload) => {
                let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
                    (*s).to_string()
                } else if let Some(s) = panic_payload.downcast_ref::<String>() {
                    s.clone()
                } else {
                    "unknown panic".to_string()
                };
                tracing::error!(run_id = %run_id, panic = %msg, "agent run panicked");
                let mut registry = state.agent_run_registry.lock();
                registry.mark_failed(&run_id, format!("panic: {msg}"));
            }
        }
    });

    // Supervisor task: if the inner task panics in a way that bypasses
    // catch_unwind (e.g. the match/error-handling code itself panics),
    // the JoinHandle will return Err(JoinError). We catch that here as
    // a last-resort safety net so the run never stays stuck in Running.
    tokio::spawn(async move {
        if let Err(join_err) = handle.await {
            let msg = if join_err.is_panic() {
                let panic_payload = join_err.into_panic();
                if let Some(s) = panic_payload.downcast_ref::<&str>() {
                    format!("task panic: {s}")
                } else if let Some(s) = panic_payload.downcast_ref::<String>() {
                    format!("task panic: {s}")
                } else {
                    "task panic: unknown payload".to_string()
                }
            } else {
                format!("task failed: {join_err}")
            };

            tracing::error!(
                run_id = %supervisor_run_id,
                error = %msg,
                "agent task terminated unexpectedly — marking run as failed"
            );

            let mut registry = supervisor_state.agent_run_registry.lock();
            // Only mark failed if the run is still in a non-terminal state
            // (the inner handler may have already marked it).
            if registry.get(&supervisor_run_id).is_some_and(|r| {
                r.status == AgentRunStatus::Running || r.status == AgentRunStatus::Queued
            }) {
                registry.mark_failed(&supervisor_run_id, msg);
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent::provider::{CompletionRequest, LlmProvider, StreamEvent};
    use crate::server::ws::{AgentRun, AgentRunStatus, WsServerConfig, WsServerState};
    use crate::sessions;
    use async_trait::async_trait;
    use std::sync::Arc;
    use tokio::sync::mpsc;
    use tokio_util::sync::CancellationToken;

    /// Server-boundary contract: `AgentError::Configuration` must yield a
    /// stable wire code that the HTTP and WS layers can surface to
    /// external clients. Free-form variants must yield `None` so the
    /// surfaces don't fabricate a misleading code.
    #[test]
    fn test_agent_error_wire_code_for_configuration_variants() {
        let unknown = AgentError::Configuration(AgentConfigurationError::unknown_route("fast"));
        assert_eq!(unknown.wire_code(), Some("unknown_route"));

        let missing = AgentError::Configuration(AgentConfigurationError::missing_model());
        assert_eq!(missing.wire_code(), Some("missing_model"));

        assert_eq!(
            AgentError::Provider("transient".to_string()).wire_code(),
            None
        );
        assert_eq!(AgentError::Cancelled.wire_code(), None);
        assert_eq!(AgentError::MaxTurns(5).wire_code(), None);
    }

    /// Mock provider whose `complete` method panics with a `&str` message.
    struct PanickingProvider {
        message: &'static str,
    }

    #[async_trait]
    impl LlmProvider for PanickingProvider {
        async fn complete(
            &self,
            _request: CompletionRequest,
            _cancel_token: CancellationToken,
        ) -> Result<mpsc::Receiver<StreamEvent>, AgentError> {
            panic!("{}", self.message);
        }
    }

    /// Mock provider whose `complete` method panics with a `String` message.
    struct PanickingStringProvider {
        message: String,
    }

    #[async_trait]
    impl LlmProvider for PanickingStringProvider {
        async fn complete(
            &self,
            _request: CompletionRequest,
            _cancel_token: CancellationToken,
        ) -> Result<mpsc::Receiver<StreamEvent>, AgentError> {
            panic!("{}", self.message);
        }
    }

    /// Helper to set up test state with a temp session store.
    fn make_test_state() -> (Arc<WsServerState>, tempfile::TempDir) {
        let tmp = tempfile::tempdir().unwrap();
        let store = Arc::new(sessions::SessionStore::with_base_path(
            tmp.path().join("sessions"),
        ));
        let state = WsServerState::new(WsServerConfig::default()).with_session_store(store);
        (Arc::new(state), tmp)
    }

    /// Helper to set up a session and register an agent run.
    fn setup_session_and_run(state: &WsServerState, session_key: &str, run_id: &str) {
        let session = state
            .session_store()
            .get_or_create_session(session_key, sessions::SessionMetadata::default())
            .unwrap();
        state
            .session_store()
            .append_message(sessions::ChatMessage::user(&session.id, "Hello"))
            .unwrap();
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_millis() as u64;
        let mut registry = state.agent_run_registry.lock();
        registry.register(AgentRun {
            run_id: run_id.to_string(),
            session_key: session_key.to_string(),
            delivery_recipient_id: None,
            typing_context: None,
            status: AgentRunStatus::Queued,
            message: "Hello".to_string(),
            response: String::new(),
            error: None,
            created_at: now,
            started_at: None,
            completed_at: None,
            cancel_token: CancellationToken::new(),
            waiters: Vec::new(),
        });
    }

    #[tokio::test]
    async fn test_spawn_run_panic_marks_run_failed() {
        let (state, _tmp) = make_test_state();
        let run_id = "run-panic-str";
        let session_key = "session-panic-str";
        setup_session_and_run(&state, session_key, run_id);

        let provider: Arc<dyn LlmProvider> = Arc::new(PanickingProvider {
            message: "provider exploded",
        });
        let config = AgentConfig {
            max_turns: 1,
            ..Default::default()
        };

        let supervisor_handle = spawn_run(
            run_id.to_string(),
            session_key.to_string(),
            config,
            state.clone(),
            provider,
            CancellationToken::new(),
        );

        // Wait for the supervisor task to complete
        supervisor_handle.await.unwrap();

        // The run should be marked as failed with a panic message
        let registry = state.agent_run_registry.lock();
        let run = registry.get(run_id).expect("run should exist in registry");
        assert_eq!(
            run.status,
            AgentRunStatus::Failed,
            "run should be Failed, got: {:?}",
            run.status
        );
        let error = run.error.as_deref().expect("run should have an error");
        assert!(
            error.contains("panic"),
            "error should mention panic, got: {error}"
        );
        assert!(
            error.contains("provider exploded"),
            "error should contain panic message, got: {error}"
        );
    }

    #[tokio::test]
    async fn test_spawn_run_panic_string_payload_marks_run_failed() {
        let (state, _tmp) = make_test_state();
        let run_id = "run-panic-string";
        let session_key = "session-panic-string";
        setup_session_and_run(&state, session_key, run_id);

        let provider: Arc<dyn LlmProvider> = Arc::new(PanickingStringProvider {
            message: "string panic payload".to_string(),
        });
        let config = AgentConfig {
            max_turns: 1,
            ..Default::default()
        };

        let supervisor_handle = spawn_run(
            run_id.to_string(),
            session_key.to_string(),
            config,
            state.clone(),
            provider,
            CancellationToken::new(),
        );

        supervisor_handle.await.unwrap();

        let registry = state.agent_run_registry.lock();
        let run = registry.get(run_id).expect("run should exist in registry");
        assert_eq!(run.status, AgentRunStatus::Failed);
        let error = run.error.as_deref().expect("run should have an error");
        assert!(
            error.contains("panic"),
            "error should mention panic, got: {error}"
        );
        assert!(
            error.contains("string panic payload"),
            "error should contain panic message, got: {error}"
        );
    }

    #[tokio::test]
    async fn test_spawn_run_panic_does_not_leave_run_in_running_state() {
        let (state, _tmp) = make_test_state();
        let run_id = "run-not-stuck";
        let session_key = "session-not-stuck";
        setup_session_and_run(&state, session_key, run_id);

        let provider: Arc<dyn LlmProvider> = Arc::new(PanickingProvider { message: "boom" });
        let config = AgentConfig::default();

        let supervisor_handle = spawn_run(
            run_id.to_string(),
            session_key.to_string(),
            config,
            state.clone(),
            provider,
            CancellationToken::new(),
        );

        supervisor_handle.await.unwrap();

        let registry = state.agent_run_registry.lock();
        let run = registry.get(run_id).expect("run should exist");
        // The key invariant: the run must NOT be in Running or Queued state
        assert_ne!(
            run.status,
            AgentRunStatus::Running,
            "run must not be stuck in Running after panic"
        );
        assert_ne!(
            run.status,
            AgentRunStatus::Queued,
            "run must not be stuck in Queued after panic"
        );
    }

    // ── resolve_agent_model tests ──────────────────────────────────

    #[test]
    fn resolve_agent_model_named_route_in_defaults() {
        let settings = serde_json::json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" }
            },
            "agents": {
                "defaults": { "route": "fast" }
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            None,
            &ModelResolutionOverrides::default(),
        )
        .unwrap();
        assert_eq!(config.model, "anthropic:claude-sonnet-4-6");
    }

    #[test]
    fn resolve_agent_model_named_route_on_agent_entry() {
        let settings = serde_json::json!({
            "routes": {
                "smart": { "model": "anthropic:claude-opus-4-20250514" }
            },
            "agents": {
                "list": [
                    { "id": "helper", "route": "smart" }
                ]
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            Some("helper"),
            &ModelResolutionOverrides::default(),
        )
        .unwrap();
        assert_eq!(config.model, "anthropic:claude-opus-4-20250514");
    }

    #[test]
    fn resolve_agent_model_defaults_model_without_routes() {
        let settings = serde_json::json!({
            "agents": {
                "defaults": { "model": "openai:gpt-5.5" }
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            None,
            &ModelResolutionOverrides::default(),
        )
        .unwrap();
        assert_eq!(config.model, "openai:gpt-5.5");
    }

    #[test]
    fn resolve_agent_model_agent_route_overrides_defaults_model() {
        let settings = serde_json::json!({
            "routes": {
                "smart": { "model": "anthropic:claude-opus-4-20250514" }
            },
            "agents": {
                "defaults": { "model": "openai:gpt-5.5" },
                "list": [
                    { "id": "thinker", "route": "smart" }
                ]
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            Some("thinker"),
            &ModelResolutionOverrides::default(),
        )
        .unwrap();
        assert_eq!(config.model, "anthropic:claude-opus-4-20250514");
    }

    #[test]
    fn resolve_agent_model_request_model_overrides_agent_route() {
        let settings = serde_json::json!({
            "routes": {
                "smart": { "model": "anthropic:claude-opus-4-20250514" }
            },
            "agents": {
                "list": [
                    { "id": "thinker", "route": "smart" }
                ]
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            Some("thinker"),
            &ModelResolutionOverrides {
                request_model: Some("openai:gpt-5.5"),
                ..Default::default()
            },
        )
        .unwrap();
        assert_eq!(config.model, "openai:gpt-5.5");
    }

    // ── session-level route/model tests ──────────────────────────────

    #[test]
    fn resolve_agent_model_session_route_overrides_defaults_model() {
        let settings = serde_json::json!({
            "routes": {
                "smart": { "model": "anthropic:claude-opus-4-20250514" }
            },
            "agents": {
                "defaults": { "model": "openai:gpt-5.5" }
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            None,
            &ModelResolutionOverrides {
                session_route: Some("smart"),
                ..Default::default()
            },
        )
        .unwrap();
        assert_eq!(config.model, "anthropic:claude-opus-4-20250514");
    }

    #[test]
    fn resolve_agent_model_session_model_overrides_defaults_model() {
        let settings = serde_json::json!({
            "agents": {
                "defaults": { "model": "openai:gpt-5.5" }
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            None,
            &ModelResolutionOverrides {
                session_model: Some("anthropic:claude-sonnet-4-6"),
                ..Default::default()
            },
        )
        .unwrap();
        assert_eq!(config.model, "anthropic:claude-sonnet-4-6");
    }

    #[test]
    fn resolve_agent_model_request_route_overrides_session_route() {
        let settings = serde_json::json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" },
                "smart": { "model": "anthropic:claude-opus-4-20250514" }
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            None,
            &ModelResolutionOverrides {
                request_route: Some("fast"),
                session_route: Some("smart"),
                ..Default::default()
            },
        )
        .unwrap();
        assert_eq!(config.model, "anthropic:claude-sonnet-4-6");
    }

    #[test]
    fn resolve_agent_model_session_route_overrides_agent_route() {
        let settings = serde_json::json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" },
                "smart": { "model": "anthropic:claude-opus-4-20250514" }
            },
            "agents": {
                "list": [
                    { "id": "helper", "route": "fast" }
                ]
            }
        });
        let mut config = AgentConfig::default();
        resolve_agent_model(
            &mut config,
            &settings,
            Some("helper"),
            &ModelResolutionOverrides {
                session_route: Some("smart"),
                ..Default::default()
            },
        )
        .unwrap();
        assert_eq!(config.model, "anthropic:claude-opus-4-20250514");
    }
}
