//! `App` — fluent assembler that collapses the 13-field `AgentContext`
//! into a one-call wiring surface.
//!
//! Use [`App::local`] for ephemeral dev (Ollama + sqlite + memory bus),
//! [`App::local_at`] for persistent dev,
//! [`App::builder`] for custom impls. All three produce an [`App`] that
//! mints fresh [`AgentContext`] per run via [`App::context`] and
//! shares a parent [`tokio_util::sync::CancellationToken`] across runs.
//!
//! ```ignore
//! use klieo::{App, SimpleAgent};
//! let app = App::local().model("qwen2.5:14b").build().await?;
//! let agent = SimpleAgent::new("hello", "Be brief.", app.tools_catalogue().to_vec());
//! let answer = app.run(&agent, "hi".into()).await?;
//! ```
//!
//! Power users who need a port shape `App` does not surface can still
//! hand-build `AgentContext` directly — `App` is shortcut, not
//! replacement.

use klieo_core::{
    Agent, AgentContext, AuditRedactor, BusHandles, EpisodicMemory, Error, LlmClient,
    LongTermMemory, MemoryHandles, RunId, ShortTermMemory, ToolDef, ToolInvoker,
};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;

#[cfg(feature = "tools")]
use klieo_core::Tool;

#[cfg(any(
    feature = "llm-openai",
    feature = "llm-anthropic",
    feature = "llm-gemini"
))]
use secrecy::SecretString;

/// Resolved klieo runtime. Holds every `Arc<dyn …>` port plus a parent
/// cancel token. Cheap to clone; expensive to construct (impl crates
/// open DB handles / pools during their `From` impls).
#[derive(Clone)]
pub struct App {
    llm: Arc<dyn LlmClient>,
    memory: MemoryHandles,
    bus: BusHandles,
    tools: Arc<dyn ToolInvoker>,
    catalogue: Vec<ToolDef>,
    parent_cancel: CancellationToken,
    audit_redactor: Option<Arc<dyn AuditRedactor>>,
}

impl std::fmt::Debug for App {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("App")
            .field("llm", &self.llm.name())
            .field("tools", &self.catalogue.len())
            .field("cancelled", &self.parent_cancel.is_cancelled())
            .finish_non_exhaustive()
    }
}

impl App {
    /// Start a local-defaults builder: Ollama at
    /// `http://localhost:11434` + sqlite (`:memory:`) with the dummy
    /// embedder + in-process [`klieo_bus_memory::MemoryBus`]. Override
    /// any default by calling its setter on the returned builder.
    ///
    /// State is **ephemeral** — the in-memory SQLite database resets
    /// every run. Use this for tests, CI, and one-shot scripts. For
    /// iterative dev where memory should survive restarts use
    /// [`App::local_at`].
    ///
    /// Available only when the `llm-ollama`, `memory-sqlite`, and
    /// `bus-memory` features are all active. Without them, use
    /// [`App::builder`] and wire ports by hand.
    #[cfg(all(
        feature = "llm-ollama",
        feature = "memory-sqlite",
        feature = "bus-memory",
        feature = "tools",
    ))]
    pub fn local() -> AppBuilder {
        AppBuilder::new()
            .ollama("http://localhost:11434", "qwen2.5:14b")
            .sqlite(":memory:")
            .memory_bus()
    }

    /// Start a local-defaults builder that persists memory to `db_path`.
    ///
    /// Same as [`App::local`] except the SQLite database is written to
    /// the given path instead of `:memory:`. Memory (threads, episodic
    /// events) survives process restarts — the file is created on first
    /// run and reused on every subsequent run.
    ///
    /// Recommended for interactive dev:
    ///
    /// ```ignore
    /// let app = App::local_at("klieo.db").model("qwen2.5:14b").build().await?;
    /// ```
    ///
    /// Use [`App::local`] (`:memory:`) for tests and CI.
    #[cfg(all(
        feature = "llm-ollama",
        feature = "memory-sqlite",
        feature = "bus-memory",
        feature = "tools",
    ))]
    pub fn local_at(db_path: impl Into<std::path::PathBuf>) -> AppBuilder {
        AppBuilder::new()
            .ollama("http://localhost:11434", "qwen2.5:14b")
            .sqlite(db_path)
            .memory_bus()
    }

    /// Start a local-defaults builder with persistent memory in the
    /// user's standard data directory.
    ///
    /// Path is resolved as `dirs::data_dir().join("klieo/klieo.db")`:
    ///
    /// | OS      | Resolved path                                 |
    /// |---------|-----------------------------------------------|
    /// | Linux   | `$XDG_DATA_HOME/klieo/klieo.db` (default `~/.local/share/klieo/klieo.db`) |
    /// | macOS   | `~/Library/Application Support/klieo/klieo.db` |
    /// | Windows | `%APPDATA%\klieo\klieo.db`                    |
    ///
    /// The parent directory is created on first call (`create_dir_all`
    /// is idempotent on repeat runs). The SQLite file itself is opened
    /// lazily on first connection.
    ///
    /// Returns an error if the platform does not expose a data
    /// directory (rare; on those platforms fall back to
    /// [`App::local_at`] with an explicit path) or if the directory
    /// cannot be created (filesystem error). Override the location by
    /// pointing `XDG_DATA_HOME` at a writable path before calling.
    ///
    /// Available with the `data-dir` feature.
    #[cfg(feature = "data-dir")]
    pub fn local_at_data_dir() -> Result<AppBuilder, Error> {
        let dir = dirs::data_dir().ok_or_else(|| {
            Error::Config(klieo_core::error::ConfigError::MissingKey(
                "platform data directory unavailable; call App::local_at with an explicit path"
                    .into(),
            ))
        })?;
        let path = dir.join("klieo").join("klieo.db");
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| Error::wrap("create klieo data dir", e))?;
        }
        Ok(AppBuilder::new()
            .ollama("http://localhost:11434", "qwen2.5:14b")
            .sqlite(path)
            .memory_bus())
    }

    /// Start an empty builder. Every port must be set explicitly.
    pub fn builder() -> AppBuilder {
        AppBuilder::new()
    }

    /// Mint a fresh [`AgentContext`] for one agent run. Each call
    /// produces a distinct [`RunId`] and a child cancel derived from
    /// `App`'s parent token — cancelling the parent cancels every
    /// in-flight run; per-run cancel does not affect siblings.
    pub fn context(&self, agent_name: impl Into<String>) -> AgentContext {
        let ctx = AgentContext::new(
            self.llm.clone(),
            self.memory.short_term.clone(),
            self.memory.long_term.clone(),
            self.memory.episodic.clone(),
            self.bus.pubsub.clone(),
            self.bus.kv.clone(),
            self.bus.request_reply.clone(),
            self.bus.jobs.clone(),
            self.tools.clone(),
            RunId::new(),
            self.parent_cancel.child_token(),
            agent_name,
        );
        match &self.audit_redactor {
            Some(redactor) => ctx.with_audit_redactor(redactor.clone()),
            None => ctx,
        }
    }

    /// Use this to call [`LlmClient::complete`] directly from custom
    /// HTTP / CLI wrappers that do not want to mint an
    /// [`AgentContext`] per call.
    pub fn llm(&self) -> Arc<dyn LlmClient> {
        self.llm.clone()
    }

    /// Every handle in the returned bundle shares the underlying memory
    /// backend — cloning one and mutating through another are visible to
    /// each other under that backend's consistency model.
    pub fn memory(&self) -> &MemoryHandles {
        &self.memory
    }

    /// Every handle in the returned bundle shares the underlying bus
    /// transport — pubsub messages published on one handle are visible
    /// to subscribers on any clone.
    pub fn bus(&self) -> &BusHandles {
        &self.bus
    }

    /// Pair with [`App::tools_catalogue`] when you need both the
    /// dispatcher and the `Vec<ToolDef>` advertised to the LLM.
    pub fn tools_invoker(&self) -> Arc<dyn ToolInvoker> {
        self.tools.clone()
    }

    /// Tool catalogue advertised to the LLM.
    pub fn tools_catalogue(&self) -> &[ToolDef] {
        &self.catalogue
    }

    /// Parent cancel token. Clone to share across runs (e.g.
    /// SIGINT-driven shutdown).
    pub fn cancel_token(&self) -> CancellationToken {
        self.parent_cancel.clone()
    }

    /// Convenience: mint a context with `agent.name()` as
    /// `agent_name`, then delegate to [`Agent::run`].
    ///
    /// Useful when the caller does not need to inspect or mutate the
    /// context between minting and run start. For composite agents
    /// that thread the same context through multiple `Agent::run`
    /// calls, mint via [`App::context`] and call [`Agent::run`]
    /// directly.
    pub async fn run<A: Agent>(&self, agent: &A, input: A::Input) -> Result<A::Output, A::Error> {
        let ctx = self.context(agent.name());
        agent.run(ctx, input).await
    }
}

