//! Tool config resolution pipeline.
//!
//! Four-step resolution:
//! 1. `effective_tool_config = config.tool_config.unwrap_or_else(|| parent.effective_tool_config.clone())`
//! 2. `merged = merge_mcp_tools(effective_tool_config, shared.mcp_servers.snapshot())`
//! 3. `filtered = config.capability_mode.filter(merged)`
//! 4. `toolset = build_finalized_toolset(filtered, &session.cwd, &session.session_env, ...)`
use crate::capability::{CapabilityMode, kind_allowed};
use crate::config::SessionContextFactory;
use crate::error::{WorkspaceError, WorkspaceResult};
use kigi_tools::registry::types::{
    FinalizedToolset, ToolConfig, ToolRegistryBuilder, ToolServerConfig,
};
use kigi_tools::types::tool::ToolKind;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
/// Create-shaped entry of the resolution pipeline: run
/// [`resolve_session_toolset_rebuild`] around a FRESH factory-built
/// session-lifetime terminal backend, and return that backend so the caller
/// can store it on the session it is creating.
pub(crate) fn resolve_session_toolset(
    effective_tool_config: ToolServerConfig,
    capability_mode: CapabilityMode,
    mcp_snapshot: &[ToolConfig],
    cwd: PathBuf,
    session_env: Arc<HashMap<String, String>>,
    session_id: &str,
    factory: &dyn SessionContextFactory,
    local_registry: Option<kigi_tool_runtime::LocalRegistry>,
    lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
    viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
    notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
) -> WorkspaceResult<(
    ToolServerConfig,
    Arc<FinalizedToolset>,
    crate::config::SessionTerminalBackend,
)> {
    let terminal_backend = factory.build_terminal_backend();
    let (effective, toolset) = resolve_session_toolset_rebuild(
        effective_tool_config,
        capability_mode,
        mcp_snapshot,
        cwd,
        session_env,
        session_id,
        factory,
        local_registry,
        lsp,
        viewer_ctx,
        notification_handle,
        terminal_backend.backend().clone(),
    )?;
    Ok((effective, toolset, terminal_backend))
}
/// Rebuild-shaped entry: run steps 2-5 of the resolution pipeline around an
/// EXISTING session-owned terminal backend. The parameter is non-optional on
/// purpose: every toolset-swap call site must state which backend it rebuilds
/// around, so background tasks and shell state can never be orphaned by a
/// resolve that silently built a fresh backend.
///
/// Returns the *unmodified* `effective_tool_config` (step-1 baseline) so
/// the caller can store it on the session. The FinalizedToolset reflects
/// MCP merging and capability filtering on top of that baseline.
///
/// **MCP-origin `kind: None` tools are dropped under every non-`All`
/// mode.** Baseline `kind: None` tools are always kept — but before
/// filtering, kind-less baseline entries whose id the binary's registry
/// knows get their [`ToolKind`] backfilled (see [`backfill_tool_kinds`]),
/// so the capability filter applies to pinned toolsets whose wire entries
/// cannot carry a kind.
pub(crate) fn resolve_session_toolset_rebuild(
    effective_tool_config: ToolServerConfig,
    capability_mode: CapabilityMode,
    mcp_snapshot: &[ToolConfig],
    cwd: PathBuf,
    session_env: Arc<HashMap<String, String>>,
    session_id: &str,
    factory: &dyn SessionContextFactory,
    local_registry: Option<kigi_tool_runtime::LocalRegistry>,
    lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
    viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
    notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
    terminal_backend: Arc<dyn kigi_tools::computer::types::TerminalBackend>,
) -> WorkspaceResult<(ToolServerConfig, Arc<FinalizedToolset>)> {
    let mut builder = factory.registry_builder();
    if let Some(lr) = local_registry {
        builder = builder.with_local_registry(lr);
    }
    let baseline = backfill_tool_kinds(&effective_tool_config, &builder.known_tool_kinds());
    let finalize_config = merge_and_filter(&baseline, mcp_snapshot, capability_mode, session_id);
    let mut ctx = factory.build_session_context(session_id, cwd, session_env, terminal_backend);
    if let Some(lsp_handle) = lsp {
        ctx.lsp = Some(lsp_handle);
    }
    if let Some(handle) = notification_handle {
        ctx.notification_handle = handle;
    }
    let toolset = builder
        .finalize_with_trunc_config(
            finalize_config,
            ctx,
            kigi_tools::types::context::TruncationConfig::default(),
            viewer_ctx,
        )
        .map_err(|errs| {
            let summary: Vec<String> = errs.iter().map(|e| e.summary()).collect();
            WorkspaceError::Finalize(summary.join("; "))
        })?;
    Ok((effective_tool_config, Arc::new(toolset)))
}
/// Backfill `kind: None` baseline entries from the binary's own registry
/// (fully-qualified id -> declared [`ToolKind`]).
///
/// Ids unknown to the registry stay `None` and keep the always-kept
/// baseline behavior (ad-hoc `ToolConfig::simple` tools). Entries that
/// already carry a kind are left untouched.
fn backfill_tool_kinds(
    config: &ToolServerConfig,
    kinds: &HashMap<String, ToolKind>,
) -> ToolServerConfig {
    ToolServerConfig {
        tools: config
            .tools
            .iter()
            .map(|tool| {
                let mut tool = tool.clone();
                if tool.kind.is_none() {
                    tool.kind = kinds.get(&tool.id).copied();
                }
                tool
            })
            .collect(),
        behavior_preset: config.behavior_preset.clone(),
    }
}
/// Steps 2-3 of the resolution pipeline, without the `finalize` step:
///
/// - **Step 2** -- MCP merge: append MCP-origin tools, skipping ID/name collisions with baseline.
/// - **Step 3** -- Capability filter: drop tools whose `kind` is not allowed by the mode.
///   External (MCP) `kind: None` tools are only kept under `CapabilityMode::All`.
///
/// Priority on ID/name collision: baseline wins over MCP.
pub(crate) fn merge_and_filter(
    baseline: &ToolServerConfig,
    mcp_snapshot: &[ToolConfig],
    mode: CapabilityMode,
    session_id: &str,
) -> ToolServerConfig {
    if mcp_snapshot.is_empty() {
        return mode.filter(baseline);
    }
    let baseline_ids: std::collections::HashSet<&str> =
        baseline.tools.iter().map(|t| t.id.as_str()).collect();
    let mut taken_names: std::collections::HashSet<String> = baseline
        .tools
        .iter()
        .map(|t| {
            let unqualified = t.id.rsplit_once(':').map_or(t.id.as_str(), |(_, n)| n);
            t.resolve_client_name(unqualified)
        })
        .collect();
    let mut tagged: Vec<(ToolConfig, bool)> =
        baseline.tools.iter().cloned().map(|t| (t, false)).collect();
    for mcp_tool in mcp_snapshot {
        if baseline_ids.contains(mcp_tool.id.as_str()) {
            tracing::warn!(
                mcp_id = % mcp_tool.id, session = % session_id,
                "skipping MCP tool: id collides with baseline"
            );
            continue;
        }
        let client_name = mcp_tool.resolve_client_name(&mcp_tool.id);
        if !taken_names.insert(client_name.clone()) {
            tracing::warn!(
                mcp_id = % mcp_tool.id, client_name = % client_name, session = %
                session_id,
                "skipping MCP tool: resolved client name collides with another tool"
            );
            continue;
        }
        tagged.push((mcp_tool.clone(), true));
    }
    let kept: Vec<ToolConfig> = tagged
        .into_iter()
        .filter(|(tool, is_external)| match tool.kind {
            Some(k) => kind_allowed(mode, k),
            None if !*is_external => true,
            None => matches!(mode, CapabilityMode::All),
        })
        .map(|(t, _)| t)
        .collect();
    ToolServerConfig {
        tools: kept,
        behavior_preset: baseline.behavior_preset.clone(),
    }
}
/// Alias for backward compatibility.
pub type NoopSessionContextFactory = WorkspaceSessionContextFactory;
/// Sanitize a `session_id` into a single safe filesystem path segment: chars
/// outside `[A-Za-z0-9_-]` become `_`, empty becomes `anon`. When any
/// replacement happened, an 8-hex digest of the ORIGINAL id is appended so the
/// mapping stays injective — plain substitution would collide distinct ids
/// (`sess/1` and `sess_1`) into one directory, cross-contaminating
/// persistence and rehydration. Already-safe ids (the common UUID case)
/// map to themselves.
fn sanitize_session_id(session_id: &str) -> String {
    let mut safe = String::with_capacity(session_id.len());
    let mut modified = false;
    for c in session_id.chars() {
        if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
            safe.push(c);
        } else {
            safe.push('_');
            modified = true;
        }
    }
    if safe.is_empty() {
        safe.push_str("anon");
        modified = true;
    }
    if modified {
        let digest = kigi_file_utils::sha256_hex(session_id.as_bytes());
        safe.push('-');
        safe.push_str(&digest[..8]);
    }
    safe
}
/// `<root>/sessions/<sanitized_id>`, creating the directory when possible.
fn ensure_session_dir(root: &std::path::Path, session_id: &str) -> (PathBuf, std::io::Result<()>) {
    let dir = root.join("sessions").join(sanitize_session_id(session_id));
    let created = std::fs::create_dir_all(&dir);
    (dir, created)
}
/// Serializes tests (across modules) that mutate the process-global
/// `KIGI_WORKSPACE_TOOL_STATE_ENABLED`. Aliased to the crate-wide
/// [`crate::ENV_TEST_LOCK`] so ALL env-mutating tests share ONE lock (the
/// hazard is the global `environ` array, not the variable's value).
#[cfg(test)]
pub(crate) use crate::ENV_TEST_LOCK as TOOL_STATE_ENV_LOCK;
/// [`SessionContextFactory`] for workspace sessions.
///
/// [`SessionContext::session_folder`] is `/tmp/sessions/<sanitized_id>/`
/// (terminal logs and other tool artifacts — not the project `cwd`).
///
/// Terminal backends are persistent-shell [`LocalTerminalBackend`]s, built
/// once per session by [`build_terminal_backend`] and passed into every
/// [`build_session_context`] call.
///
/// [`build_terminal_backend`]: crate::config::SessionContextFactory::build_terminal_backend
/// [`build_session_context`]: crate::config::SessionContextFactory::build_session_context
/// [`LocalTerminalBackend`]: kigi_tools::computer::local::LocalTerminalBackend
#[derive(Default)]
pub struct WorkspaceSessionContextFactory;
impl WorkspaceSessionContextFactory {
    pub fn new() -> Self {
        Self
    }
    /// `/tmp/sessions/<sanitized_id>/` for terminal logs and other tool artifacts.
    fn resolve_session_folder(session_id: &str) -> PathBuf {
        let (dir, created) = ensure_session_dir(std::path::Path::new("/tmp"), session_id);
        if let Err(e) = created {
            tracing::warn!(
                session = % session_id, dir = % dir.display(), error = % e,
                "session_folder: failed to create dir; tools may create it on write"
            );
        }
        dir
    }
}
impl SessionContextFactory for WorkspaceSessionContextFactory {
    fn build_session_context(
        &self,
        session_id: &str,
        cwd: PathBuf,
        session_env: Arc<HashMap<String, String>>,
        backend: Arc<dyn kigi_tools::computer::types::TerminalBackend>,
    ) -> kigi_tools::registry::types::SessionContext {
        let fs = Arc::new(kigi_tools::computer::local::LocalFs)
            as Arc<dyn kigi_tools::computer::types::AsyncFileSystem>;
        let notification_handle = kigi_tools::notification::ToolNotificationHandle::noop();
        kigi_tools::registry::types::SessionContext {
            backend,
            fs,
            cwd,
            session_folder: Self::resolve_session_folder(session_id),
            session_env,
            notification_handle,
            owner_session_id: None,
            parent_scheduler_handle: None,
            skills: vec![],
            state_path: PathBuf::new(),
            memory_backend: None,
            web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::default(),
            web_fetch_config: build_web_fetch_config(),
            lsp: None,
            app_builder_deployer_config:
                kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig::default(),
            api_key_provider: None,
            attribution_callback: None,
            system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
        }
    }
    fn build_terminal_backend(&self) -> crate::config::SessionTerminalBackend {
        crate::config::SessionTerminalBackend::local(
            kigi_tools::computer::local::LocalTerminalBackend::with_persistent_shell(),
        )
    }
    fn registry_builder(&self) -> ToolRegistryBuilder {
        ToolRegistryBuilder::new()
    }
    fn known_tool_ids(&self) -> Arc<std::collections::HashSet<String>> {
        static IDS: std::sync::LazyLock<Arc<std::collections::HashSet<String>>> =
            std::sync::LazyLock::new(|| Arc::new(ToolRegistryBuilder::new().known_tool_ids()));
        IDS.clone()
    }
}
/// Build web fetch config. Enabled with default params unless
/// `KIGI_DISABLE_WEB_FETCH=1` is set.
fn build_web_fetch_config() -> kigi_tools::implementations::kigi::web_fetch::WebFetchConfig {
    use kigi_tools::implementations::kigi::web_fetch::{WebFetchConfig, WebFetchParams};
    if std::env::var("KIGI_DISABLE_WEB_FETCH").is_ok_and(|v| v == "1" || v == "true") {
        return WebFetchConfig::Disabled;
    }
    let mut params = WebFetchParams::default();
    if let Ok(proxy) = std::env::var("KIGI_WEB_FETCH_PROXY") {
        params.proxy_endpoint = Some(proxy);
    }
    WebFetchConfig::Enabled { params }
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
    use crate::config::SessionContextFactory;
    use kigi_tools::computer::local::{LocalFs, LocalTerminalBackend};
    use kigi_tools::notification::ToolNotificationHandle;
    use kigi_tools::registry::types::{
        SessionContext, ToolConfig, ToolRegistryBuilder, ToolServerConfig,
    };
    use kigi_tools::types::tool::ToolKind;
    use std::collections::HashMap;
    use std::path::PathBuf;
    use std::sync::Arc;
    use tempfile::TempDir;
    /// Test factory: builds a `SessionContext` rooted at a per-test temp dir.
    pub struct TestSessionContextFactory {
        pub temp: TempDir,
    }
    impl Default for TestSessionContextFactory {
        fn default() -> Self {
            Self::new()
        }
    }
    impl TestSessionContextFactory {
        pub fn new() -> Self {
            Self {
                temp: TempDir::new().expect("create temp dir"),
            }
        }
    }
    impl SessionContextFactory for TestSessionContextFactory {
        fn build_session_context(
            &self,
            session_id: &str,
            cwd: PathBuf,
            session_env: Arc<HashMap<String, String>>,
            backend: Arc<dyn kigi_tools::computer::types::TerminalBackend>,
        ) -> SessionContext {
            let session_root = self
                .temp
                .path()
                .join(super::sanitize_session_id(session_id));
            std::fs::create_dir_all(&session_root).expect("create session root");
            SessionContext {
                backend,
                fs: Arc::new(LocalFs),
                cwd,
                session_folder: session_root.clone(),
                session_env,
                notification_handle: ToolNotificationHandle::noop(),
                owner_session_id: None,
                parent_scheduler_handle: None,
                skills: vec![],
                state_path: session_root.join("tool_state.json"),
                memory_backend: None,
                web_search_config: Default::default(),
                web_fetch_config: Default::default(),
                lsp: None,
                app_builder_deployer_config: Default::default(),
                api_key_provider: None,
                attribution_callback: None,
                system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
            }
        }
        fn build_terminal_backend(&self) -> crate::config::SessionTerminalBackend {
            crate::config::SessionTerminalBackend::local(LocalTerminalBackend::new())
        }
        fn registry_builder(&self) -> ToolRegistryBuilder {
            ToolRegistryBuilder::new()
        }
    }
    /// `ToolConfig` builder helper.
    pub fn tc(id: &str, kind: Option<ToolKind>) -> ToolConfig {
        ToolConfig {
            id: id.to_owned(),
            params: None,
            name_override: None,
            params_name_overrides: None,
            description_override: None,
            behavior_version: None,
            kind,
        }
    }
    /// Minimal valid `ToolServerConfig` for finalize-time tests.
    pub fn baseline_config() -> ToolServerConfig {
        ToolServerConfig {
            tools: vec![
                tc("Kigi:read_file", Some(ToolKind::Read)),
                tc("Kigi:search_replace", Some(ToolKind::Edit)),
                tc("Kigi:grep", Some(ToolKind::Search)),
                tc("Kigi:list_dir", Some(ToolKind::ListDir)),
            ],
            behavior_preset: None,
        }
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::SessionContextFactory;
    use kigi_tools::types::tool::ToolKind;
    use std::collections::HashMap;
    use std::path::PathBuf;
    use std::sync::Arc;
    fn factory_for_test() -> Arc<dyn SessionContextFactory> {
        Arc::new(test_support::TestSessionContextFactory::new())
    }
    fn empty_env() -> Arc<HashMap<String, String>> {
        Arc::new(HashMap::new())
    }
    #[tokio::test]
    async fn resolve_session_toolset_empty_mcp_snapshot_is_noop_for_baseline() {
        let factory = factory_for_test();
        let cwd = PathBuf::from("/tmp");
        let baseline = test_support::baseline_config();
        let baseline_ids: Vec<String> = baseline.tools.iter().map(|t| t.id.clone()).collect();
        let (eff, ts, _backend) = resolve_session_toolset(
            baseline,
            CapabilityMode::ReadWrite,
            &[],
            cwd,
            empty_env(),
            "main",
            factory.as_ref(),
            None,
            None,
            None,
            None,
        )
        .expect("resolve");
        assert_eq!(
            eff.tools
                .iter()
                .map(|t| t.id.clone())
                .collect::<Vec<String>>(),
            baseline_ids
        );
        assert!(!ts.tool_definitions().is_empty());
    }
    #[tokio::test]
    async fn resolve_session_toolset_mcp_merge_dedup_by_id_baseline_wins() {
        let factory = factory_for_test();
        let baseline = ToolServerConfig {
            tools: vec![test_support::tc("Kigi:read_file", Some(ToolKind::Read))],
            behavior_preset: None,
        };
        let mut mcp_dup = test_support::tc("Kigi:read_file", Some(ToolKind::Read));
        mcp_dup.name_override = Some("mcp_read".into());
        let snapshot = vec![mcp_dup];
        let (_eff, ts, _backend) = resolve_session_toolset(
            baseline,
            CapabilityMode::ReadWrite,
            &snapshot,
            PathBuf::from("/tmp"),
            empty_env(),
            "main",
            factory.as_ref(),
            None,
            None,
            None,
            None,
        )
        .expect("resolve");
        let defs = ts.tool_definitions();
        let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
        assert!(
            names.contains(&"read_file"),
            "baseline read_file must survive: {names:?}"
        );
        assert!(
            !names.contains(&"mcp_read"),
            "MCP duplicate must be skipped: {names:?}"
        );
    }
    #[test]
    fn backfill_tool_kinds_fills_known_kindless_ids_only() {
        let kinds = HashMap::from([
            ("Kigi:search_replace".to_owned(), ToolKind::Edit),
            ("Kigi:read_file".to_owned(), ToolKind::Read),
        ]);
        let config = ToolServerConfig {
            tools: vec![
                test_support::tc("Kigi:search_replace", None),
                test_support::tc("adhoc.opaque", None),
                test_support::tc("Kigi:read_file", Some(ToolKind::Search)),
            ],
            behavior_preset: Some("current".to_owned()),
        };
        let backfilled = backfill_tool_kinds(&config, &kinds);
        let kind_of = |id: &str| {
            backfilled
                .tools
                .iter()
                .find(|t| t.id == id)
                .expect("tool present")
                .kind
        };
        assert_eq!(kind_of("Kigi:search_replace"), Some(ToolKind::Edit));
        assert_eq!(
            kind_of("adhoc.opaque"),
            None,
            "ids unknown to the registry stay kind-less"
        );
        assert_eq!(
            kind_of("Kigi:read_file"),
            Some(ToolKind::Search),
            "an explicit kind wins over the registry's"
        );
        assert_eq!(backfilled.behavior_preset.as_deref(), Some("current"));
    }
    /// Regression: pinned server-bind toolsets arrive kind-less (the gRPC
    /// `ToolConfigEntry` has no kind field), which used to make every
    /// baseline entry bypass the capability filter — a `read_only`
    /// sub-agent kept edit + execute tools. The registry backfill must
    /// restore the filter.
    #[tokio::test]
    async fn resolve_session_toolset_readonly_filters_kindless_pinned_tools() {
        let factory = factory_for_test();
        let baseline = ToolServerConfig {
            tools: vec![
                test_support::tc("Kigi:read_file", None),
                test_support::tc("Kigi:grep", None),
                test_support::tc("Kigi:list_dir", None),
                test_support::tc("Kigi:search_replace", None),
                test_support::tc("Kigi:run_terminal_cmd", None),
            ],
            behavior_preset: None,
        };
        let (eff, ts, _backend) = resolve_session_toolset(
            baseline,
            CapabilityMode::ReadOnly,
            &[],
            PathBuf::from("/tmp"),
            empty_env(),
            "main",
            factory.as_ref(),
            None,
            None,
            None,
            None,
        )
        .expect("resolve");
        assert!(eff.tools.iter().all(|t| t.kind.is_none()));
        let names: Vec<String> = ts
            .tool_definitions()
            .into_iter()
            .map(|d| d.function.name)
            .collect();
        for kept in ["read_file", "grep", "list_dir"] {
            assert!(
                names.iter().any(|n| n == kept),
                "{kept} must survive ReadOnly: {names:?}"
            );
        }
        for dropped in ["search_replace", "run_terminal_cmd"] {
            assert!(
                !names.iter().any(|n| n == dropped),
                "{dropped} must be dropped under ReadOnly: {names:?}"
            );
        }
    }
    #[test]
    fn resolve_session_toolset_mcp_edit_dropped_under_readonly() {
        let baseline = ToolServerConfig {
            tools: vec![test_support::tc("Kigi:read_file", Some(ToolKind::Read))],
            behavior_preset: None,
        };
        let mcp_edit = test_support::tc("mcp.editor", Some(ToolKind::Edit));
        let filtered = merge_and_filter(&baseline, &[mcp_edit], CapabilityMode::ReadOnly, "test");
        assert!(!filtered.tools.iter().any(|t| t.id == "mcp.editor"));
    }
    #[tokio::test]
    async fn resolve_session_toolset_mcp_kind_none_dropped_under_readonly() {
        let factory = factory_for_test();
        let baseline = ToolServerConfig {
            tools: vec![
                test_support::tc("Kigi:read_file", Some(ToolKind::Read)),
                test_support::tc("baseline.opaque", None),
            ],
            behavior_preset: None,
        };
        let mcp = vec![test_support::tc("mcp.opaque", None)];
        let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::ReadOnly, "test_session");
        let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
        assert!(
            kept_ids.contains(&"baseline.opaque"),
            "baseline kind: None must survive ReadOnly: {kept_ids:?}"
        );
        assert!(
            !kept_ids.contains(&"mcp.opaque"),
            "MCP kind: None MUST be dropped under ReadOnly: {kept_ids:?}"
        );
        assert!(
            kept_ids.contains(&"Kigi:read_file"),
            "baseline Read kind must survive ReadOnly: {kept_ids:?}"
        );
        let _ = factory;
    }
    #[tokio::test]
    async fn resolve_session_toolset_mcp_kind_none_kept_under_all() {
        let baseline = ToolServerConfig {
            tools: vec![],
            behavior_preset: None,
        };
        let mcp = vec![test_support::tc("mcp.opaque", None)];
        let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::All, "test_session");
        let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
        assert!(
            kept_ids.contains(&"mcp.opaque"),
            "All mode keeps MCP kind: None: {kept_ids:?}"
        );
    }
    #[tokio::test]
    async fn resolve_session_toolset_mcp_name_override_collision_skipped() {
        let baseline = ToolServerConfig {
            tools: vec![],
            behavior_preset: None,
        };
        let mut mcp_a = test_support::tc("mcp.tool_a", Some(ToolKind::Read));
        mcp_a.name_override = Some("shared_name".into());
        let mut mcp_b = test_support::tc("mcp.tool_b", Some(ToolKind::Read));
        mcp_b.name_override = Some("shared_name".into());
        let mcp = vec![mcp_a, mcp_b];
        let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::ReadOnly, "test_session");
        let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
        assert!(ids.contains(&"mcp.tool_a"), "first wins: {ids:?}");
        assert!(
            !ids.contains(&"mcp.tool_b"),
            "duplicate name dropped: {ids:?}"
        );
    }
    #[test]
    fn factory_session_folder_is_tmp_sessions_not_project_cwd() {
        let cwd = PathBuf::from("/workspace");
        let folder = WorkspaceSessionContextFactory::resolve_session_folder("sess-1");
        let expected = PathBuf::from("/tmp/sessions/sess-1");
        assert_eq!(folder, expected);
        assert!(folder.is_dir());
        assert!(!folder.starts_with(&cwd));
        assert_eq!(
            folder.join("terminal").join("call-42.log"),
            PathBuf::from("/tmp/sessions/sess-1/terminal/call-42.log")
        );
    }
    #[test]
    fn factory_session_folder_sanitizes_and_isolates_ids() {
        let sessions = PathBuf::from("/tmp/sessions");
        let hostile = WorkspaceSessionContextFactory::resolve_session_folder("../../etc");
        assert!(hostile.starts_with(&sessions));
        assert_eq!(hostile.parent(), Some(sessions.as_path()));
        assert_ne!(hostile, sessions.join("etc"));
        let a = WorkspaceSessionContextFactory::resolve_session_folder("sess/1");
        let b = WorkspaceSessionContextFactory::resolve_session_folder("sess_1");
        assert_ne!(a, b);
        let empty = WorkspaceSessionContextFactory::resolve_session_folder("");
        assert_eq!(empty.parent(), Some(sessions.as_path()));
        assert!(
            empty
                .file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|s| s.starts_with("anon-"))
        );
        let uuid = "019f3c0a-e2c2-79a3-908d-e8a8f088fe52";
        assert_eq!(
            WorkspaceSessionContextFactory::resolve_session_folder(uuid),
            sessions.join(uuid)
        );
    }
    #[test]
    fn ensure_session_dir_shared_by_state_and_session_folder() {
        let home = tempfile::TempDir::new().unwrap();
        let (under_home, ok) = ensure_session_dir(home.path(), "shared-id");
        assert!(ok.is_ok());
        assert_eq!(under_home, home.path().join("sessions").join("shared-id"));
        assert!(under_home.is_dir());
        let (under_tmp, ok) = ensure_session_dir(std::path::Path::new("/tmp"), "shared-id");
        assert!(ok.is_ok());
        assert_eq!(under_tmp, PathBuf::from("/tmp/sessions/shared-id"));
        assert!(under_tmp.is_dir());
    }
    /// A hostile `session_id` (`../../etc`) is sanitized to a single safe
    /// segment and cannot traverse outside `<home>/sessions/`.
    #[test]
    fn ensure_session_dir_sanitizes_malicious_session_id_no_traversal() {
        let home = tempfile::TempDir::new().unwrap();
        let sessions = home.path().join("sessions");
        let (session_dir, created) = ensure_session_dir(home.path(), "../../etc");
        assert!(created.is_ok());
        assert!(
            session_dir.starts_with(&sessions),
            "session dir escaped sessions/: {}",
            session_dir.display()
        );
        assert_eq!(
            session_dir.parent(),
            Some(sessions.as_path()),
            "session dir must be a direct child of sessions/, got {}",
            session_dir.display()
        );
        let seg = session_dir
            .file_name()
            .and_then(|n| n.to_str())
            .expect("segment is valid utf-8");
        assert!(
            seg.chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
            "sanitized segment must contain no path separators or dots: {seg:?}"
        );
        assert!(
            session_dir.is_dir(),
            "the confined session dir must be the only thing created"
        );
    }
    /// Sanitization is injective: distinct ids that substitute to the same
    /// base string still map to distinct directories (hash disambiguator),
    /// while already-safe ids map to themselves.
    #[test]
    fn sanitize_session_id_is_injective() {
        assert_eq!(super::sanitize_session_id("sess-1_a"), "sess-1_a");
        assert_ne!(
            super::sanitize_session_id("sess/1"),
            super::sanitize_session_id("sess_1"),
            "substitution collisions must be disambiguated"
        );
        assert_ne!(
            super::sanitize_session_id("sess/1"),
            super::sanitize_session_id("sess.1"),
        );
        assert!(super::sanitize_session_id("").starts_with("anon-"));
    }
    /// A toolset rebuilt for the SAME session rehydrates persisted state from
    /// disk; a DIFFERENT session_id cold-starts with no cross-contamination.
    #[tokio::test]
    async fn tool_state_rehydrates_same_session_and_cold_starts_other() {
        use kigi_tools::types::resources::{State, WebCitationCounter};
        let factory = test_support::TestSessionContextFactory::new();
        let cwd = PathBuf::from("/tmp");
        let (_eff, ts_a, _backend_a) = resolve_session_toolset(
            test_support::baseline_config(),
            CapabilityMode::ReadWrite,
            &[],
            cwd.clone(),
            empty_env(),
            "sess-A",
            &factory,
            None,
            None,
            None,
            None,
        )
        .expect("build toolset A");
        {
            let mut res = ts_a.resources.lock().await;
            let counter = res.get_or_default::<State<WebCitationCounter>>();
            counter.counter = 123;
        }
        ts_a.save_and_flush_persistence().await;
        drop(ts_a);
        let (_eff, ts_b, _backend_b) = resolve_session_toolset(
            test_support::baseline_config(),
            CapabilityMode::ReadWrite,
            &[],
            cwd.clone(),
            empty_env(),
            "sess-A",
            &factory,
            None,
            None,
            None,
            None,
        )
        .expect("build toolset B");
        {
            let res = ts_b.resources.lock().await;
            let counter = res
                .get::<State<WebCitationCounter>>()
                .expect("WebCitationCounter must be present after rehydration");
            assert_eq!(
                counter.counter, 123,
                "tool state must survive a rebuild for the same session (rehydration)"
            );
        }
        let (_eff, ts_c, _backend_c) = resolve_session_toolset(
            test_support::baseline_config(),
            CapabilityMode::ReadWrite,
            &[],
            cwd,
            empty_env(),
            "sess-B",
            &factory,
            None,
            None,
            None,
            None,
        )
        .expect("build toolset C");
        {
            let res = ts_c.resources.lock().await;
            let contaminated = res
                .get::<State<WebCitationCounter>>()
                .is_some_and(|c| c.counter == 123);
            assert!(
                !contaminated,
                "a different session_id must cold-start, never inherit sess-A state"
            );
        }
    }
}