/// Deferred-construction record for a provider chosen via shortcut.
///
/// Lets the builder hold provider-shaped config without instantiating
/// the client until [`AppBuilder::build`] runs (some providers do
/// async/fallible work in their ctors; recording the choice keeps the
/// chain sync until the final await point).
enum LlmShortcut {
    #[cfg(feature = "llm-ollama")]
    Ollama { base_url: String, model: String },
    #[cfg(feature = "llm-openai")]
    OpenAi {
        api_key: SecretString,
        model: String,
    },
    #[cfg(feature = "llm-anthropic")]
    Anthropic {
        api_key: SecretString,
        model: String,
    },
    #[cfg(feature = "llm-gemini")]
    Gemini {
        api_key: SecretString,
        model: String,
    },
}

/// Deferred-construction record for a bus chosen via shortcut.
enum BusShortcut {
    #[cfg(feature = "bus-memory")]
    Memory,
    #[cfg(feature = "bus-nats")]
    Nats(Box<klieo_bus_nats::NatsBusConfig>),
}

/// Deferred-construction record for one stdio MCP server.
#[cfg(feature = "mcp")]
struct McpStdioSpec {
    id: String,
    cmd: String,
    args: Vec<String>,
}

/// Deferred-construction record for one streamable-HTTP MCP server.
#[cfg(feature = "http")]
struct McpHttpSpec {
    id: String,
    url: String,
    opts: klieo_tools_mcp::HttpConnectOptions,
}

/// One pending MCP server source, resolved at [`AppBuilder::build`] time
/// into wrapped tools. The HTTP variant exists only under the `http`
/// feature.
#[cfg(feature = "mcp")]
enum McpSpec {
    Stdio(McpStdioSpec),
    #[cfg(feature = "http")]
    Http(McpHttpSpec),
}

/// Fluent assembler for [`App`]. All setters consume `self` so the
/// chain reads top-to-bottom in user code.
#[derive(Default)]
pub struct AppBuilder {
    llm: Option<Arc<dyn LlmClient>>,
    memory: Option<MemoryHandles>,
    bus: Option<BusHandles>,
    tools_invoker: Option<Arc<dyn ToolInvoker>>,
    #[cfg(feature = "tools")]
    pending_tools: Vec<Arc<dyn Tool>>,
    parent_cancel: Option<CancellationToken>,
    audit_redactor: Option<Arc<dyn AuditRedactor>>,

    llm_shortcut: Option<LlmShortcut>,
    bus_shortcut: Option<BusShortcut>,

    model_registry_path: Option<std::path::PathBuf>,

    short_term: Option<Arc<dyn ShortTermMemory>>,
    long_term: Option<Arc<dyn LongTermMemory>>,
    episodic: Option<Arc<dyn EpisodicMemory>>,

    #[cfg(feature = "memory-sqlite")]
    sqlite_path: Option<std::path::PathBuf>,

    #[cfg(feature = "mcp")]
    pending_mcp: Vec<McpSpec>,
}

impl AppBuilder {
    /// Construct an empty builder. Prefer [`App::builder`] or
    /// [`App::local`] at the call site.
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the LLM port directly with an already-`Arc`-wrapped client.
    pub fn llm(mut self, llm: Arc<dyn LlmClient>) -> Self {
        self.llm = Some(llm);
        self
    }

    /// Set the three memory ports from any value convertible into
    /// [`MemoryHandles`] (e.g. `MemorySqlite::new(...).await?`).
    pub fn memory(mut self, memory: impl Into<MemoryHandles>) -> Self {
        self.memory = Some(memory.into());
        self
    }

    /// Set the four bus ports from any value convertible into
    /// [`BusHandles`] (e.g. `MemoryBus::new()` or
    /// `NatsBus::connect(cfg).await?`).
    pub fn bus(mut self, bus: impl Into<BusHandles>) -> Self {
        self.bus = Some(bus.into());
        self
    }

    /// Set the tool dispatcher directly. Mutually exclusive with
    /// [`AppBuilder::tool`] — calling both errors at `build()`.
    pub fn tools_invoker(mut self, tools: Arc<dyn ToolInvoker>) -> Self {
        self.tools_invoker = Some(tools);
        self
    }

    /// Install the audit redactor applied to PII-flagged tools' recorded
    /// args/results during a run. Overrides the default that the `ops`
    /// feature wires (`klieo_ops::redactor::DefaultRedactor`). When no
    /// redactor is set and the `ops` feature is off, dispatch falls back
    /// to the fail-closed opaque digest for flagged tools.
    pub fn audit_redactor(mut self, redactor: Arc<dyn AuditRedactor>) -> Self {
        self.audit_redactor = Some(redactor);
        self
    }

    /// Register one tool. Repeated calls accumulate. The final
    /// `build()` consolidates them into a
    /// [`klieo_tools::ChainedInvoker`]. Mutually exclusive with
    /// [`AppBuilder::tools_invoker`].
    #[cfg(feature = "tools")]
    pub fn tool<T: Tool + 'static>(mut self, tool: T) -> Self {
        self.pending_tools.push(Arc::new(tool));
        self
    }

    /// Override the parent cancel token. Default: a fresh, uncancelled
    /// `CancellationToken::new()`.
    pub fn cancel_token(mut self, token: CancellationToken) -> Self {
        self.parent_cancel = Some(token);
        self
    }

    /// Set just the short-term-memory port. Composes with
    /// [`AppBuilder::long_term`] + [`AppBuilder::episodic`] to satisfy
    /// the memory triple. Useful for partial-shape impls like
    /// `MemoryNeo4j` (short + episodic) paired with `MemoryQdrant`
    /// (long).
    pub fn short_term(mut self, short_term: Arc<dyn ShortTermMemory>) -> Self {
        self.short_term = Some(short_term);
        self
    }

    /// Set just the long-term-memory port. See
    /// [`AppBuilder::short_term`] for composition semantics.
    pub fn long_term(mut self, long_term: Arc<dyn LongTermMemory>) -> Self {
        self.long_term = Some(long_term);
        self
    }

    /// Set just the episodic-memory port. See
    /// [`AppBuilder::short_term`] for composition semantics.
    pub fn episodic(mut self, episodic: Arc<dyn EpisodicMemory>) -> Self {
        self.episodic = Some(episodic);
        self
    }

    /// Configure Ollama as the LLM port. `base_url` accepts a
    /// scheme-prefixed URL (e.g. `"http://localhost:11434"`). Last
    /// provider shortcut wins; explicit [`AppBuilder::llm`] overrides
    /// every shortcut.
    ///
    /// Available with the `llm-ollama` feature.
    #[cfg(feature = "llm-ollama")]
    pub fn ollama(mut self, base_url: impl Into<String>, model: impl Into<String>) -> Self {
        self.llm_shortcut = Some(LlmShortcut::Ollama {
            base_url: base_url.into(),
            model: model.into(),
        });
        self
    }

    /// Override only the Ollama model on a builder already configured
    /// via [`AppBuilder::ollama`] (or [`App::local`]). Resets a
    /// non-Ollama provider shortcut to Ollama with the default base
    /// URL.
    #[cfg(feature = "llm-ollama")]
    pub fn model(mut self, model: impl Into<String>) -> Self {
        let base_url = match self.llm_shortcut.take() {
            Some(LlmShortcut::Ollama { base_url, .. }) => base_url,
            _ => "http://localhost:11434".to_string(),
        };
        self.llm_shortcut = Some(LlmShortcut::Ollama {
            base_url,
            model: model.into(),
        });
        self
    }

    /// Configure OpenAI Chat Completions as the LLM port.
    ///
    /// Available with the `llm-openai` feature.
    #[cfg(feature = "llm-openai")]
    pub fn openai(mut self, api_key: impl Into<SecretString>, model: impl Into<String>) -> Self {
        self.llm_shortcut = Some(LlmShortcut::OpenAi {
            api_key: api_key.into(),
            model: model.into(),
        });
        self
    }

    /// Configure Anthropic Messages as the LLM port.
    ///
    /// Available with the `llm-anthropic` feature.
    #[cfg(feature = "llm-anthropic")]
    pub fn anthropic(mut self, api_key: impl Into<SecretString>, model: impl Into<String>) -> Self {
        self.llm_shortcut = Some(LlmShortcut::Anthropic {
            api_key: api_key.into(),
            model: model.into(),
        });
        self
    }

    /// Configure Google Gemini as the LLM port.
    ///
    /// Available with the `llm-gemini` feature.
    #[cfg(feature = "llm-gemini")]
    pub fn gemini(mut self, api_key: impl Into<SecretString>, model: impl Into<String>) -> Self {
        self.llm_shortcut = Some(LlmShortcut::Gemini {
            api_key: api_key.into(),
            model: model.into(),
        });
        self
    }

    /// Opt in to model-registry enforcement: at `build()`, the configured model is
    /// checked against the registry at `path` — a sunset model fails the build, a
    /// deprecated or unpinned model logs a `governance` warning. No path → no check.
    pub fn model_registry(mut self, path: impl Into<std::path::PathBuf>) -> Self {
        self.model_registry_path = Some(path.into());
        self
    }

    /// Use SQLite-backed memory with the dummy embedder. `path` may
    /// be `":memory:"` for an ephemeral DB or a file path for
    /// cross-run persistence.
    ///
    /// Available with the `memory-sqlite` feature.
    #[cfg(feature = "memory-sqlite")]
    pub fn sqlite(mut self, path: impl Into<std::path::PathBuf>) -> Self {
        self.sqlite_path = Some(path.into());
        self
    }

    /// Use the in-process [`klieo_bus_memory::MemoryBus`]. Last bus
    /// shortcut wins; explicit [`AppBuilder::bus`] overrides every
    /// shortcut.
    ///
    /// Available with the `bus-memory` feature.
    #[cfg(feature = "bus-memory")]
    pub fn memory_bus(mut self) -> Self {
        self.bus_shortcut = Some(BusShortcut::Memory);
        self
    }

    /// Use a NATS JetStream bus. Resolves the connection during
    /// [`AppBuilder::build`].
    ///
    /// Available with the `bus-nats` feature.
    #[cfg(feature = "bus-nats")]
    pub fn nats(mut self, config: klieo_bus_nats::NatsBusConfig) -> Self {
        self.bus_shortcut = Some(BusShortcut::Nats(Box::new(config)));
        self
    }

    /// Register a stdio MCP server as a tool source.
    ///
    /// `id` is the server identifier used to namespace tool names
    /// (`mcp:<id>.<tool_name>`). `cmd` is the binary to spawn; `args`
    /// are its arguments. `build()` calls [`klieo_tools_mcp::McpToolset::connect_stdio`]
    /// and adds the resulting tools to the catalogue alongside any
    /// `.tool()` tools.
    ///
    /// Mutually exclusive with `.tools_invoker()` — combining them
    /// returns `Error::AppBuildError` at `build()` time.
    ///
    /// Available with the `mcp` feature.
    #[cfg(feature = "mcp")]
    pub fn mcp_stdio(
        mut self,
        id: impl Into<String>,
        cmd: impl Into<String>,
        args: Vec<String>,
    ) -> Self {
        self.pending_mcp.push(McpSpec::Stdio(McpStdioSpec {
            id: id.into(),
            cmd: cmd.into(),
            args,
        }));
        self
    }

    /// Register a remote MCP server reached over Streamable HTTP as a tool
    /// source.
    ///
    /// `id` namespaces the tool names (`mcp:<id>.<tool_name>`); `url` is
    /// the server endpoint (`https`, or loopback `http` for local dev);
    /// `opts` carry the auth env-var name and optional CA bundle. `build()`
    /// calls [`klieo_tools_mcp::McpToolset::connect_http`] and adds the
    /// resulting tools to the catalogue alongside any `.tool()` tools.
    ///
    /// Mutually exclusive with `.tools_invoker()` — combining them returns
    /// [`Error::AppBuildError`] at `build()` time.
    ///
    /// Available with the `http` feature.
    #[cfg(feature = "http")]
    pub fn mcp_http(
        mut self,
        id: impl Into<String>,
        url: impl Into<String>,
        opts: klieo_tools_mcp::HttpConnectOptions,
    ) -> Self {
        self.pending_mcp.push(McpSpec::Http(McpHttpSpec {
            id: id.into(),
            url: url.into(),
            opts,
        }));
        self
    }

    /// Assemble the [`App`]. Resolves feature-gated shortcuts into
    /// their typed equivalents, validates every required port, and
    /// fails with [`Error::AppBuildError`] if any are missing.
    pub async fn build(mut self) -> Result<App, Error> {
        self.enforce_model_registry()?;

        self.resolve_shortcuts().await?;

        let llm = self.llm.ok_or(Error::AppBuildError { missing: "llm" })?;
        let memory = self
            .memory
            .ok_or(Error::AppBuildError { missing: "memory" })?;
        let bus = self.bus.ok_or(Error::AppBuildError { missing: "bus" })?;

        let (tools, catalogue) = resolve_tools(
            self.tools_invoker,
            #[cfg(feature = "tools")]
            self.pending_tools,
        )?;

        Ok(App {
            llm,
            memory,
            bus,
            tools,
            catalogue,
            parent_cancel: self.parent_cancel.unwrap_or_default(),
            audit_redactor: resolve_audit_redactor(self.audit_redactor),
        })
    }

    /// Enforce the opt-in model registry against the configured model: load the
    /// registry, fail the build on a sunset model, warn on deprecated/unpinned.
    /// No-op when no registry path was configured.
    fn enforce_model_registry(&self) -> Result<(), Error> {
        let Some(path) = self.model_registry_path.clone() else {
            return Ok(());
        };
        let registry = match klieo_model_registry::ModelRegistry::load_from_path(&path) {
            Ok(r) => r,
            Err(e) => {
                tracing::error!(target: "governance", error = %e, "model registry load failed");
                return Err(Error::AppBuildError {
                    missing: "model-registry",
                });
            }
        };
        match shortcut_model(self.llm_shortcut.as_ref()) {
            Some(pin) => match registry.status(&pin) {
                Some(klieo_model_registry::DeprecationStatus::Sunset(since)) => {
                    return Err(Error::ModelSunset {
                        model: pin.to_string(),
                        since: since.to_string(),
                    });
                }
                Some(klieo_model_registry::DeprecationStatus::DeprecatedSince(since)) => {
                    tracing::warn!(target: "governance", model = %pin, %since, "configured model is deprecated");
                }
                Some(klieo_model_registry::DeprecationStatus::Active) => {}
                None => {
                    tracing::warn!(target: "governance", model = %pin, "configured model is unpinned/unknown in the registry")
                }
                Some(_) => {
                    tracing::warn!(target: "governance", model = %pin, "unrecognized model status; treating as non-fatal")
                }
            },
            None => {
                tracing::warn!(target: "governance", "model not observable (raw LlmClient injected); registry not applied")
            }
        }
        Ok(())
    }

    async fn resolve_shortcuts(&mut self) -> Result<(), Error> {
        self.resolve_llm_shortcut();
        self.resolve_sqlite().await?;
        self.resolve_memory_partials()?;
        self.resolve_bus_shortcut().await?;
        #[cfg(feature = "mcp")]
        self.resolve_mcp().await?;
        Ok(())
    }

    fn resolve_llm_shortcut(&mut self) {
        if self.llm.is_some() {
            return;
        }
        match self.llm_shortcut.take() {
            None => {}
            #[cfg(feature = "llm-ollama")]
            Some(LlmShortcut::Ollama { base_url, model }) => {
                self.llm = Some(Arc::new(klieo_llm_ollama::OllamaClient::new(
                    base_url, model,
                )));
            }
            #[cfg(feature = "llm-openai")]
            Some(LlmShortcut::OpenAi { api_key, model }) => {
                self.llm = Some(Arc::new(klieo_llm_openai::OpenAiClient::new(
                    api_key, model,
                )));
            }
            #[cfg(feature = "llm-anthropic")]
            Some(LlmShortcut::Anthropic { api_key, model }) => {
                self.llm = Some(Arc::new(klieo_llm_anthropic::AnthropicClient::new(
                    api_key, model,
                )));
            }
            #[cfg(feature = "llm-gemini")]
            Some(LlmShortcut::Gemini { api_key, model }) => {
                self.llm = Some(Arc::new(klieo_llm_gemini::GeminiClient::new(
                    api_key, model,
                )));
            }
        }
    }

    #[cfg(feature = "memory-sqlite")]
    async fn resolve_sqlite(&mut self) -> Result<(), Error> {
        if self.memory.is_some() {
            return Ok(());
        }
        if let Some(path) = self.sqlite_path.take() {
            let mem = klieo_memory_sqlite::MemorySqlite::new(
                path,
                Arc::new(klieo_memory_sqlite::DummyEmbedder),
            )
            .await?;
            self.memory = Some(mem.into());
        }
        Ok(())
    }

    #[cfg(not(feature = "memory-sqlite"))]
    #[allow(clippy::unused_async)]
    async fn resolve_sqlite(&mut self) -> Result<(), Error> {
        Ok(())
    }

    /// Compose `short_term` + `long_term` + `episodic` partials into
    /// [`MemoryHandles`] when [`AppBuilder::memory`] and
    /// [`AppBuilder::sqlite`] were not set. Reports the first missing
    /// trait if some — but not all — partials are present.
    fn resolve_memory_partials(&mut self) -> Result<(), Error> {
        if self.memory.is_some() {
            return Ok(());
        }
        let any_partial =
            self.short_term.is_some() || self.long_term.is_some() || self.episodic.is_some();
        if !any_partial {
            return Ok(());
        }
        let short_term = self.short_term.take().ok_or(Error::AppBuildError {
            missing: "memory.short_term",
        })?;
        let long_term = self.long_term.take().ok_or(Error::AppBuildError {
            missing: "memory.long_term",
        })?;
        let episodic = self.episodic.take().ok_or(Error::AppBuildError {
            missing: "memory.episodic",
        })?;
        self.memory = Some(MemoryHandles::new(short_term, long_term, episodic));
        Ok(())
    }

    async fn resolve_bus_shortcut(&mut self) -> Result<(), Error> {
        if self.bus.is_some() {
            return Ok(());
        }
        match self.bus_shortcut.take() {
            None => {}
            #[cfg(feature = "bus-memory")]
            Some(BusShortcut::Memory) => {
                self.bus = Some(klieo_bus_memory::MemoryBus::new().into());
            }
            #[cfg(feature = "bus-nats")]
            Some(BusShortcut::Nats(config)) => {
                let bus = klieo_bus_nats::NatsBus::connect(*config).await?;
                self.bus = Some(bus.into());
            }
        }
        Ok(())
    }

    #[cfg(feature = "mcp")]
    async fn resolve_mcp(&mut self) -> Result<(), Error> {
        if self.tools_invoker.is_some() && !self.pending_mcp.is_empty() {
            tracing::error!(
                op = "resolve_mcp",
                "conflicting builder options: mcp_stdio()/mcp_http() combined with tools_invoker()"
            );
            return Err(Error::AppBuildError {
                missing: "tools (cannot combine .mcp_stdio()/.mcp_http() with .tools_invoker())",
            });
        }
        for spec in self.pending_mcp.drain(..).collect::<Vec<_>>() {
            let tools = match spec {
                McpSpec::Stdio(stdio) => connect_mcp_stdio(stdio).await?,
                #[cfg(feature = "http")]
                McpSpec::Http(http) => connect_mcp_http(http).await?,
            };
            for tool in tools {
                self.pending_tools.push(tool);
            }
        }
        Ok(())
    }
}

/// Connect one stdio MCP server, mapping connect failure to [`Error::Tool`].
#[cfg(feature = "mcp")]
async fn connect_mcp_stdio(spec: McpStdioSpec) -> Result<Vec<Arc<dyn Tool>>, Error> {
    klieo_tools_mcp::McpToolset::connect_stdio(
        klieo_tools_mcp::McpServerId::new(&spec.id),
        &spec.cmd,
        klieo_tools_mcp::StdioConnectOptions {
            args: spec.args,
            ..Default::default()
        },
    )
    .await
    .map_err(|e| {
        tracing::error!(op = "mcp_stdio", server_id = %spec.id, cmd = %spec.cmd, err = %e, "MCP connect failed");
        Error::Tool(e)
    })
}

/// Connect one streamable-HTTP MCP server, mapping connect failure to
/// [`Error::Tool`]. The URL is logged (non-secret); the auth secret never
/// enters [`HttpConnectOptions`] and so cannot leak here.
#[cfg(feature = "http")]
async fn connect_mcp_http(spec: McpHttpSpec) -> Result<Vec<Arc<dyn Tool>>, Error> {
    klieo_tools_mcp::McpToolset::connect_http(
        klieo_tools_mcp::McpServerId::new(&spec.id),
        &spec.url,
        spec.opts,
    )
    .await
    .map_err(|e| {
        tracing::error!(op = "mcp_http", server_id = %spec.id, url = %spec.url, err = %e, "MCP connect failed");
        Error::Tool(e)
    })
}

/// The model a shortcut configures, as a `ModelPin`, if any. The raw
/// `.llm(Arc<dyn LlmClient>)` path returns `None` — its model string is not
/// observable here.
fn shortcut_model(shortcut: Option<&LlmShortcut>) -> Option<klieo_model_registry::ModelPin> {
    let (provider, model): (&str, &str) = match shortcut? {
        #[cfg(feature = "llm-ollama")]
        LlmShortcut::Ollama { model, .. } => ("ollama", model),
        #[cfg(feature = "llm-openai")]
        LlmShortcut::OpenAi { model, .. } => ("openai", model),
        #[cfg(feature = "llm-anthropic")]
        LlmShortcut::Anthropic { model, .. } => ("anthropic", model),
        #[cfg(feature = "llm-gemini")]
        LlmShortcut::Gemini { model, .. } => ("gemini", model),
    };
    Some(klieo_model_registry::ModelPin::new(provider, model))
}

/// An explicit [`AppBuilder::audit_redactor`] always wins. Otherwise,
/// when the `ops` feature is active, default to
/// [`klieo_ops::redactor::DefaultRedactor`] so PII-flagged tools get
/// shape-preserving redaction out of the box. Without `ops` and without
/// an explicit redactor, returns `None` and dispatch falls back to the
/// fail-closed opaque digest for flagged tools.
fn resolve_audit_redactor(
    explicit: Option<Arc<dyn AuditRedactor>>,
) -> Option<Arc<dyn AuditRedactor>> {
    if explicit.is_some() {
        return explicit;
    }
    #[cfg(feature = "ops")]
    {
        return Some(Arc::new(klieo_ops::redactor::DefaultRedactor::new()));
    }
    #[cfg(not(feature = "ops"))]
    {
        None
    }
}

fn resolve_tools(
    invoker: Option<Arc<dyn ToolInvoker>>,
    #[cfg(feature = "tools")] pending: Vec<Arc<dyn Tool>>,
) -> Result<(Arc<dyn ToolInvoker>, Vec<ToolDef>), Error> {
    #[cfg(feature = "tools")]
    {
        let has_pending = !pending.is_empty();
        match (invoker, has_pending) {
            (Some(_), true) => Err(Error::AppBuildError {
                missing: "tools (cannot combine .tool() with .tools_invoker())",
            }),
            (Some(inv), false) => {
                let cat = inv.catalogue();
                Ok((inv, cat))
            }
            (None, _) => {
                let mut chained = klieo_tools::ChainedInvoker::new();
                for t in pending {
                    chained
                        .add_tool(t)
                        .map_err(|e| Error::wrap("tool registration failed", e))?;
                }
                let cat = chained.catalogue();
                Ok((Arc::new(chained), cat))
            }
        }
    }
    #[cfg(not(feature = "tools"))]
    {
        let inv = invoker.ok_or(Error::AppBuildError { missing: "tools" })?;
        let cat = inv.catalogue();
        Ok((inv, cat))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use klieo_core::test_utils::{FakeLlmClient, FakeLlmStep, FakeToolInvoker};

    fn fake_llm() -> Arc<dyn LlmClient> {
        Arc::new(FakeLlmClient::new("fake"))
    }

    #[cfg(feature = "llm-ollama")]
    #[test]
    fn shortcut_model_maps_ollama() {
        let s = LlmShortcut::Ollama {
            base_url: "x".into(),
            model: "qwen2.5:14b".into(),
        };
        assert_eq!(
            shortcut_model(Some(&s)),
            Some(klieo_model_registry::ModelPin::new("ollama", "qwen2.5:14b"))
        );
    }

    #[test]
    fn shortcut_model_none_for_raw_llm() {
        assert_eq!(shortcut_model(None), None);
    }

    fn fake_tools() -> Arc<dyn ToolInvoker> {
        Arc::new(FakeToolInvoker::new())
    }

    #[tokio::test]
    async fn build_missing_llm_errors() {
        let err = App::builder()
            .memory(
                klieo_memory_sqlite::MemorySqlite::new(
                    ":memory:",
                    Arc::new(klieo_memory_sqlite::DummyEmbedder),
                )
                .await
                .unwrap(),
            )
            .bus(klieo_bus_memory::MemoryBus::new())
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap_err();
        assert!(matches!(err, Error::AppBuildError { missing: "llm" }));
    }

    #[tokio::test]
    async fn build_missing_memory_errors() {
        let err = App::builder()
            .llm(fake_llm())
            .bus(klieo_bus_memory::MemoryBus::new())
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap_err();
        assert!(matches!(err, Error::AppBuildError { missing: "memory" }));
    }

    #[tokio::test]
    async fn build_missing_bus_errors() {
        let err = App::builder()
            .llm(fake_llm())
            .memory(
                klieo_memory_sqlite::MemorySqlite::new(
                    ":memory:",
                    Arc::new(klieo_memory_sqlite::DummyEmbedder),
                )
                .await
                .unwrap(),
            )
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap_err();
        assert!(matches!(err, Error::AppBuildError { missing: "bus" }));
    }

    #[tokio::test]
    async fn build_local_with_only_model_succeeds() {
        let app = App::local().model("dummy-model").build().await.unwrap();
        assert!(app.tools_catalogue().is_empty());
        let ctx = app.context("test-agent");
        assert_eq!(ctx.agent_name, "test-agent");
    }

    #[tokio::test]
    async fn context_mints_fresh_run_id_per_call() {
        let app = App::local().model("dummy").build().await.unwrap();
        let a = app.context("agent");
        let b = app.context("agent");
        assert_ne!(a.run_id, b.run_id);
    }

    #[tokio::test]
    async fn cancel_token_propagates_to_minted_context() {
        let app = App::local().model("dummy").build().await.unwrap();
        let ctx = app.context("agent");
        assert!(!ctx.cancel.is_cancelled());
        app.cancel_token().cancel();
        assert!(ctx.cancel.is_cancelled());
    }

    struct ConstRedactor;

    impl AuditRedactor for ConstRedactor {
        fn redact(&self, _value: &serde_json::Value) -> serde_json::Value {
            serde_json::json!("[const]")
        }
    }

    #[cfg(feature = "ops")]
    #[tokio::test]
    async fn ops_feature_wires_default_audit_redactor() {
        let app = App::local().model("dummy").build().await.unwrap();
        assert!(
            app.audit_redactor.is_some(),
            "the ops feature must default a redactor so PII-flagged tools \
             are masked rather than falling back to the opaque digest"
        );
    }

    #[tokio::test]
    async fn explicit_audit_redactor_overrides_default() {
        let app = App::local()
            .model("dummy")
            .audit_redactor(Arc::new(ConstRedactor))
            .build()
            .await
            .unwrap();
        let redactor = app
            .audit_redactor
            .as_ref()
            .expect("explicit redactor must be retained");
        assert_eq!(
            redactor.redact(&serde_json::json!("anything")),
            serde_json::json!("[const]")
        );
    }

    #[cfg(feature = "data-dir")]
    #[test]
    fn local_at_data_dir_resolves_and_creates_parent_dir() {
        // Smoke: the public fn must resolve a data dir on this platform
        // and idempotently create the `klieo` parent directory. We do
        // NOT call .build() — that would write an empty klieo.db to
        // the real user data dir.
        let _builder =
            App::local_at_data_dir().expect("dirs::data_dir resolvable on this platform");
        let parent = dirs::data_dir()
            .expect("data_dir already validated above")
            .join("klieo");
        assert!(
            parent.is_dir(),
            "expected klieo data dir to be created: {parent:?}",
        );
        // Idempotency: a second call must not error.
        let _builder_again = App::local_at_data_dir().expect("data_dir fn must be idempotent");
    }

    #[tokio::test]
    async fn port_accessors_expose_configured_handles() {
        let llm = fake_llm();
        let app = App::builder()
            .llm(Arc::clone(&llm))
            .memory(
                klieo_memory_sqlite::MemorySqlite::new(
                    ":memory:",
                    Arc::new(klieo_memory_sqlite::DummyEmbedder),
                )
                .await
                .unwrap(),
            )
            .bus(klieo_bus_memory::MemoryBus::new())
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap();

        assert!(Arc::ptr_eq(&app.llm(), &llm));

        let ctx = app.context("ports-test");
        assert!(Arc::ptr_eq(&app.memory().short_term, &ctx.short_term));
        assert!(Arc::ptr_eq(&app.memory().long_term, &ctx.long_term));
        assert!(Arc::ptr_eq(&app.memory().episodic, &ctx.episodic));
        assert!(Arc::ptr_eq(&app.bus().pubsub, &ctx.pubsub));
        assert!(Arc::ptr_eq(&app.bus().kv, &ctx.kv));
        assert!(Arc::ptr_eq(&app.bus().request_reply, &ctx.request_reply));
        assert!(Arc::ptr_eq(&app.bus().jobs, &ctx.jobs));

        assert!(Arc::ptr_eq(&app.tools_invoker(), &ctx.tools));
    }

    #[tokio::test]
    async fn cannot_combine_tool_with_tools_invoker() {
        // Use klieo-tools' own Tool impl from a sibling crate. Manually
        // wire a pending tool via the builder field by going through
        // .tool() with an Arc-wrapped Tool object built via the
        // chained-invoker path.
        let mut builder = App::local().model("dummy");
        // Push a pending tool via the public API: a simple anonymous
        // Tool impl is overkill here — use the trivial fact that
        // .tools_invoker() + a non-empty pending list collides.
        builder.pending_tools.push(Arc::new(EchoTool));
        let err = builder
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap_err();
        assert!(matches!(
            err,
            Error::AppBuildError {
                missing: "tools (cannot combine .tool() with .tools_invoker())"
            }
        ));
    }

    struct EchoTool;

    #[async_trait::async_trait]
    impl Tool for EchoTool {
        fn name(&self) -> &str {
            "echo"
        }
        fn description(&self) -> &str {
            "echo"
        }
        fn json_schema(&self) -> &serde_json::Value {
            static SCHEMA: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
            SCHEMA.get_or_init(|| serde_json::json!({"type": "object"}))
        }
        async fn invoke(
            &self,
            _args: serde_json::Value,
            _ctx: klieo_core::ToolCtx,
        ) -> Result<serde_json::Value, klieo_core::ToolError> {
            Ok(serde_json::Value::Null)
        }
    }

    #[tokio::test]
    async fn partial_memory_composes_into_handles() {
        let mem = klieo_memory_sqlite::MemorySqlite::new(
            ":memory:",
            Arc::new(klieo_memory_sqlite::DummyEmbedder),
        )
        .await
        .unwrap();
        let app = App::builder()
            .llm(fake_llm())
            .short_term(mem.short_term.clone())
            .long_term(mem.long_term.clone())
            .episodic(mem.episodic.clone())
            .bus(klieo_bus_memory::MemoryBus::new())
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap();
        let ctx = app.context("partial");
        assert!(Arc::ptr_eq(&ctx.short_term, &mem.short_term));
        assert!(Arc::ptr_eq(&ctx.long_term, &mem.long_term));
        assert!(Arc::ptr_eq(&ctx.episodic, &mem.episodic));
    }

    #[tokio::test]
    async fn partial_memory_missing_long_term_errors() {
        let mem = klieo_memory_sqlite::MemorySqlite::new(
            ":memory:",
            Arc::new(klieo_memory_sqlite::DummyEmbedder),
        )
        .await
        .unwrap();
        let err = App::builder()
            .llm(fake_llm())
            .short_term(mem.short_term.clone())
            .episodic(mem.episodic.clone())
            .bus(klieo_bus_memory::MemoryBus::new())
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap_err();
        assert!(matches!(
            err,
            Error::AppBuildError {
                missing: "memory.long_term"
            }
        ));
    }

    #[tokio::test]
    async fn explicit_memory_overrides_partials() {
        let primary = klieo_memory_sqlite::MemorySqlite::new(
            ":memory:",
            Arc::new(klieo_memory_sqlite::DummyEmbedder),
        )
        .await
        .unwrap();
        let secondary = klieo_memory_sqlite::MemorySqlite::new(
            ":memory:",
            Arc::new(klieo_memory_sqlite::DummyEmbedder),
        )
        .await
        .unwrap();
        let primary_st = primary.short_term.clone();
        let app = App::builder()
            .llm(fake_llm())
            .memory(primary)
            .short_term(secondary.short_term)
            .bus(klieo_bus_memory::MemoryBus::new())
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap();
        let ctx = app.context("override");
        assert!(Arc::ptr_eq(&ctx.short_term, &primary_st));
    }

    #[cfg(feature = "llm-openai")]
    #[tokio::test]
    async fn openai_shortcut_sets_llm() {
        let app = App::local()
            .openai("test-key".to_string(), "gpt-4o-mini")
            .build()
            .await
            .unwrap();
        let ctx = app.context("a");
        assert!(
            ctx.llm.name().starts_with("openai"),
            "got {}",
            ctx.llm.name()
        );
    }

    #[cfg(feature = "llm-anthropic")]
    #[tokio::test]
    async fn anthropic_shortcut_sets_llm() {
        let app = App::local()
            .anthropic("test-key".to_string(), "claude-3-5-haiku-latest")
            .build()
            .await
            .unwrap();
        let ctx = app.context("a");
        assert!(
            ctx.llm.name().starts_with("anthropic"),
            "got {}",
            ctx.llm.name()
        );
    }

    #[cfg(feature = "llm-gemini")]
    #[tokio::test]
    async fn gemini_shortcut_sets_llm() {
        let app = App::local()
            .gemini("test-key".to_string(), "gemini-2.0-flash")
            .build()
            .await
            .unwrap();
        let ctx = app.context("a");
        assert!(ctx.llm.name().contains("gemini"), "got {}", ctx.llm.name());
    }

    #[tokio::test]
    async fn last_llm_shortcut_wins() {
        let app = App::local()
            .ollama("http://localhost:11434", "qwen")
            .ollama("http://other:11434", "llama")
            .build()
            .await
            .unwrap();
        // Ollama client doesn't expose model via LlmClient; just verify
        // that build succeeded under the second config (no panic, no
        // error) which proves the second .ollama() call replaced the
        // first.
        let ctx = app.context("a");
        assert!(
            ctx.llm.name().starts_with("ollama"),
            "got {}",
            ctx.llm.name()
        );
    }

    #[cfg(feature = "mcp")]
    #[tokio::test]
    async fn mcp_stdio_registers_entry_verified_via_conflict_guard() {
        // mcp_stdio() pushes a McpStdioSpec into pending_mcp. The conflict
        // guard in resolve_mcp_stdio() fires immediately (before any connect
        // attempt) when tools_invoker is also set, confirming the entry was
        // recorded and the guard reads it.
        struct DummyInvoker;
        #[async_trait::async_trait]
        impl klieo_core::ToolInvoker for DummyInvoker {
            fn catalogue(&self) -> Vec<klieo_core::ToolDef> {
                vec![]
            }
            async fn invoke(
                &self,
                _name: &str,
                _args: serde_json::Value,
                _ctx: klieo_core::ToolCtx,
            ) -> Result<serde_json::Value, klieo_core::error::ToolError> {
                Ok(serde_json::Value::Null)
            }
        }

        let err = App::local()
            .mcp_stdio("test-srv", "/bin/echo", vec![])
            .tools_invoker(Arc::new(DummyInvoker))
            .build()
            .await
            .unwrap_err();
        assert!(
            matches!(err, klieo_core::Error::AppBuildError { .. }),
            "expected AppBuildError for conflict, got {err:?}"
        );
    }

    #[cfg(feature = "mcp")]
    #[tokio::test]
    async fn mcp_stdio_errors_on_missing_binary() {
        let err = App::local()
            .mcp_stdio("test-server", "/nonexistent/binary/path", vec![])
            .build()
            .await
            .unwrap_err();
        // connect_stdio → child-process spawn fails → ToolError → Error::Tool
        assert!(
            matches!(err, klieo_core::Error::Tool(_)),
            "expected Error::Tool, got {err:?}"
        );
    }

    #[cfg(feature = "mcp")]
    #[tokio::test]
    async fn mcp_stdio_combined_with_tools_invoker_errors_at_build() {
        struct DummyInvoker;
        #[async_trait::async_trait]
        impl klieo_core::ToolInvoker for DummyInvoker {
            fn catalogue(&self) -> Vec<klieo_core::ToolDef> {
                vec![]
            }
            async fn invoke(
                &self,
                _name: &str,
                _args: serde_json::Value,
                _ctx: klieo_core::ToolCtx,
            ) -> Result<serde_json::Value, klieo_core::error::ToolError> {
                Ok(serde_json::Value::Null)
            }
        }

        let err = App::local()
            .tools_invoker(Arc::new(DummyInvoker))
            .mcp_stdio("srv", "/bin/echo", vec![])
            .build()
            .await
            .unwrap_err();
        assert!(
            matches!(err, klieo_core::Error::AppBuildError { .. }),
            "expected AppBuildError, got {err:?}"
        );
    }

    #[cfg(feature = "http")]
    #[tokio::test]
    async fn mcp_http_combined_with_tools_invoker_errors_at_build() {
        struct DummyInvoker;
        #[async_trait::async_trait]
        impl klieo_core::ToolInvoker for DummyInvoker {
            fn catalogue(&self) -> Vec<klieo_core::ToolDef> {
                vec![]
            }
            async fn invoke(
                &self,
                _name: &str,
                _args: serde_json::Value,
                _ctx: klieo_core::ToolCtx,
            ) -> Result<serde_json::Value, klieo_core::error::ToolError> {
                Ok(serde_json::Value::Null)
            }
        }

        let err = App::local()
            .mcp_http(
                "remote",
                "https://mcp.example.com/mcp",
                klieo_tools_mcp::HttpConnectOptions::default(),
            )
            .tools_invoker(Arc::new(DummyInvoker))
            .build()
            .await
            .unwrap_err();
        assert!(
            matches!(err, klieo_core::Error::AppBuildError { .. }),
            "expected AppBuildError for conflict, got {err:?}"
        );
    }

    #[cfg(feature = "http")]
    #[tokio::test]
    async fn mcp_http_rejects_plain_http_remote_url_at_build() {
        let err = App::local()
            .mcp_http(
                "remote",
                "http://mcp.example.com/mcp",
                klieo_tools_mcp::HttpConnectOptions::default(),
            )
            .build()
            .await
            .unwrap_err();
        // validate_url rejects non-loopback plain http → ToolError::InvalidArgs
        // → Error::Tool before any network I/O.
        assert!(
            matches!(err, klieo_core::Error::Tool(_)),
            "expected Error::Tool for plain-http rejection, got {err:?}"
        );
    }

    #[cfg(feature = "bus-nats")]
    #[tokio::test]
    async fn nats_shortcut_errors_on_unreachable_server() {
        let cfg = klieo_bus_nats::NatsBusConfig {
            url: "nats://127.0.0.1:14223".to_string(),
            ..klieo_bus_nats::NatsBusConfig::default()
        };
        let err = App::builder()
            .llm(fake_llm())
            .memory(
                klieo_memory_sqlite::MemorySqlite::new(
                    ":memory:",
                    Arc::new(klieo_memory_sqlite::DummyEmbedder),
                )
                .await
                .unwrap(),
            )
            .nats(cfg)
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap_err();
        assert!(matches!(err, Error::Bus(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn run_delegates_to_agent_with_fresh_context() {
        let app = App::builder()
            .llm(Arc::new(
                FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]),
            ))
            .memory(
                klieo_memory_sqlite::MemorySqlite::new(
                    ":memory:",
                    Arc::new(klieo_memory_sqlite::DummyEmbedder),
                )
                .await
                .unwrap(),
            )
            .bus(klieo_bus_memory::MemoryBus::new())
            .tools_invoker(fake_tools())
            .build()
            .await
            .unwrap();
        let agent =
            klieo_core::SimpleAgent::new("greeter", "be brief", app.tools_catalogue().to_vec());
        let out = app.run(&agent, "hi".into()).await.unwrap();
        assert_eq!(out, "done");
    }

    #[cfg(all(
        feature = "llm-ollama",
        feature = "memory-sqlite",
        feature = "bus-memory",
        feature = "tools",
    ))]
    #[tokio::test]
    async fn build_local_at_file_path_succeeds() {
        let db_path = std::env::temp_dir().join("klieo_test_local_at.db");
        let _ = std::fs::remove_file(&db_path);
        let app = App::local_at(&db_path)
            .model("dummy-model")
            .build()
            .await
            .unwrap();
        assert!(app.tools_catalogue().is_empty());
        let ctx = app.context("test-agent");
        assert_eq!(ctx.agent_name, "test-agent");
        assert!(
            db_path.exists(),
            "sqlite file should have been created at {db_path:?}"
        );
        let _ = std::fs::remove_file(&db_path);
    }
}
