use super::*;
mod hunk_tracking_mode {
    use super::super::{plan_hunk_tracking, resolve_hunk_tracking_mode};
    use kigi_hunk_tracker::TrackingMode;
    #[test]
    fn off_and_disabled_disable_tracking() {
        assert_eq!(resolve_hunk_tracking_mode(Some("off")), None);
        assert_eq!(resolve_hunk_tracking_mode(Some("disabled")), None);
    }
    #[test]
    fn matching_is_case_insensitive_and_trimmed() {
        assert_eq!(resolve_hunk_tracking_mode(Some("OFF")), None);
        assert_eq!(resolve_hunk_tracking_mode(Some("  Off ")), None);
        assert_eq!(resolve_hunk_tracking_mode(Some("DISABLED")), None);
        assert_eq!(
            resolve_hunk_tracking_mode(Some("Agent_Only")),
            Some(TrackingMode::AgentOnly)
        );
        assert_eq!(
            resolve_hunk_tracking_mode(Some(" ALL_DIRTY ")),
            Some(TrackingMode::AllDirty)
        );
    }
    #[test]
    fn recognized_modes_parse() {
        assert_eq!(
            resolve_hunk_tracking_mode(Some("agent_only")),
            Some(TrackingMode::AgentOnly)
        );
        assert_eq!(
            resolve_hunk_tracking_mode(Some("all_dirty")),
            Some(TrackingMode::AllDirty)
        );
    }
    #[test]
    fn parser_absent_returns_none_policy_defaults_in_plan() {
        assert_eq!(resolve_hunk_tracking_mode(None), None);
        assert_eq!(resolve_hunk_tracking_mode(Some("")), None);
        assert_eq!(
            resolve_hunk_tracking_mode(Some("bogus")),
            Some(TrackingMode::AllDirty)
        );
    }
    #[test]
    fn plan_disables_actor_forward_and_loc_together() {
        for off in ["off", "disabled", "OFF"] {
            let plan = plan_hunk_tracking(Some(off));
            assert_eq!(plan.actor_mode, None, "{off} must not spawn the actor");
            assert!(!plan.enabled(), "{off} must disable the forward + LOC sink");
        }
    }
    #[test]
    fn plan_enables_actor_and_forward_for_active_modes() {
        for (mode, expected) in [
            ("agent_only", TrackingMode::AgentOnly),
            ("all_dirty", TrackingMode::AllDirty),
            ("bogus", TrackingMode::AllDirty),
        ] {
            let plan = plan_hunk_tracking(Some(mode));
            assert_eq!(plan.actor_mode, Some(expected));
            assert!(plan.enabled());
        }
        let plan = plan_hunk_tracking(None);
        assert_eq!(plan.actor_mode, None);
        assert!(!plan.enabled());
    }
}
mod capture {
    use tokio::sync::mpsc;
    use tracing::Subscriber;
    use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
    use tracing_subscriber::registry::LookupSpan;
    pub(crate) struct CapturedEvent {
        pub level: tracing::Level,
        pub fields: String,
    }
    pub(crate) struct Captured {
        pub events_rx: mpsc::UnboundedReceiver<CapturedEvent>,
        _guard: tracing::subscriber::DefaultGuard,
    }
    pub(crate) fn capture() -> Captured {
        let (tx, rx) = mpsc::unbounded_channel();
        let subscriber = tracing_subscriber::registry().with(CaptureLayer { tx });
        let guard = tracing::subscriber::set_default(subscriber);
        Captured {
            events_rx: rx,
            _guard: guard,
        }
    }
    struct CaptureLayer {
        tx: mpsc::UnboundedSender<CapturedEvent>,
    }
    impl<S> Layer<S> for CaptureLayer
    where
        S: Subscriber + for<'a> LookupSpan<'a>,
    {
        fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
            let mut v = Visitor::default();
            event.record(&mut v);
            let _ = self.tx.send(CapturedEvent {
                level: *event.metadata().level(),
                fields: v.out,
            });
        }
    }
    #[derive(Default)]
    struct Visitor {
        out: String,
    }
    impl tracing::field::Visit for Visitor {
        fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
            if !self.out.is_empty() {
                self.out.push(' ');
            }
            self.out.push_str(f.name());
            self.out.push('=');
            self.out.push_str(&format!("{v:?}"));
        }
        fn record_str(&mut self, f: &tracing::field::Field, v: &str) {
            if !self.out.is_empty() {
                self.out.push(' ');
            }
            self.out.push_str(f.name());
            self.out.push('=');
            self.out.push_str(v);
        }
    }
}
#[test]
fn warn_on_missing_parent_session_emits_when_session_absent() {
    let captured = capture::capture();
    warn_on_missing_parent_session_for_validate_type("ghost-session", false);
    let mut rx = captured.events_rx;
    let mut saw = false;
    while let Ok(event) = rx.try_recv() {
        if event.level == tracing::Level::WARN
            && event
                .fields
                .contains("ValidateType received for unknown parent session")
            && event.fields.contains("parent_session_id=ghost-session")
        {
            saw = true;
            break;
        }
    }
    assert!(saw, "warn must fire");
}
#[test]
fn warn_on_missing_parent_session_silent_when_session_present() {
    let captured = capture::capture();
    warn_on_missing_parent_session_for_validate_type("real-session", true);
    let mut rx = captured.events_rx;
    assert!(rx.try_recv().is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn broadcast_refresh_skill_baseline_sends_one_message_per_sender() {
    let mut receivers = Vec::new();
    let mut senders = Vec::new();
    for _ in 0..3 {
        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
        senders.push(tx);
        receivers.push(rx);
    }
    MvpAgent::broadcast_refresh_skill_baseline(senders);
    for mut rx in receivers {
        assert!(matches!(
            rx.try_recv(),
            Ok(crate::session::SessionCommand::RefreshSkillBaseline)
        ));
        assert!(
            rx.try_recv().is_err(),
            "broadcast must send exactly one message per sender",
        );
    }
}
#[tokio::test(flavor = "current_thread")]
async fn broadcast_refresh_skill_baseline_tolerates_dropped_receiver() {
    let (tx_alive, mut rx_alive) = tokio::sync::mpsc::unbounded_channel();
    let (tx_dead, rx_dead) = tokio::sync::mpsc::unbounded_channel();
    drop(rx_dead);
    MvpAgent::broadcast_refresh_skill_baseline(vec![tx_alive, tx_dead]);
    assert!(matches!(
        rx_alive.try_recv(),
        Ok(crate::session::SessionCommand::RefreshSkillBaseline)
    ));
}
/// The monotonic turn counter must never wrap on the DB-bound i32 path.
/// `allocate_turn_number` returns u64; the AB submission casts to i32.
/// Verify we saturate instead of wrapping.
#[test]
fn trace_turn_to_i32_saturates_at_max() {
    let small: u64 = 42;
    let result = i32::try_from(small).unwrap_or(i32::MAX);
    assert_eq!(result, 42);
    let huge: u64 = (i32::MAX as u64) + 100;
    let result = i32::try_from(huge).unwrap_or(i32::MAX);
    assert_eq!(result, i32::MAX);
    let boundary: u64 = i32::MAX as u64;
    let result = i32::try_from(boundary).unwrap_or(i32::MAX);
    assert_eq!(result, i32::MAX);
}
/// After allocating a turn number, `session_turn_numbers` holds the next
/// value (current + 1). This is the value that must be persisted via
/// `SetNextTraceTurn` so the counter survives restarts.
#[test]
fn allocate_turn_number_advances_counter() {
    use std::cell::RefCell;
    use std::collections::HashMap;
    let counters: RefCell<HashMap<acp::SessionId, u64>> = RefCell::new(HashMap::new());
    let sid = acp::SessionId::new("test-session");
    let allocate = |id: &acp::SessionId| -> u64 {
        let mut m = counters.borrow_mut();
        let turn = m.get(id).copied().unwrap_or(0u64);
        m.insert(id.clone(), turn.saturating_add(1));
        turn
    };
    assert_eq!(allocate(&sid), 0);
    assert_eq!(*counters.borrow().get(&sid).unwrap(), 1);
    assert_eq!(allocate(&sid), 1);
    assert_eq!(*counters.borrow().get(&sid).unwrap(), 2);
    assert_eq!(allocate(&sid), 2);
    assert_eq!(*counters.borrow().get(&sid).unwrap(), 3);
}
/// With no overrides and model_agent_type = None, the default agent is used.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_defaults_to_kigi() {
    let prev = std::env::var("KIGI_AGENT").ok();
    unsafe {
        std::env::remove_var("KIGI_AGENT");
    }
    let tmp = tempfile::tempdir().unwrap();
    let def = MvpAgent::resolve_agent_definition(
        tmp.path(),
        None,
        &config::AgentSelectionConfig::default(),
        None,
        None,
    );
    assert_eq!(def.name, config::DEFAULT_AGENT_TYPE);
    if let Some(v) = prev {
        unsafe { std::env::set_var("KIGI_AGENT", v) }
    }
}
/// When model_agent_type = Some("codex"), the codex agent is selected even
/// though the default chain would return kigi.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_model_agent_type_overrides_default() {
    let prev = std::env::var("KIGI_AGENT").ok();
    unsafe {
        std::env::remove_var("KIGI_AGENT");
    }
    let tmp = tempfile::tempdir().unwrap();
    let def = MvpAgent::resolve_agent_definition(
        tmp.path(),
        None,
        &config::AgentSelectionConfig::default(),
        None,
        Some("codex"),
    );
    assert_eq!(def.name, "codex");
    if let Some(v) = prev {
        unsafe { std::env::set_var("KIGI_AGENT", v) }
    }
}
/// When model_agent_type is None, the chain-resolved default agent is
/// NOT overridden. This is the crux of the leader-mode fix: a session whose
/// model has no agent_type must get the default agent, not a stale value
/// from a different client's model.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_none_agent_type_does_not_override() {
    let prev = std::env::var("KIGI_AGENT").ok();
    unsafe {
        std::env::remove_var("KIGI_AGENT");
    }
    let tmp = tempfile::tempdir().unwrap();
    let def = MvpAgent::resolve_agent_definition(
        tmp.path(),
        None,
        &config::AgentSelectionConfig::default(),
        None,
        None,
    );
    assert_eq!(def.name, config::DEFAULT_AGENT_TYPE);
    if let Some(v) = prev {
        unsafe { std::env::set_var("KIGI_AGENT", v) }
    }
}
/// Regression for the web-client devbox bug: an ACP profile must
/// win when the model's `agent_type` is the default value.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_acp_profile_wins_when_model_agent_type_is_default() {
    let prev = std::env::var("KIGI_AGENT").ok();
    unsafe {
        std::env::remove_var("KIGI_AGENT");
    }
    let tmp = tempfile::tempdir().unwrap();
    let acp_profile = kigi_agent::AgentDefinition::from_json(&serde_json::json!(
        { "name" : "custom-devbox-profile", "description" :
        "Custom devbox profile", "systemPrompt" :
        "You are a custom-configured devbox agent.", }
    ))
    .expect("agent definition must parse");
    let def = MvpAgent::resolve_agent_definition(
        tmp.path(),
        None,
        &config::AgentSelectionConfig::default(),
        Some(acp_profile),
        Some(config::DEFAULT_AGENT_TYPE),
    );
    assert_eq!(
        def.name, "custom-devbox-profile",
        "ACP _meta.agentProfile must win when model_agent_type is the default value"
    );
    if let Some(v) = prev {
        unsafe { std::env::set_var("KIGI_AGENT", v) }
    }
}
/// Regression: after `DEFAULT_AGENT_TYPE` flipped to
/// `kigi-plan`, models in the catalog that still declare
/// `agent_type = "kigi"` explicitly must NOT preempt an ACP
/// profile. Any value in the `kigi*` family is the stock harness
/// with no strict requirement.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_acp_profile_wins_for_explicit_kigi_family() {
    let prev = std::env::var("KIGI_AGENT").ok();
    unsafe {
        std::env::remove_var("KIGI_AGENT");
    }
    let tmp = tempfile::tempdir().unwrap();
    let acp_profile = kigi_agent::AgentDefinition::from_json(&serde_json::json!(
        { "name" : "custom-devbox-profile", "description" :
        "Custom devbox profile", }
    ))
    .expect("agent definition must parse");
    for family_variant in ["kigi", "kigi-plan", "kigi-concise"] {
        let def = MvpAgent::resolve_agent_definition(
            tmp.path(),
            None,
            &config::AgentSelectionConfig::default(),
            Some(acp_profile.clone()),
            Some(family_variant),
        );
        assert_eq!(
            def.name, "custom-devbox-profile",
            "ACP profile must win for kigi family variant `{family_variant}`"
        );
    }
    if let Some(v) = prev {
        unsafe { std::env::set_var("KIGI_AGENT", v) }
    }
}
/// A non-strict (stock / vision-capable) model leaves the template alone, so
/// such models keep native image input.
#[test]
fn inherited_harness_template_skips_nonstrict_model() {
    use kigi_agent::prompt::user_message::UserMessageTemplate;
    let tmp = tempfile::tempdir().unwrap();
    assert!(
        inherited_harness_template(
            &UserMessageTemplate::Default,
            Some(config::DEFAULT_AGENT_TYPE),
            tmp.path(),
        )
        .is_none()
    );
}
/// An explicit (non-default) template is never overridden — inheritance only
/// fills in the default.
#[test]
fn inherited_harness_template_respects_explicit_template() {
    use kigi_agent::prompt::user_message::UserMessageTemplate;
    let tmp = tempfile::tempdir().unwrap();
    let explicit = UserMessageTemplate::Custom("MY CUSTOM TEMPLATE".to_owned());
    assert!(inherited_harness_template(&explicit, Some("cursor"), tmp.path()).is_none());
}
/// CLI `--agent-profile` wins when model_agent_type is the default
/// (also shadowed by the same regression).
#[test]
#[serial_test::serial]
fn resolve_agent_definition_cli_agent_profile_wins_when_model_agent_type_is_default() {
    let prev = std::env::var("KIGI_AGENT").ok();
    unsafe {
        std::env::remove_var("KIGI_AGENT");
    }
    let tmp = tempfile::tempdir().unwrap();
    let profile_path = tmp.path().join("cli-profile.md");
    std::fs::write(
        &profile_path,
        "---\nname: cli-profile\ndescription: cli test\n---\nYou are a CLI profile.\n",
    )
    .unwrap();
    let def = MvpAgent::resolve_agent_definition(
        tmp.path(),
        Some(&profile_path),
        &config::AgentSelectionConfig::default(),
        None,
        Some(config::DEFAULT_AGENT_TYPE),
    );
    assert_eq!(def.name, "cli-profile");
    if let Some(v) = prev {
        unsafe { std::env::set_var("KIGI_AGENT", v) }
    }
}
/// Agent profile with `model: Override(id)` preserves the field through resolution.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_agent_profile_with_model_override() {
    let prev = std::env::var("KIGI_AGENT").ok();
    unsafe {
        std::env::remove_var("KIGI_AGENT");
    }
    let tmp = tempfile::tempdir().unwrap();
    let agents_dir = tmp.path().join(".kigi").join("agents");
    std::fs::create_dir_all(&agents_dir).unwrap();
    std::fs::write(
            agents_dir.join("test-architect.md"),
            "---\nname: test-architect\ndescription: test\nmodel: test-model-123\n---\nYou are a test.\n",
        )
        .unwrap();
    let agent_config = config::AgentSelectionConfig {
        name: Some("test-architect".to_string()),
        definition: None,
        system_prompt_label: None,
    };
    let def = MvpAgent::resolve_agent_definition(tmp.path(), None, &agent_config, None, None);
    assert_eq!(def.name, "test-architect");
    assert_eq!(
        def.model,
        kigi_agent::config::ModelOverride::Override("test-model-123".to_string()),
        "agent profile model override must be preserved through resolution"
    );
    match prev {
        Some(v) => unsafe { std::env::set_var("KIGI_AGENT", v) },
        None => unsafe { std::env::remove_var("KIGI_AGENT") },
    }
}
#[test]
fn read_session_or_init_meta_str_prefers_session_meta() {
    let session = serde_json::json!({ "rules" : "from-session" });
    let init = serde_json::json!({ "rules" : "from-init" });
    assert_eq!(
        read_session_or_init_meta_str(session.as_object(), init.as_object(), "rules"),
        Some("from-session"),
    );
}
#[test]
fn read_session_or_init_meta_str_falls_back_to_init_meta() {
    let session = serde_json::json!({ "other" : "x" });
    let init = serde_json::json!({ "rules" : "from-init" });
    assert_eq!(
        read_session_or_init_meta_str(session.as_object(), init.as_object(), "rules"),
        Some("from-init"),
    );
    assert_eq!(
        read_session_or_init_meta_str(None, init.as_object(), "rules"),
        Some("from-init"),
    );
}
#[test]
fn parse_session_plugin_dirs_filters_and_dedupes() {
    let tmp = tempfile::tempdir().unwrap();
    let dir = dunce::canonicalize(tmp.path()).unwrap().join("plugin");
    std::fs::create_dir(&dir).unwrap();
    let file = tmp.path().join("file.txt");
    std::fs::write(&file, "x").unwrap();
    let meta = serde_json::json!(
        { "pluginDirs" : [dir.to_string_lossy(), dir.to_string_lossy(), file
        .to_string_lossy(), "relative/path", 42,] }
    );
    assert_eq!(parse_session_plugin_dirs(meta.as_object()), vec![dir]);
    assert!(parse_session_plugin_dirs(None).is_empty());
    assert!(parse_session_plugin_dirs(serde_json::json!({}).as_object()).is_empty());
}
#[test]
fn read_session_or_init_meta_str_returns_none_when_absent() {
    assert_eq!(read_session_or_init_meta_str(None, None, "rules"), None,);
    let session = serde_json::json!({ "other" : "x" });
    assert_eq!(
        read_session_or_init_meta_str(session.as_object(), None, "rules"),
        None,
    );
}
#[test]
fn read_session_or_init_meta_str_ignores_non_string_values() {
    let session = serde_json::json!({ "rules" : 42 });
    let init = serde_json::json!({ "rules" : "from-init" });
    assert_eq!(
        read_session_or_init_meta_str(session.as_object(), init.as_object(), "rules"),
        Some("from-init"),
    );
}
#[test]
fn system_prompt_override_from_meta_prefers_session_and_rejects_empty() {
    let session = serde_json::json!({ "systemPromptOverride" : "from session" });
    let init = serde_json::json!({ "systemPromptOverride" : "from init" });
    assert_eq!(
        system_prompt_override_from_meta(session.as_object(), init.as_object()),
        Some("from session")
    );
    assert_eq!(
        system_prompt_override_from_meta(None, init.as_object()),
        Some("from init")
    );
    let empty = serde_json::json!({ "systemPromptOverride" : "" });
    assert_eq!(
        system_prompt_override_from_meta(empty.as_object(), None),
        None
    );
    assert_eq!(system_prompt_override_from_meta(None, None), None);
}
#[test]
fn enqueue_replace_system_prompt_override_sends_when_present() {
    use crate::session::SessionCommand;
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
    let session = serde_json::json!({ "systemPromptOverride" : "from session" });
    let init = serde_json::json!({ "systemPromptOverride" : "from init" });
    enqueue_replace_system_prompt_override(&tx, session.as_object(), init.as_object());
    match rx.try_recv() {
        Ok(SessionCommand::ReplaceSystemPrompt { system_prompt }) => {
            assert_eq!(system_prompt, "from session", "session meta wins over init");
        }
        _ => panic!("expected a ReplaceSystemPrompt command"),
    }
}
#[test]
fn enqueue_replace_system_prompt_override_noop_when_absent_or_empty() {
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
    enqueue_replace_system_prompt_override(
        &tx,
        serde_json::json!({ "systemPromptOverride" : "" }).as_object(),
        None,
    );
    enqueue_replace_system_prompt_override(&tx, serde_json::json!({}).as_object(), None);
    enqueue_replace_system_prompt_override(&tx, None, None);
    assert!(
        rx.try_recv().is_err(),
        "no command should be enqueued without a non-empty override"
    );
}
/// Regression for the web-client `_meta.agentProfile` -> `set_session_model`
/// flow: a zero-turn switch from `kigi` (a client profile name) to
/// `kigi-plan` (the default model agent_type) must be
/// treated as compatible so the harness rebuild is skipped and the
/// custom prompt body is preserved.
#[test]
fn harnesses_are_compatible_for_stock_family_pairs() {
    assert!(harnesses_are_compatible("kigi", "kigi-plan"));
    assert!(harnesses_are_compatible("kigi-plan", "kigi"));
    assert!(harnesses_are_compatible("kigi", "kigi"));
    assert!(harnesses_are_compatible(
        "kigi-concise",
        "kigi-plan"
    ));
    assert!(harnesses_are_compatible(
        "remote-sidebar",
        "kigi-plan"
    ));
}
#[test]
fn harnesses_are_compatible_rejects_strict_mismatches() {
    assert!(harnesses_are_compatible("codex", "codex"));
    assert!(!harnesses_are_compatible("kigi-plan", "codex"));
}
#[test]
fn explicit_agent_type_wins_over_session_default() {
    assert_eq!(
        resolve_required_agent_type(Some("cursor"), "kigi-plan"),
        "cursor"
    );
}
#[test]
fn null_agent_type_falls_back_to_session_default_kigi_plan() {
    assert_eq!(
        resolve_required_agent_type(None, "kigi-plan"),
        "kigi-plan"
    );
}
#[test]
fn null_agent_type_falls_back_to_session_default_kigi() {
    assert_eq!(
        resolve_required_agent_type(None, "kigi"),
        "kigi"
    );
}
#[test]
fn null_agent_type_returns_to_session_default_after_cursor_switch() {
    let session_default = "kigi-plan";
    let required_after_null = resolve_required_agent_type(None, session_default);
    assert_eq!(required_after_null, "kigi-plan");
    assert_ne!(required_after_null, "cursor");
}
/// Compatible stock switches (no rebuild) must NOT mutate `agent_name`,
/// preserving the session's original ACP `agentProfile`.
#[test]
fn agent_name_unchanged_without_harness_rebuild() {
    let unchanged = agent_name_after_model_switch(false, "kigi-plan", "remote-sidebar");
    assert_eq!(
        unchanged, "remote-sidebar",
        "a compatible stock switch must preserve the original agent profile name"
    );
}
/// End-to-end test: config -> resolve -> override -> finalize -> tool_definitions.
///
/// Exercises the full live path through to the finalized toolset, proving
/// that the hashline tools appear in the actual tool definitions that
/// would be sent to the model.
#[tokio::test]
async fn file_toolset_override_e2e_to_finalized_toolset() {
    use crate::tools::{FileToolset, ShellToolsetConfig};
    use kigi_tools::computer::local::{LocalFs, LocalTerminalBackend};
    use kigi_tools::notification::ToolNotificationHandle;
    use kigi_tools::registry::types::SessionContext;
    let tmp = tempfile::tempdir().unwrap();
    let mut def = MvpAgent::resolve_agent_definition(
        tmp.path(),
        None,
        &config::AgentSelectionConfig::default(),
        None,
        None,
    );
    let toolset_config = ShellToolsetConfig {
        file_toolset: FileToolset::Hashline,
        ..ShellToolsetConfig::default()
    };
    let effective = toolset_config.resolve_file_toolset(None);
    let file_tools = effective
        .tool_configs(&toolset_config.hashline)
        .expect("default hashline config should validate");
    def.override_file_tools(file_tools);
    let builder = kigi_tools::registry::types::ToolRegistryBuilder::new();
    let ctx = SessionContext {
        backend: std::sync::Arc::new(LocalTerminalBackend::new()),
        fs: std::sync::Arc::new(LocalFs),
        cwd: tmp.path().to_path_buf(),
        session_folder: tmp.path().join("session"),
        session_env: std::sync::Arc::new(std::collections::HashMap::new()),
        notification_handle: ToolNotificationHandle::noop(),
        owner_session_id: None,
        parent_scheduler_handle: None,
        skills: vec![],
        state_path: tmp.path().join("state.json"),
        memory_backend: None,
        web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::default(),
        web_fetch_config: Default::default(),
        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,
    };
    let toolset = builder
        .finalize(def.tool_config, ctx)
        .expect("hashline toolset should finalize");
    let defs = toolset.tool_definitions();
    let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
    assert!(names.contains(&"hashline_read"), "defs: {names:?}");
    assert!(names.contains(&"hashline_edit"), "defs: {names:?}");
    assert!(names.contains(&"hashline_grep"), "defs: {names:?}");
    assert!(!names.contains(&"read_file"), "defs: {names:?}");
    assert!(!names.contains(&"search_replace"), "defs: {names:?}");
    assert!(names.contains(&"list_dir"), "defs: {names:?}");
}
/// Invalid hashline config returns a clean error, not a panic.
#[test]
fn file_toolset_override_invalid_config_returns_error() {
    use crate::tools::FileToolset;
    use crate::tools::config::HashlineSchemeConfig;
    let bad = HashlineSchemeConfig {
        scheme: "bogus".to_owned(),
        hash_len: 0,
        chunk_size: 0,
    };
    let err = FileToolset::Hashline.tool_configs(&bad);
    assert!(err.is_err());
    assert!(err.unwrap_err().contains("unknown"));
}
/// Helper: creates a real SessionHandle with the given model, yolo, and client id.
/// Requires a tokio runtime for SessionSignalsHandle::new().
fn make_test_handle(
    model: &str,
    yolo: bool,
    client_id: Option<&str>,
) -> crate::session::SessionHandle {
    let (cmd_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel();
    let (hunk_event_tx, _hunk_event_rx) = tokio::sync::mpsc::unbounded_channel();
    let hunk_cancel = tokio_util::sync::CancellationToken::new();
    let hunk_tracker_handle = kigi_hunk_tracker::HunkTrackerActor::spawn(
        "test".to_string(),
        std::path::PathBuf::from("/tmp"),
        hunk_event_tx,
        kigi_hunk_tracker::TrackingMode::AllDirty,
        hunk_cancel,
    );
    crate::session::SessionHandle {
        cmd_tx,
        persistence_tx,
        current_prompt_id: std::sync::Arc::new(std::sync::Mutex::new(None)),
        pending_interactions: std::sync::Arc::new(std::sync::Mutex::new(
            std::collections::HashMap::new(),
        )),
        info: crate::session::info::Info {
            id: acp::SessionId::new("test"),
            cwd: "/tmp".to_string(),
        },
        max_turns: None,
        hunk_tracker_handle,
        chat_state_handle: kigi_chat_state::ChatStateHandle::noop(),
        signals_handle: crate::session::signals::SessionSignalsHandle::new(),
        gateway_enabled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
        mcp_servers: vec![],
        initial_client_mcp_servers: vec![],
        display_cwd: None,
        feedback_manager: std::sync::Arc::new(
            crate::session::feedback_manager::FeedbackManager::local_only("test"),
        ),
        tool_context: crate::tools::ToolContext::new_local_context(
            kigi_paths::AbsPathBuf::new(std::path::PathBuf::from("/tmp")).unwrap(),
            std::sync::Arc::new(kigi_workspace::file_system::LocalFs::new(
                std::path::PathBuf::from("/tmp"),
            )),
            std::sync::Arc::new(crate::terminal::LocalTerminalRunner),
        ),
        model_id: acp::ModelId::new(model),
        reasoning_effort: None,
        yolo_mode: yolo,
        origin_client: client_id.map(|s| crate::http::OriginClientInfo {
            product: s.to_string(),
            version: None,
        }),
        code_nav_enabled: false,
        ask_user_question_enabled: true,
        plan_mode: std::sync::Arc::new(parking_lot::Mutex::new(
            crate::session::plan_mode::PlanModeTracker::new(std::path::PathBuf::from("/tmp")),
        )),
        force_compact: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
        permission_handle: kigi_workspace::permission::PermissionHandle::allow_all(),
        attribution_callback: None,
        agent_name: "kigi".to_string(),
        session_default_agent_profile: None,
        allowed_subagent_types: None,
        hook_registry: None,
        workspace_ops: kigi_workspace::WorkspaceOps::for_test(),
        terminal_backend: None,
        tools_notification_handle: None,
        scheduler_handle: None,
    }
}
/// lookup_session_model returns the per-session model for each session.
#[tokio::test]
async fn lookup_session_model_returns_per_session_model() {
    let sid_a = acp::SessionId::new("sess-a");
    let sid_b = acp::SessionId::new("sess-b");
    let default_model = acp::ModelId::new("default-model");
    let sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = [
        (sid_a.clone(), make_test_handle("kigi-3-fast", false, None)),
        (sid_b.clone(), make_test_handle("codex-mini", false, None)),
    ]
    .into();
    assert_eq!(
        lookup_session_model(&sessions, Some(&sid_a), &default_model)
            .0
            .as_ref(),
        "kigi-3-fast"
    );
    assert_eq!(
        lookup_session_model(&sessions, Some(&sid_b), &default_model)
            .0
            .as_ref(),
        "codex-mini"
    );
}
/// lookup_session_model falls back to the default when session_id is None.
#[tokio::test]
async fn lookup_session_model_fallback_no_session() {
    let default_model = acp::ModelId::new("kigi-3");
    let sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = HashMap::new();
    assert_eq!(
        lookup_session_model(&sessions, None, &default_model)
            .0
            .as_ref(),
        "kigi-3"
    );
}
/// Mutating session A's model_id via the handle does not affect session B.
#[tokio::test]
async fn set_session_model_does_not_cross_contaminate() {
    let sid_a = acp::SessionId::new("sess-a");
    let sid_b = acp::SessionId::new("sess-b");
    let default_model = acp::ModelId::new("default");
    let mut sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = [
        (sid_a.clone(), make_test_handle("kigi-3", false, None)),
        (sid_b.clone(), make_test_handle("kigi-3", false, None)),
    ]
    .into();
    sessions.get_mut(&sid_a).unwrap().model_id = acp::ModelId::new("codex-mini");
    assert_eq!(
        lookup_session_model(&sessions, Some(&sid_a), &default_model)
            .0
            .as_ref(),
        "codex-mini"
    );
    assert_eq!(
        lookup_session_model(&sessions, Some(&sid_b), &default_model)
            .0
            .as_ref(),
        "kigi-3",
        "Session B's model must not be affected by session A's model change"
    );
}
#[tokio::test]
async fn model_state_prefers_session_reasoning_effort_over_global() {
    use crate::agent::config::{EndpointsConfig, ModelEntry};
    use kigi_sampling_types::{REASONING_EFFORT_META_KEY, ReasoningEffort};
    let agent = build_minimal_agent_for_tests();
    let mut entry = ModelEntry::fallback("effort-model", &EndpointsConfig::default());
    entry.info.supports_reasoning_effort = true;
    agent
        .models_manager
        .insert_test_entry("effort-model", entry);
    agent
        .models_manager
        .set_current_reasoning_effort(Some(ReasoningEffort::Low));
    let read_effort = |state: &acp::SessionModelState| -> Option<String> {
        state
            .available_models
            .iter()
            .find(|m| m.model_id.0.as_ref() == "effort-model")
            .and_then(|m| m.meta.as_ref())
            .and_then(|m| m.get(REASONING_EFFORT_META_KEY))
            .and_then(|v| v.as_str())
            .map(str::to_owned)
    };
    let pinned = acp::SessionId::new("sess-pinned");
    let mut handle = make_test_handle("effort-model", false, None);
    handle.reasoning_effort = Some(ReasoningEffort::Xhigh);
    agent.sessions.borrow_mut().insert(pinned.clone(), handle);
    assert_eq!(
        read_effort(&agent.model_state(Some(&pinned))).as_deref(),
        Some("xhigh"),
        "model_state must report the session's own restored effort",
    );
    let unset = acp::SessionId::new("sess-unset");
    agent
        .sessions
        .borrow_mut()
        .insert(unset.clone(), make_test_handle("effort-model", false, None));
    assert_eq!(
        read_effort(&agent.model_state(Some(&unset))).as_deref(),
        Some("low"),
        "absent session effort falls back to the global default",
    );
}
/// A session persisted under a routing *slug* (not the catalog map key) must
/// still get reasoning modes and a selected model from
/// `session_config_options` — the id is resolved to the catalog key before
/// the catalog effort lookups and the selected-model match.
#[tokio::test]
async fn session_config_options_resolves_routing_slug_to_catalog_model() {
    use crate::agent::config::{EndpointsConfig, ModelEntry};
    use kigi_sampling_types::ReasoningEffort;
    let agent = build_minimal_agent_for_tests();
    let mut entry = ModelEntry::fallback("catalog-key-model", &EndpointsConfig::default());
    entry.info.model = "routing-slug".to_string();
    entry.info.supports_reasoning_effort = true;
    entry.info.reasoning_effort = Some(ReasoningEffort::High);
    agent
        .models_manager
        .insert_test_entry("catalog-key-model", entry);
    let sid = acp::SessionId::new("sess-slug");
    agent
        .sessions
        .borrow_mut()
        .insert(sid.clone(), make_test_handle("routing-slug", false, None));
    let state = agent.model_state(Some(&sid));
    assert_eq!(state.current_model_id.0.as_ref(), "routing-slug");
    let opts = agent.session_config_options(Some(&sid), &state);
    let modes: Vec<_> = opts.iter().filter(|o| o.category == "mode").collect();
    assert!(
        !modes.is_empty(),
        "reasoning modes must surface for a slug-identified session"
    );
    assert!(
        modes.iter().any(|o| o.id == "high" && o.selected),
        "catalog default effort should be selected"
    );
    assert!(
        opts.iter()
            .any(|o| o.category == "model" && o.id == "catalog-key-model" && o.selected),
        "resolved catalog model must be selected"
    );
}
/// YOLO toggle scoped by client_identifier: only matching sessions are updated.
#[tokio::test]
async fn yolo_toggle_scoped_by_client_identifier() {
    let sid_tui = acp::SessionId::new("sess-tui");
    let sid_vscode = acp::SessionId::new("sess-vscode");
    let mut sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = [
        (
            sid_tui.clone(),
            make_test_handle("kigi-3", false, Some("kigi-tui")),
        ),
        (
            sid_vscode.clone(),
            make_test_handle("kigi-3", false, Some("kigi-code-extension")),
        ),
    ]
    .into();
    let updated = apply_yolo_mode_to_matching_sessions(&mut sessions, Some("kigi-tui"), true);
    assert_eq!(updated, 1, "exactly one matching session should be updated");
    assert!(
        sessions[&sid_tui].yolo_mode,
        "TUI session should have yolo=true after TUI toggle"
    );
    assert!(
        !sessions[&sid_vscode].yolo_mode,
        "VS Code session must NOT be affected by TUI's yolo toggle"
    );
}
/// A client can explicitly disable YOLO for its own sessions after startup,
/// even if those sessions were initially created with yolo=true.
#[tokio::test]
async fn yolo_toggle_can_disable_session_started_with_yolo_enabled() {
    let sid_tui = acp::SessionId::new("sess-tui");
    let sid_other = acp::SessionId::new("sess-other");
    let mut sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = [
        (
            sid_tui.clone(),
            make_test_handle("kigi-3", true, Some("kigi-tui")),
        ),
        (
            sid_other.clone(),
            make_test_handle("kigi-3", true, Some("kigi-code-extension")),
        ),
    ]
    .into();
    let updated = apply_yolo_mode_to_matching_sessions(&mut sessions, Some("kigi-tui"), false);
    assert_eq!(updated, 1, "only the sender's session should be updated");
    assert!(
        !sessions[&sid_tui].yolo_mode,
        "sender session should be switched to yolo=false"
    );
    assert!(
        sessions[&sid_other].yolo_mode,
        "other client's session must keep its previous yolo state"
    );
}
/// `drain_old_session_thread` returns immediately when the thread has
/// already finished.
#[tokio::test]
async fn drain_finished_thread_returns_immediately() {
    let session_threads: RefCell<HashMap<acp::SessionId, crate::session::SessionThread>> =
        RefCell::new(HashMap::new());
    let sid = acp::SessionId::new("drain-test");
    let handle = std::thread::spawn(|| {});
    std::thread::sleep(std::time::Duration::from_millis(10));
    session_threads.borrow_mut().insert(
        sid.clone(),
        crate::session::SessionThread::from_handle(handle),
    );
    let thread = session_threads.borrow_mut().remove(&sid).unwrap();
    assert!(thread.is_finished(), "thread should be finished");
    assert!(!session_threads.borrow().contains_key(&sid));
}
/// `drain_old_session_thread` waits for a slow thread to finish.
#[tokio::test]
async fn drain_waits_for_slow_thread() {
    let session_threads: RefCell<HashMap<acp::SessionId, crate::session::SessionThread>> =
        RefCell::new(HashMap::new());
    let sid = acp::SessionId::new("slow-drain");
    let handle = std::thread::spawn(|| {
        std::thread::sleep(std::time::Duration::from_millis(100));
    });
    session_threads.borrow_mut().insert(
        sid.clone(),
        crate::session::SessionThread::from_handle(handle),
    );
    let thread = session_threads.borrow_mut().remove(&sid).unwrap();
    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
    loop {
        if thread.is_finished() {
            break;
        }
        assert!(
            tokio::time::Instant::now() < deadline,
            "thread should finish within 5s"
        );
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    }
    assert!(thread.is_finished());
}
/// Drain respects the 5s deadline and returns even if the thread is still running.
#[tokio::test]
async fn drain_respects_deadline() {
    let session_threads: RefCell<HashMap<acp::SessionId, crate::session::SessionThread>> =
        RefCell::new(HashMap::new());
    let sid = acp::SessionId::new("hung-drain");
    let handle = std::thread::spawn(|| {
        std::thread::sleep(std::time::Duration::from_secs(30));
    });
    session_threads.borrow_mut().insert(
        sid.clone(),
        crate::session::SessionThread::from_handle(handle),
    );
    let thread = session_threads.borrow_mut().remove(&sid).unwrap();
    let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(200);
    let mut timed_out = false;
    loop {
        if thread.is_finished() {
            break;
        }
        if tokio::time::Instant::now() >= deadline {
            timed_out = true;
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    }
    assert!(
        timed_out,
        "should have timed out waiting for the hung thread"
    );
    assert!(!thread.is_finished(), "thread should still be running");
}
#[test]
fn parse_code_nav_capability_present_and_true() {
    let mut meta = serde_json::Map::new();
    meta.insert(
        "kigi/codeNavigation".to_string(),
        serde_json::json!({ "enabled" : true }),
    );
    let init = acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(
        acp::ClientCapabilities::new()
            .fs(acp::FileSystemCapabilities::new())
            .terminal(false)
            .meta(meta),
    );
    assert!(MvpAgent::parse_code_nav_capability(&init));
}
#[test]
fn parse_code_nav_capability_absent_returns_false() {
    let init = acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(
        acp::ClientCapabilities::new()
            .fs(acp::FileSystemCapabilities::new())
            .terminal(false),
    );
    assert!(!MvpAgent::parse_code_nav_capability(&init));
}
#[test]
fn parse_code_nav_capability_false_returns_false() {
    let mut meta = serde_json::Map::new();
    meta.insert(
        "kigi/codeNavigation".to_string(),
        serde_json::json!({ "enabled" : false }),
    );
    let init = acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(
        acp::ClientCapabilities::new()
            .fs(acp::FileSystemCapabilities::new())
            .terminal(false)
            .meta(meta),
    );
    assert!(!MvpAgent::parse_code_nav_capability(&init));
}
/// Verify that two session handles with different code-nav state produce
/// independent eligibility outcomes — the key leader-mode isolation test.
///
/// This tests the `code_nav_eligibility_for_request` lookup path directly
/// by inspecting the per-handle fields rather than building a full agent,
/// which mirrors what the method actually reads at runtime.
#[tokio::test]
async fn test_per_session_code_nav_isolation() {
    let web_handle = {
        let mut h = make_test_handle("model", false, Some("kigi-web"));
        h.code_nav_enabled = true;
        h
    };
    let tui_handle = {
        let mut h = make_test_handle("model", false, Some("kigi-tui"));
        h.code_nav_enabled = false;
        h
    };
    let check = |handle: &crate::session::SessionHandle| {
        let ct = crate::http::client_type_from_origin(handle.origin_client.as_ref());
        if !matches!(ct, ClientType::KigiWeb) {
            return Err(CodeNavEligibility::ClientNotWeb);
        }
        if !handle.code_nav_enabled {
            return Err(CodeNavEligibility::CapabilityNotAdvertised);
        }
        Ok(())
    };
    assert!(
        check(&web_handle).is_ok(),
        "web session with capability should pass client-type and capability gates"
    );
    assert_eq!(
        check(&tui_handle),
        Err(CodeNavEligibility::ClientNotWeb),
        "tui session should be rejected at gate 1"
    );
    let mut web_no_cap = web_handle.clone();
    web_no_cap.code_nav_enabled = false;
    assert_eq!(
        check(&web_no_cap),
        Err(CodeNavEligibility::CapabilityNotAdvertised),
        "web session without capability should be rejected at gate 2"
    );
    assert!(
        check(&web_handle).is_ok(),
        "original web handle must be unaffected"
    );
}
/// Verify that code-nav requests without a sessionId are rejected.
///
/// `sessionId` is required so per-client capability gating is unambiguous
/// in both simple and leader modes.  Falling back to shared global state
/// (last-client-wins in leader mode) is not safe.
#[test]
fn test_sessionless_request_requires_session_id() {
    let session_id: Option<&acp::SessionId> = None;
    let result: Result<(), CodeNavEligibility> = if session_id.is_none() {
        Err(CodeNavEligibility::SessionRequired)
    } else {
        Ok(())
    };
    assert_eq!(
        result,
        Err(CodeNavEligibility::SessionRequired),
        "cwd-only requests with no sessionId must return SessionRequired"
    );
}
/// Build a minimal MvpAgent suitable for testing extension methods.
fn build_minimal_agent_for_tests() -> MvpAgent {
    use crate::agent::config::Config as AgentConfig;
    use crate::auth::{AuthManager, KimiCodeConfig};
    let temp_dir = tempfile::tempdir().unwrap();
    let auth_manager =
        std::sync::Arc::new(AuthManager::new(temp_dir.path(), KimiCodeConfig::default()));
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    let gateway = GatewaySender::new(tx);
    let cfg = AgentConfig::default();
    MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config")
}
/// Build a minimal MvpAgent with pre-loaded auth for gate tests.
fn build_agent_with_auth(auth: crate::auth::KimiAuth) -> MvpAgent {
    use crate::agent::config::Config as AgentConfig;
    use crate::auth::{AuthManager, KimiCodeConfig};
    let temp_dir = tempfile::tempdir().unwrap();
    let auth_manager =
        std::sync::Arc::new(AuthManager::new(temp_dir.path(), KimiCodeConfig::default()));
    auth_manager.hot_swap(auth);
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    let gateway = GatewaySender::new(tx);
    let cfg = AgentConfig::default();
    MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config")
}
/// Regression (token-leak, Facet B via the api_key channel): under a
/// session-based (Kimi) primary auth method, `prepare_sampling_config_for_model`
/// must resolve the session token by the MODEL's platform — so a grok
/// (oauth-platform) model NEVER carries the primary Kimi session key as its
/// `api_key`, while a first-party Kimi model still does. Relies on the
/// process-global OAuth pool (there is no per-session snapshot).
///
/// Reverting the fix (resolving the session token from the primary regardless of
/// the model's platform) fails the grok assertion below — it would stamp the
/// live Kimi key on a request bound for api.x.ai.
#[tokio::test]
#[serial_test::serial]
async fn prepare_sampling_config_never_stamps_kimi_key_on_grok_model() {
    use crate::agent::auth_method::{
        CACHED_TOKEN_AUTH_METHOD_ID, HOUSE_API_KEY_ENV_VAR, LEGACY_XAI_API_KEY_ENV_VAR,
        XAI_API_KEY_ENV_VAR,
    };
    use crate::agent::config::{EndpointsConfig, ModelEntry};
    use kigi_test_support::EnvGuard;

    const KIMI_KEY: &str = "kimi-session-secret-DO-NOT-LEAK";

    // No ambient BYOK env key: a grok model with no stored oauth session then
    // resolves to no api_key at all, rather than a global-key fallback that could
    // mask the leak under test.
    let _house = EnvGuard::unset(HOUSE_API_KEY_ENV_VAR);
    let _xai = EnvGuard::unset(XAI_API_KEY_ENV_VAR);
    let _legacy = EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR);

    // Primary: a live Kimi session token under a session-based auth method.
    let agent = build_agent_with_auth(crate::auth::KimiAuth {
        key: KIMI_KEY.to_string(),
        auth_mode: crate::auth::AuthMode::OAuth,
        ..crate::auth::KimiAuth::test_default()
    });
    agent.set_auth_method(acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID));

    let endpoints = EndpointsConfig::default();

    // First-party SUBSCRIPTION model (kimi-code): the primary session key IS its
    // api_key — the byte-identical primary path, and proof the Kimi token is
    // live (so it WOULD leak if mis-routed onto a grok request). This assertion
    // also confirms the session-based primary path is active.
    //
    // This used to use `moonshot-cn/kimi-k2-0905-preview` and assert the SAME
    // thing, which encoded the C1 defect: moonshot-cn is an API-key registry
    // platform on `api.moonshot.cn`, NOT first-party, so "must carry the primary
    // session key" was asserting the leak. `api_key_channel_leak_tests` now pins
    // the opposite for every moonshot entry.
    let mut kimi_model = ModelEntry::fallback("kimi-for-coding", &endpoints);
    kimi_model.info.id = Some("kimi-code/kimi-for-coding".to_string());
    kimi_model.info.base_url = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url.to_string();
    assert!(!kimi_model.has_own_credentials());
    let kimi_cfg = agent.prepare_sampling_config_for_model(&kimi_model, None);
    assert_eq!(
        kimi_cfg.api_key.as_deref(),
        Some(KIMI_KEY),
        "the first-party subscription model must carry the primary session key \
         (primary path unchanged)"
    );

    // xai-grok model (oauth platform): the session token resolves from its OWN
    // pool-backed manager, INDEPENDENT of the Kimi primary — so its api_key can
    // NEVER be the Kimi session key.
    let mut grok_model = ModelEntry::fallback("grok-4-latest", &endpoints);
    grok_model.info.id = Some("xai-grok/grok-4-latest".to_string());
    assert!(!grok_model.has_own_credentials());
    let grok_cfg = agent.prepare_sampling_config_for_model(&grok_model, None);
    assert_ne!(
        grok_cfg.api_key.as_deref(),
        Some(KIMI_KEY),
        "LEAK: a grok model must never carry the primary Kimi session key as api_key"
    );
}
/// Regression: boot-time plugin discovery is deferred past ACP
/// `initialize`, so the shared plugin registry starts empty.
/// `resolve_mcp_servers` reads that snapshot to merge plugin-contributed
/// MCP servers into a new session, so without lazy population the servers
/// silently vanished until an explicit `/plugins reload`.
/// `ensure_plugin_registry` must build the snapshot on first use.
#[tokio::test]
#[serial_test::serial]
async fn ensure_plugin_registry_lazily_populates_snapshot() {
    use crate::agent::config::Config as AgentConfig;
    use crate::auth::{AuthManager, KimiCodeConfig};
    use kigi_test_support::EnvGuard;
    let kigi_home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", kigi_home.path());
    let plugin_dir = tempfile::tempdir().unwrap();
    std::fs::write(
        plugin_dir.path().join("plugin.json"),
        r#"{"name": "regr-lazy-mcp-plugin"}"#,
    )
    .unwrap();
    std::fs::write(
        plugin_dir.path().join(".mcp.json"),
        r#"{"mcpServers":{"regr-srv":{"command":"echo","args":["hi"]}}}"#,
    )
    .unwrap();
    let auth_home = tempfile::tempdir().unwrap();
    let auth_manager =
        std::sync::Arc::new(AuthManager::new(auth_home.path(), KimiCodeConfig::default()));
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    let gateway = GatewaySender::new(tx);
    let mut cfg = AgentConfig::default();
    cfg.plugins.cli_plugin_dirs = vec![plugin_dir.path().to_path_buf()];
    let agent = MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config");
    assert!(
        agent.plugin_registry_handle.snapshot().is_none(),
        "snapshot must start empty (boot discovery deferred past initialize)"
    );
    agent.ensure_plugin_registry();
    let snapshot = agent
        .plugin_registry_handle
        .snapshot()
        .expect("snapshot must be populated on first use");
    assert!(
        snapshot.get("regr-lazy-mcp-plugin").is_some(),
        "lazy discovery must surface the plugin so its MCP server merges into the session"
    );
    agent.ensure_plugin_registry();
    assert!(
        agent
            .plugin_registry_handle
            .snapshot()
            .is_some_and(|s| s.get("regr-lazy-mcp-plugin").is_some()),
        "repeat call must keep the populated snapshot"
    );
}
mod subagent_spawn_context_tests;
/// LEAK guard for the `api_key` channel (C1/C2), through the real
/// `prepare_sampling_config_for_model` resolution path.
mod api_key_channel_leak_tests;
mod chokepoint_leak_tests;
/// No load in flight and no session → the wait returns immediately
/// (the caller then surfaces "unknown session id" exactly as before).
#[tokio::test]
async fn wait_for_in_flight_load_returns_immediately_when_idle() {
    let agent = build_minimal_agent_for_tests();
    let sid = acp::SessionId::new("sess-none");
    tokio::time::timeout(
        std::time::Duration::from_millis(200),
        agent.wait_for_in_flight_session_load(&sid),
    )
    .await
    .expect("wait must not block when no load is in flight");
}
/// A waiter racing an in-flight `session/load` blocks until the load
/// finishes and then observes the registered session. This is the
/// agent-side guarantee that closes the post-leader-crash
/// "unknown session id" race: the reconnect replay's `session/load` and
/// the client's next `session/prompt` can arrive back-to-back.
#[tokio::test]
async fn wait_for_in_flight_load_blocks_until_load_completes() {
    let local = tokio::task::LocalSet::new();
    local
        .run_until(async {
            let agent = std::rc::Rc::new(build_minimal_agent_for_tests());
            let sid = acp::SessionId::new("sess-loading");
            let guard = agent.begin_session_load(&sid);
            let waiter_agent = agent.clone();
            let waiter_sid = sid.clone();
            let waiter = tokio::task::spawn_local(async move {
                waiter_agent
                    .wait_for_in_flight_session_load(&waiter_sid)
                    .await;
                waiter_agent.sessions.borrow().contains_key(&waiter_sid)
            });
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            assert!(!waiter.is_finished(), "waiter must block while loading");
            let handle = make_test_handle("test-model", false, None);
            agent.sessions.borrow_mut().insert(sid.clone(), handle);
            drop(guard);
            let found_session = tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
                .await
                .expect("waiter must wake when the load guard drops")
                .expect("waiter task must not panic");
            assert!(
                found_session,
                "after the wait, the session must be visible to the racing request"
            );
        })
        .await;
}
/// A failed load (guard dropped WITHOUT registering the session) also
/// wakes waiters — they re-check, find nothing, and the caller surfaces
/// the regular "unknown session id" error rather than hanging.
#[tokio::test]
async fn wait_for_in_flight_load_wakes_on_failed_load() {
    let local = tokio::task::LocalSet::new();
    local
        .run_until(async {
            let agent = std::rc::Rc::new(build_minimal_agent_for_tests());
            let sid = acp::SessionId::new("sess-load-fails");
            let guard = agent.begin_session_load(&sid);
            let waiter_agent = agent.clone();
            let waiter_sid = sid.clone();
            let waiter = tokio::task::spawn_local(async move {
                waiter_agent
                    .wait_for_in_flight_session_load(&waiter_sid)
                    .await;
                waiter_agent.sessions.borrow().contains_key(&waiter_sid)
            });
            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
            drop(guard);
            let found_session = tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
                .await
                .expect("waiter must wake when the failed load's guard drops")
                .expect("waiter task must not panic");
            assert!(!found_session, "failed load leaves no session behind");
        })
        .await;
}
/// Two concurrent loads of the same session: the first guard's drop must
/// not remove the second load's marker (waiters keep waiting on the
/// newer in-flight load).
#[tokio::test]
async fn concurrent_load_guards_do_not_clobber_each_other() {
    let agent = build_minimal_agent_for_tests();
    let sid = acp::SessionId::new("sess-concurrent");
    let guard_one = agent.begin_session_load(&sid);
    let guard_two = agent.begin_session_load(&sid);
    drop(guard_one);
    assert!(
        agent.loading_sessions.borrow().contains_key(&sid),
        "second load's marker must survive the first guard's drop"
    );
    drop(guard_two);
    assert!(
        agent.loading_sessions.borrow().is_empty(),
        "all markers removed once every load finished"
    );
}
/// `resident_activity` returns `NeedsInput` whenever the session's
/// pending-interaction map is non-empty — and that wins even over a
/// running turn (a session blocked on a permission mid-turn "needs
/// input"). Clearing the map falls back to Working / Idle.
#[tokio::test]
async fn resident_activity_reports_needs_input_when_pending() {
    use crate::agent::roster::RosterActivity;
    let agent = build_minimal_agent_for_tests();
    let sid = acp::SessionId::new("sess-pending");
    let handle = make_test_handle("kigi-3", false, None);
    let pending = handle.pending_interactions.clone();
    let prompt_id = handle.current_prompt_id.clone();
    agent.sessions.borrow_mut().insert(sid.clone(), handle);
    assert_eq!(agent.resident_activity(&sid), RosterActivity::Idle);
    *prompt_id.lock().unwrap() = Some("turn-1".to_string());
    assert_eq!(agent.resident_activity(&sid), RosterActivity::Working);
    pending.lock().unwrap().insert(
        "call-1".to_string(),
        crate::session::pending_interaction::PendingKind::Permission,
    );
    assert_eq!(agent.resident_activity(&sid), RosterActivity::NeedsInput);
    let entry = agent.resident_roster_entry(&sid).expect("resident entry");
    assert_eq!(entry.activity, RosterActivity::NeedsInput);
    pending.lock().unwrap().clear();
    assert_eq!(agent.resident_activity(&sid), RosterActivity::Working);
}
/// Drain the agent gateway, returning the first `kigi/sessions/changed`
/// payload that carries an upserted entry (ignoring any unrelated
/// notifications, which parse into an empty `RosterChanged`).
fn drain_roster_changed(
    rx: &mut tokio::sync::mpsc::UnboundedReceiver<kigi_acp_lib::AcpClientMessage>,
) -> Option<crate::agent::roster::RosterChanged> {
    let mut found = None;
    while let Ok(msg) = rx.try_recv() {
        if let kigi_acp_lib::AcpClientMessage::ExtNotification(args) = msg {
            if found.is_none()
                && let Ok(changed) = serde_json::from_str::<crate::agent::roster::RosterChanged>(
                    args.request.params.get(),
                )
                && !changed.upserted.is_empty()
            {
                found = Some(changed);
            }
            let _ = args.response_tx.send(Ok(()));
        }
    }
    found
}
/// A turn-boundary activity delta (`push_roster_activity_delta`) broadcasts
/// an `kigi/sessions/changed` upsert carrying the *overridden* activity, so
/// every attached dashboard reflects Working/Idle immediately instead of
/// waiting for the ≤1s roster poll (turn-start/turn-end). The
/// override matters because at turn-start the actor has not yet published
/// `current_prompt_id`, so a natural `resident_activity` read would emit
/// `Idle` for a session that is in fact starting a turn.
#[tokio::test]
async fn push_roster_activity_delta_broadcasts_overridden_activity() {
    use crate::agent::config::Config as AgentConfig;
    use crate::agent::roster::RosterActivity;
    use crate::auth::{AuthManager, KimiCodeConfig};
    let temp_dir = tempfile::tempdir().unwrap();
    let auth_manager =
        std::sync::Arc::new(AuthManager::new(temp_dir.path(), KimiCodeConfig::default()));
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
    let gateway = GatewaySender::new(tx);
    let cfg = AgentConfig::default();
    let agent = MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config");
    let sid = acp::SessionId::new("sess-activity");
    agent
        .sessions
        .borrow_mut()
        .insert(sid.clone(), make_test_handle("kigi-3", false, None));
    agent.push_roster_activity_delta(&sid, RosterActivity::Working);
    let changed = drain_roster_changed(&mut rx).expect("turn-start delta emitted");
    assert_eq!(changed.upserted.len(), 1);
    assert_eq!(changed.upserted[0].session_id, sid.0.to_string());
    assert!(changed.upserted[0].resident);
    assert_eq!(
        changed.upserted[0].activity,
        RosterActivity::Working,
        "forced activity must override the Idle that resident_activity would read"
    );
    assert!(changed.removed.is_empty());
    agent.push_roster_activity_delta(&sid, RosterActivity::Idle);
    let changed = drain_roster_changed(&mut rx).expect("turn-end delta emitted");
    assert_eq!(changed.upserted[0].activity, RosterActivity::Idle);
}
/// Extract the inner payload from an ExtResponse.
#[expect(
    dead_code,
    reason = "unused in production; remove expect when wired or delete the item"
)]
fn parse_ext_body(resp: &acp::ExtResponse) -> serde_json::Value {
    let outer: serde_json::Value =
        serde_json::from_str(resp.0.get()).expect("ExtResponse must be valid JSON");
    outer
        .get("result")
        .cloned()
        .unwrap_or_else(|| panic!("ExtResponse has no 'result' key; full JSON: {outer}"))
}
/// Replicate the lookup logic of code_nav_eligibility_for_request so we
/// can test it with a plain sessions HashMap.
fn check_nav_eligibility_from_sessions(
    sessions: &HashMap<acp::SessionId, crate::session::SessionHandle>,
    session_id: Option<&acp::SessionId>,
) -> Result<(), CodeNavEligibility> {
    let session_id = match session_id {
        Some(sid) => sid,
        None => return Err(CodeNavEligibility::SessionRequired),
    };
    let Some(handle) = sessions.get(session_id) else {
        return Err(CodeNavEligibility::SessionRequired);
    };
    let ct = crate::http::client_type_from_origin(handle.origin_client.as_ref());
    if !matches!(ct, ClientType::KigiWeb) {
        return Err(CodeNavEligibility::ClientNotWeb);
    }
    if !handle.code_nav_enabled {
        return Err(CodeNavEligibility::CapabilityNotAdvertised);
    }
    Ok(())
}
/// Web session with code-nav capability is eligible.
///
/// This is the "happy path" that allows lazy index startup on the first
/// code-nav request.
#[tokio::test]
async fn test_web_session_with_capability_is_eligible() {
    let sid = acp::SessionId::new("sess-web");
    let mut handle = make_test_handle("model", false, Some("kigi-web"));
    handle.code_nav_enabled = true;
    let sessions = [(sid.clone(), handle)].into();
    assert!(
        check_nav_eligibility_from_sessions(&sessions, Some(&sid)).is_ok(),
        "web session with code-nav capability must be eligible"
    );
}
/// TUI session is rejected at gate 1 (client type) regardless of capability.
#[tokio::test]
async fn test_tui_session_is_rejected() {
    let sid = acp::SessionId::new("sess-tui");
    let mut handle = make_test_handle("model", false, Some("kigi-tui"));
    handle.code_nav_enabled = true;
    let sessions = [(sid.clone(), handle)].into();
    assert_eq!(
        check_nav_eligibility_from_sessions(&sessions, Some(&sid)),
        Err(CodeNavEligibility::ClientNotWeb),
        "TUI client must be rejected at gate 1 (client type)"
    );
}
/// Web session without capability is rejected at gate 2.
#[tokio::test]
async fn test_web_session_without_capability_is_rejected() {
    let sid = acp::SessionId::new("sess-web-no-cap");
    let mut handle = make_test_handle("model", false, Some("kigi-web"));
    handle.code_nav_enabled = false;
    let sessions = [(sid.clone(), handle)].into();
    assert_eq!(
        check_nav_eligibility_from_sessions(&sessions, Some(&sid)),
        Err(CodeNavEligibility::CapabilityNotAdvertised),
        "web client without capability must be rejected at gate 2"
    );
}
/// Leader-mode isolation: two sessions with different code-nav state return
/// independent results.
#[tokio::test]
async fn test_leader_mode_two_sessions_stay_isolated() {
    let web_sid = acp::SessionId::new("web");
    let tui_sid = acp::SessionId::new("tui");
    let mut web_handle = make_test_handle("model", false, Some("kigi-web"));
    web_handle.code_nav_enabled = true;
    let mut tui_handle = make_test_handle("model", false, Some("kigi-tui"));
    tui_handle.code_nav_enabled = false;
    let sessions = [(web_sid.clone(), web_handle), (tui_sid.clone(), tui_handle)].into();
    assert!(
        check_nav_eligibility_from_sessions(&sessions, Some(&web_sid)).is_ok(),
        "web session must be eligible"
    );
    assert_eq!(
        check_nav_eligibility_from_sessions(&sessions, Some(&tui_sid)),
        Err(CodeNavEligibility::ClientNotWeb),
        "tui session must remain ineligible even when web session is eligible"
    );
}
/// Unknown session ID returns SessionRequired, not a global fallback.
///
/// This is the stale/evicted session path: a caller with a session ID that
/// no longer exists in the sessions map must get SessionRequired, not
/// accidentally inherit the last-initialized client's eligibility.
#[tokio::test]
async fn test_unknown_session_id_returns_session_required() {
    let known_sid = acp::SessionId::new("known");
    let mut known_handle = make_test_handle("model", false, Some("kigi-web"));
    known_handle.code_nav_enabled = true;
    let sessions = [(known_sid.clone(), known_handle)].into();
    let stale_sid = acp::SessionId::new("stale-or-evicted");
    assert_eq!(
        check_nav_eligibility_from_sessions(&sessions, Some(&stale_sid)),
        Err(CodeNavEligibility::SessionRequired),
        "stale/evicted sessionId must not fall back to global state"
    );
    assert!(check_nav_eligibility_from_sessions(&sessions, Some(&known_sid)).is_ok());
}
mod parse_json_object_env_tests {
    use super::parse_json_object_env;
    unsafe fn set(k: &str, v: &str) {
        unsafe { std::env::set_var(k, v) };
    }
    unsafe fn unset(k: &str) {
        unsafe { std::env::remove_var(k) };
    }
    #[test]
    #[serial_test::serial]
    fn valid_json_object_returns_some() {
        unsafe { set("TEST_JSON_OBJ", r#"{"team":"platform","org":"acme"}"#) };
        let result = parse_json_object_env("TEST_JSON_OBJ");
        unsafe { unset("TEST_JSON_OBJ") };
        let val = result.expect("should parse valid JSON object");
        assert_eq!(val["team"], "platform");
        assert_eq!(val["org"], "acme");
    }
    #[test]
    #[serial_test::serial]
    fn non_object_json_returns_none() {
        unsafe { set("TEST_JSON_ARR", r#"["not","an","object"]"#) };
        let result = parse_json_object_env("TEST_JSON_ARR");
        unsafe { unset("TEST_JSON_ARR") };
        assert!(result.is_none());
    }
    #[test]
    #[serial_test::serial]
    fn invalid_json_returns_none() {
        unsafe { set("TEST_JSON_BAD", "not json at all") };
        let result = parse_json_object_env("TEST_JSON_BAD");
        unsafe { unset("TEST_JSON_BAD") };
        assert!(result.is_none());
    }
    #[test]
    #[serial_test::serial]
    fn unset_var_returns_none() {
        unsafe { unset("TEST_JSON_UNSET") };
        assert!(parse_json_object_env("TEST_JSON_UNSET").is_none());
    }
}
mod eligibility_gates {
    use super::*;
    /// Standalone replica of the first three eligibility gates.
    /// Gate 4 (git root) requires a real filesystem and is covered by
    /// integration tests.
    fn check_gates(
        client_type: ClientType,
        code_nav_enabled: bool,
        indexing_enabled: bool,
    ) -> Result<(), CodeNavEligibility> {
        if !matches!(client_type, ClientType::KigiWeb) {
            return Err(CodeNavEligibility::ClientNotWeb);
        }
        if !code_nav_enabled {
            return Err(CodeNavEligibility::CapabilityNotAdvertised);
        }
        if !indexing_enabled {
            return Err(CodeNavEligibility::DisabledByConfig);
        }
        Ok(())
    }
    #[test]
    fn non_web_client_rejected() {
        assert_eq!(
            check_gates(ClientType::Generic, true, true),
            Err(CodeNavEligibility::ClientNotWeb)
        );
    }
    #[test]
    fn tui_client_rejected() {
        assert_eq!(
            check_gates(ClientType::KigiTUI, true, true),
            Err(CodeNavEligibility::ClientNotWeb)
        );
    }
    #[test]
    fn web_client_no_capability_rejected() {
        assert_eq!(
            check_gates(ClientType::KigiWeb, false, true),
            Err(CodeNavEligibility::CapabilityNotAdvertised)
        );
    }
    #[test]
    fn web_client_with_capability_config_disabled_rejected() {
        assert_eq!(
            check_gates(ClientType::KigiWeb, true, false),
            Err(CodeNavEligibility::DisabledByConfig)
        );
    }
    #[test]
    fn web_client_with_capability_and_config_passes_first_three_gates() {
        assert!(check_gates(ClientType::KigiWeb, true, true).is_ok());
    }
}
#[test]
fn find_model_by_id_prefers_key_then_falls_back_to_slug() {
    let entry = |model: &str| ModelEntry {
        info: config::ModelInfo {
            user_selectable: true,
            id: None,
            model: model.to_string(),
            base_url: String::new(),
            name: None,
            description: None,
            max_completion_tokens: None,
            temperature: None,
            top_p: None,
            api_backend: crate::sampling::ApiBackend::default(),
            auth_scheme: Default::default(),
            extra_headers: IndexMap::new(),
            context_window: std::num::NonZeroU64::new(200_000).unwrap(),
            auto_compact_threshold_percent: None,
            system_prompt_label: None,
            use_concise: false,
            agent_type: config::default_agent_type(),
            inference_idle_timeout_secs: None,
            max_retries: None,
            hidden: false,
            supported_in_api: true,
            reasoning_effort: None,
            supports_reasoning_effort: false,
            reasoning_efforts: Vec::new(),
            capabilities: Vec::new(),
            supports_backend_search: false,
            compactions_remaining: None,
            compaction_at_tokens: None,
            show_model_fingerprint: false,
            stream_tool_calls: None,
            laziness_detector: crate::agent::config::LazinessDetectorPerModelConfig::default(),
        },
        api_key: None,
        env_key: None,
        api_base_url: None,
    };
    let mut models = indexmap::IndexMap::new();
    models.insert("a".to_string(), entry("target"));
    models.insert("target".to_string(), entry("other"));
    assert_eq!(
        config::find_model_by_id(&models, "target").unwrap().model,
        "other",
        "key match should win over slug scan"
    );
    assert_eq!(
        config::find_model_by_id(&models, "a").unwrap().model,
        "target",
        "exact key match for 'a'"
    );
}
fn write_updates(dir: &std::path::Path, lines: &[&str]) -> PathBuf {
    let path = dir.join("updates.jsonl");
    std::fs::write(&path, lines.join("\n")).unwrap();
    path
}
fn bg_line(task_id: &str) -> String {
    format!(
        r#"{{"timestamp":1,"method":"_kigi/session/update","params":{{"sessionId":"s","update":{{"sessionUpdate":"task_backgrounded","task_id":"{task_id}","command":"sleep 99","cwd":"/tmp"}}}}}}"#
    )
}
fn completed_line(task_id: &str) -> String {
    format!(
        r#"{{"timestamp":2,"method":"_kigi/session/update","params":{{"sessionId":"s","update":{{"sessionUpdate":"task_completed","task_snapshot":{{"task_id":"{task_id}","completed":true}}}}}}}}"#
    )
}
fn orphaned_ids(tasks: &[OrphanedTask]) -> std::collections::HashSet<&str> {
    tasks.iter().map(|t| t.task_id.as_str()).collect()
}
#[test]
fn orphaned_tasks_returns_empty_for_no_file() {
    let result = MvpAgent::find_orphaned_background_tasks(&None);
    assert!(result.is_empty());
}
#[test]
fn orphaned_tasks_returns_empty_for_missing_file() {
    let path = PathBuf::from("/nonexistent/updates.jsonl");
    let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
    assert!(result.is_empty());
}
#[test]
fn orphaned_tasks_returns_empty_when_all_completed() {
    let tmp = tempfile::tempdir().unwrap();
    let bg = bg_line("t1");
    let done = completed_line("t1");
    let path = write_updates(tmp.path(), &[&bg, &done]);
    let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
    assert!(result.is_empty());
}
#[test]
fn orphaned_tasks_returns_uncompleted() {
    let tmp = tempfile::tempdir().unwrap();
    let bg1 = bg_line("t1");
    let bg2 = bg_line("t2");
    let done1 = completed_line("t1");
    let path = write_updates(tmp.path(), &[&bg1, &bg2, &done1]);
    let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
    let ids = orphaned_ids(&result);
    assert_eq!(ids.len(), 1);
    assert!(ids.contains("t2"));
}
#[test]
fn orphaned_tasks_returns_multiple_uncompleted() {
    let tmp = tempfile::tempdir().unwrap();
    let bg1 = bg_line("t1");
    let bg2 = bg_line("t2");
    let bg3 = bg_line("t3");
    let done2 = completed_line("t2");
    let path = write_updates(tmp.path(), &[&bg1, &bg2, &bg3, &done2]);
    let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
    let ids = orphaned_ids(&result);
    assert_eq!(ids.len(), 2);
    assert!(ids.contains("t1"));
    assert!(ids.contains("t3"));
}
#[test]
fn orphaned_tasks_captures_command_and_cwd() {
    let tmp = tempfile::tempdir().unwrap();
    let bg = bg_line("t1");
    let path = write_updates(tmp.path(), &[&bg]);
    let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
    assert_eq!(result.len(), 1);
    assert_eq!(result[0].command, "sleep 99");
    assert_eq!(result[0].cwd, "/tmp");
}
#[test]
fn orphaned_tasks_skips_malformed_lines() {
    let tmp = tempfile::tempdir().unwrap();
    let bg = bg_line("t1");
    let path = write_updates(tmp.path(), &["not json", &bg, "{}"]);
    let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
    assert_eq!(result.len(), 1);
}
#[test]
fn orphaned_tasks_ignores_unrelated_updates() {
    let tmp = tempfile::tempdir().unwrap();
    let bg = bg_line("t1");
    let unrelated = r#"{"timestamp":1,"method":"_kigi/session/update","params":{"sessionId":"s","update":{"sessionUpdate":"auto_compact_started","percentage":80}}}"#;
    let path = write_updates(tmp.path(), &[&bg, unrelated]);
    let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
    assert_eq!(result.len(), 1);
}
#[test]
fn orphaned_tasks_filters_rewind_dead_branches() {
    let tmp = tempfile::tempdir().unwrap();
    let user_msg = r#"{"timestamp":0,"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hello"}}}}"#;
    let bg_before_rewind = bg_line("t-dead");
    let rewind = r#"{"timestamp":3,"method":"_kigi/session/update","params":{"sessionId":"s","update":{"sessionUpdate":"rewind_marker","target_prompt_index":0,"created_at":"2025-01-01T00:00:00Z"}}}"#;
    let user_msg2 = r#"{"timestamp":4,"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"retry"}}}}"#;
    let bg_after_rewind = bg_line("t-alive");
    let path = write_updates(
        tmp.path(),
        &[
            user_msg,
            &bg_before_rewind,
            rewind,
            user_msg2,
            &bg_after_rewind,
        ],
    );
    let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
    let ids = orphaned_ids(&result);
    assert!(
        ids.contains("t-alive"),
        "task after rewind should be present"
    );
    assert!(
        !ids.contains("t-dead"),
        "task in dead branch should be filtered"
    );
}
#[test]
fn on_demand_enabled_from_remote_settings() {
    let json = serde_json::json!({ "on_demand_enabled" : false });
    let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
    assert_eq!(rs.on_demand_enabled, Some(false));
    let json = serde_json::json!({});
    let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
    assert_eq!(rs.on_demand_enabled, None);
}
/// Regression for a 401 sequence seen in production. After a long idle
/// window, the auth manager may have no
/// live token by the time `session/new` runs. For session-based auth methods
/// we MUST still report `SessionToken` so chat_state credentials retain the
/// session-token shape and `try_refresh_session_token` will run on the next
/// prompt instead of early-returning.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_session_based_no_current_returns_session_token() {
    for method_id in [
        crate::agent::auth_method::CACHED_TOKEN_AUTH_METHOD_ID,
        crate::agent::auth_method::KIMI_CODE_METHOD_ID,
    ] {
        let agent = build_minimal_agent_for_tests();
        agent.set_auth_method(acp::AuthMethodId::new(method_id));
        assert!(
            agent.auth_manager.current().is_none(),
            "{method_id}: precondition: AuthManager has no current token",
        );
        assert_eq!(
            agent.auth_type(),
            kigi_chat_state::AuthType::SessionToken,
            "{method_id}: session-based auth must report SessionToken even \
                 without a live token -- otherwise chat_state gets locked into \
                 auth_type = ApiKey and try_refresh_session_token will skip \
                 every subsequent refresh attempt.",
        );
    }
}
/// BYOK guard. Users with `xai.api_key` must continue to report `ApiKey`
/// regardless of live-token state -- BYOK sessions have nothing to refresh,
/// and reporting `SessionToken` would route through cli-chat-proxy paths
/// that don't apply to BYOK keys.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_xai_api_key_no_current_returns_api_key() {
    let agent = build_minimal_agent_for_tests();
    agent.set_auth_method(acp::AuthMethodId::new(
        crate::agent::auth_method::XAI_API_KEY_METHOD_ID,
    ));
    assert!(agent.auth_manager.current().is_none());
    assert_eq!(
        agent.auth_type(),
        kigi_chat_state::AuthType::ApiKey,
        "xai.api_key auth must report ApiKey -- BYOK has no session-token \
             behavior to fall back to."
    );
}
/// Positive baseline: when both signals agree (session-based method AND
/// a live in-memory token), `SessionToken` is returned. This is the
/// common case during a healthy session.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_session_based_with_current_returns_session_token() {
    use crate::auth::KimiAuth;
    let agent = build_minimal_agent_for_tests();
    agent.set_auth_method(acp::AuthMethodId::new(
        crate::agent::auth_method::KIMI_CODE_METHOD_ID,
    ));
    agent.auth_manager.hot_swap(KimiAuth::test_default());
    assert!(agent.auth_manager.current().is_some());
    assert_eq!(agent.auth_type(), kigi_chat_state::AuthType::SessionToken,);
}
/// Defensive case: no `auth_method_id` selected yet (pre-`authenticate`
/// state) and no live credential. We default to `ApiKey` so callers
/// that key off this value (e.g. `resolve_chat_state_auth_type` for chat
/// routing) don't accidentally route session-token-shaped traffic
/// through cli-chat-proxy before a method has been chosen.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_no_method_id_no_current_returns_api_key() {
    let agent = build_minimal_agent_for_tests();
    assert!(agent.auth_method_id.load().is_none());
    assert!(agent.auth_manager.current().is_none());
    assert_eq!(agent.auth_type(), kigi_chat_state::AuthType::ApiKey,);
}
/// Live credential present but `auth_method_id` is still `None`. The
/// in-memory bearer takes precedence: this is the order observed during
/// `initialize()` silent refresh -- a token is hot-swapped in before
/// `authenticate()` writes the method id. Reporting `SessionToken`
/// here matches pre-fix behavior and keeps logging stable.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_no_method_id_with_current_returns_session_token() {
    use crate::auth::KimiAuth;
    let agent = build_minimal_agent_for_tests();
    agent.auth_manager.hot_swap(KimiAuth::test_default());
    assert!(agent.auth_method_id.load().is_none());
    assert!(agent.auth_manager.current().is_some());
    assert_eq!(agent.auth_type(), kigi_chat_state::AuthType::SessionToken,);
}
/// Deployment-key / managed-config user: `XAI_API_KEY` resolves and the kill
/// switch is off, so a dead `cached_token` MUST fall through to `xai.api_key`
/// (no browser). This is the exact regression the fallthrough fixes.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn cached_token_fallthrough_prefers_api_key_for_deployment_key() {
    use crate::agent::auth_method::{XAI_API_KEY_ENV_VAR, XAI_API_KEY_METHOD_ID};
    use kigi_test_support::EnvGuard;
    let _lockdown = EnvGuard::unset("KIGI_DISABLE_API_KEY_AUTH");
    let _key = EnvGuard::set(XAI_API_KEY_ENV_VAR, "test-deployment-key");
    let agent = build_minimal_agent_for_tests();
    assert_eq!(
        agent.cached_token_fallthrough_method_id().0.as_ref(),
        XAI_API_KEY_METHOD_ID,
        "deployment-key user (XAI_API_KEY set, no kill switch) must fall \
         through to xai.api_key on a dead cached_token -- not interactive login",
    );
}
/// No advertiseable credentials at all (no env key, no kill switch): the user
/// genuinely needs to log in, so the fallthrough is interactive `kimi-code`.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn cached_token_fallthrough_falls_to_kigi_com_without_credentials() {
    use crate::agent::auth_method::{
        HOUSE_API_KEY_ENV_VAR, KIMI_CODE_METHOD_ID, LEGACY_XAI_API_KEY_ENV_VAR,
        XAI_API_KEY_ENV_VAR,
    };
    use kigi_test_support::EnvGuard;
    let _lockdown = EnvGuard::unset("KIGI_DISABLE_API_KEY_AUTH");
    let _house = EnvGuard::unset(HOUSE_API_KEY_ENV_VAR);
    let _new = EnvGuard::unset(XAI_API_KEY_ENV_VAR);
    let _legacy = EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR);
    let agent = build_minimal_agent_for_tests();
    assert_eq!(
        agent.cached_token_fallthrough_method_id().0.as_ref(),
        KIMI_CODE_METHOD_ID,
        "no API-key creds and no kill switch -> interactive kimi.com login",
    );
}
/// `parse_session_kind` routes `session/load` to the gateway Chat path vs. the
/// disk-backed Build path. Anything but an explicit `kind: "chat"` is Build.
#[test]
fn parse_session_kind_matrix() {
    use crate::session::unified_list::SessionKind;
    use serde_json::json;
    let cases: &[(&str, serde_json::Value, SessionKind)] = &[
        (
            "chat",
            json!({ "kigi/session" : { "kind" : "chat" } }),
            SessionKind::Chat,
        ),
        (
            "build",
            json!({ "kigi/session" : { "kind" : "build" } }),
            SessionKind::Build,
        ),
        (
            "chat_malformed_sibling",
            json!({ "kigi/session" : { "kind" : "chat", "facets" : "not-a-map" } }),
            SessionKind::Chat,
        ),
        (
            "unknown_kind",
            json!({ "kigi/session" : { "kind" : "frob" } }),
            SessionKind::Build,
        ),
        ("absent", json!({}), SessionKind::Build),
    ];
    for (label, meta, expected) in cases {
        assert_eq!(parse_session_kind(meta.as_object()), *expected, "[{label}]");
    }
    assert_eq!(parse_session_kind(None), SessionKind::Build, "[none]");
}
#[test]
fn chat_initial_model_matrix() {
    let cases: &[(&str, bool, Option<&str>, Option<&str>)] = &[
        ("chat_with_model", true, Some("kigi-4.5"), Some("kigi-4.5")),
        ("chat_without_model", true, None, None),
        ("build_with_model", false, Some("kigi-4.5"), None),
        ("build_without_model", false, None, None),
    ];
    for (label, is_chat_kind, custom_model_id, expected) in cases {
        assert_eq!(
            chat_initial_model(*is_chat_kind, *custom_model_id).as_deref(),
            *expected,
            "[{label}]"
        );
    }
}
#[test]
fn chat_new_session_model_state_matrix() {
    fn state_with(current: &str, available: &[&str]) -> acp::SessionModelState {
        acp::SessionModelState::new(
            acp::ModelId::new(current.to_owned()),
            available
                .iter()
                .map(|id| {
                    acp::ModelInfo::new(acp::ModelId::new((*id).to_owned()), (*id).to_owned())
                })
                .collect(),
        )
    }
    let cases: &[(&str, acp::SessionModelState, Option<&str>, &str)] = &[
        (
            "requested_in_catalog",
            state_with("auto", &["auto", "kigi-4"]),
            Some("kigi-4"),
            "kigi-4",
        ),
        (
            "no_request_keeps_catalog_default",
            state_with("auto", &["auto", "kigi-4"]),
            None,
            "auto",
        ),
        (
            "requested_not_in_catalog",
            state_with("auto", &["auto"]),
            Some("kigi-4.5"),
            "kigi-4.5",
        ),
        (
            "requested_with_empty_catalog",
            state_with("", &[]),
            Some("kigi-4"),
            "kigi-4",
        ),
    ];
    for (label, state, requested, expected) in cases {
        let out = chat_new_session_model_state(state.clone(), requested.map(str::to_owned));
        assert_eq!(out.current_model_id.0.as_ref(), *expected, "[{label}]");
        assert_eq!(
            out.available_models.len(),
            state.available_models.len(),
            "[{label}] override must not mutate the catalog"
        );
    }
}
/// Session-actor tests use `tokio::task::spawn_local`, so run the body
/// inside a current-thread `LocalSet`.
fn run_local_for_bridge_test<F, Fut, T>(body: F) -> T
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = T>,
{
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("test runtime must build");
    let local = tokio::task::LocalSet::new();
    local.block_on(&rt, body())
}
#[test]
fn chat_session_spawn_options_matches_thin_profile() {
    let sid = acp::SessionId::new(std::sync::Arc::from("00000000-0000-0000-0000-000000000099"));
    let cwd = kigi_paths::AbsPathBuf::new(std::env::temp_dir()).expect("temp cwd");
    let opts = chat_session_spawn_options(
        SessionInfo {
            id: sid,
            cwd: cwd.as_str().to_owned(),
        },
        cwd,
        None,
        None,
        acp::ModelId::new(std::sync::Arc::from("test-model")),
        false,
    );
    assert!(opts.mcp_servers.is_empty());
    assert!(opts.initial_client_mcp_servers.is_empty());
    assert!(!opts.client_code_nav_enabled);
    assert!(!opts.client_terminal);
    assert!(!opts.client_fs_read);
    assert!(!opts.client_fs_write);
    assert!(opts.chat_history.is_empty());
    assert!(!opts.session_auto_mode);
    assert!(
        opts.persistence.is_noop(),
        "K10 thin profile must use PersistenceHandle::noop()"
    );
}
/// Without a bridge, `ext_method` falls through to the unchanged local
/// dispatch (`rewind::handle`), which reports the missing session — proving
/// the routing hook is skipped in local mode.
#[test]
fn ext_method_rewind_uses_local_dispatch_without_bridge() {
    use acp::Agent as _;
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let params = serde_json::json!({ "sessionId" : "sess-local" });
        let err = agent
            .ext_method(acp::ExtRequest::new(
                "kigi/rewind/points",
                std::sync::Arc::from(serde_json::value::to_raw_value(&params).unwrap()),
            ))
            .await
            .expect_err("local rewind with no session must error");
        assert_eq!(err.code, acp::Error::resource_not_found(None).code);
    });
}
#[test]
fn cancel_does_not_forward_to_bridge_in_local_mode() {
    use crate::session::SessionCommand;
    use acp::Agent as _;
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-cancel-local");
        let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent
            .cancel(acp::CancelNotification::new(sid.clone()))
            .await
            .expect("cancel must succeed");
        let mut saw_local_cancel = false;
        while let Ok(cmd) = cmd_rx.try_recv() {
            if let SessionCommand::Cancel { .. } = cmd {
                saw_local_cancel = true;
            }
        }
        assert!(
            saw_local_cancel,
            "local-mode cancel dispatches the local SessionCommand::Cancel with no bridge attached"
        );
    });
}
use crate::session::SessionCommand as TestSessionCommand;
/// Build a session handle wired to a *live* command channel. Returns the
/// handle (move into `sessions`) plus a probe `cmd_tx`/`cmd_rx` so a test
/// can observe what the agent sends to the actor and prove the channel is
/// live.
fn make_live_session_handle(
    sid: &acp::SessionId,
    running_prompt: Option<&str>,
) -> (
    crate::session::SessionHandle,
    tokio::sync::mpsc::UnboundedSender<TestSessionCommand>,
    tokio::sync::mpsc::UnboundedReceiver<TestSessionCommand>,
) {
    let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
    let mut handle = make_test_handle("test-model", false, Some("kigi-tui"));
    handle.cmd_tx = cmd_tx.clone();
    handle.info = crate::session::info::Info {
        id: sid.clone(),
        cwd: "/tmp".to_string(),
    };
    if let Some(pid) = running_prompt {
        *handle.current_prompt_id.lock().unwrap() = Some(pid.to_string());
    }
    (handle, cmd_tx, cmd_rx)
}
/// Spawn a minimal fake session actor on the `LocalSet` that answers
/// `SessionCommand::IsBusy` with `busy` and forwards every other command to
/// the returned receiver so a test can assert on them (e.g. `Shutdown`).
fn spawn_fake_actor(
    mut cmd_rx: tokio::sync::mpsc::UnboundedReceiver<TestSessionCommand>,
    busy: bool,
) -> tokio::sync::mpsc::UnboundedReceiver<TestSessionCommand> {
    let (observed_tx, observed_rx) = tokio::sync::mpsc::unbounded_channel();
    tokio::task::spawn_local(async move {
        while let Some(cmd) = cmd_rx.recv().await {
            match cmd {
                TestSessionCommand::IsBusy { respond_to } => {
                    let _ = respond_to.send(busy);
                }
                other => {
                    let _ = observed_tx.send(other);
                }
            }
        }
    });
    observed_rx
}
/// Drive `kigi/internal/evict_sessions` through the real `ext_notification`
/// handler path (not the internal helper) — matches how the leader server
/// signals a client disconnect.
async fn drive_disconnect(agent: &MvpAgent, sid: &acp::SessionId) {
    drive_disconnect_many(agent, &[sid]).await;
}
/// Like `drive_disconnect`, but evicts several sessions in a single
/// `kigi/internal/evict_sessions` notification — the realistic shape of a
/// real client disconnect, and the path that exercises `handle_evict_sessions`'
/// concurrent `join_all` check pass followed by the sequential act pass.
async fn drive_disconnect_many(agent: &MvpAgent, sids: &[&acp::SessionId]) {
    use acp::Agent as _;
    let ids: Vec<&str> = sids.iter().map(|s| s.0.as_ref()).collect();
    let params = serde_json::json!({ "sessionIds" : ids });
    let raw = serde_json::value::to_raw_value(&params).unwrap();
    agent
        .ext_notification(acp::ExtNotification::new(
            "kigi/internal/evict_sessions",
            raw.into(),
        ))
        .await
        .expect("evict_sessions notification must be handled");
}
/// Drive `kigi/session/close` through the real `ext_method` dispatch
/// (`ext_method` → `handlers::session::handle` → `handle_session_close`),
/// exercising the exact production path that finalizes the replica.
async fn drive_close(agent: &MvpAgent, session_id: &str) -> Result<acp::ExtResponse, acp::Error> {
    use acp::Agent as _;
    let params = serde_json::json!({ "sessionId" : session_id });
    let raw = serde_json::value::to_raw_value(&params).unwrap();
    agent
        .ext_method(acp::ExtRequest::new(
            "kigi/session/close",
            std::sync::Arc::from(raw),
        ))
        .await
}
/// No-evict keystone: a client disconnecting mid-turn must NOT destroy the
/// session. The actor stays resident, no `Shutdown` is sent, the resident
/// session's command channel still **delivers** commands (so a reconnecting
/// `session/load` can keep driving the turn), and `finalize()` is NOT called
/// on a mere disconnect.
#[test]
fn disconnect_keeps_live_session_resident_without_finalize() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-live");
        let (_cmd_tx, mut cmd_rx) = {
            let (handle, tx, rx) = make_live_session_handle(&sid, Some("turn-1"));
            agent.sessions.borrow_mut().insert(sid.clone(), handle);
            (tx, rx)
        };
        drive_disconnect(&agent, &sid).await;
        assert!(
            agent.sessions.borrow().contains_key(&sid),
            "live session must stay resident across client disconnect"
        );
        assert!(
            matches!(
                cmd_rx.try_recv(),
                Err(tokio::sync::mpsc::error::TryRecvError::Empty)
            ),
            "no command may be sent to a session kept resident with live work"
        );
        let resident = agent
            .sessions
            .borrow()
            .get(&sid)
            .cloned()
            .expect("session must still be resident");
        resident
            .cmd_tx
            .send(TestSessionCommand::ResetPermissionState)
            .expect("resident session channel must accept commands post-disconnect");
        assert!(
            matches!(
                cmd_rx.try_recv(),
                Ok(TestSessionCommand::ResetPermissionState)
            ),
            "the resident session's receiver must observe the delivered command"
        );
        assert!(
            agent.finalize_spy.borrow().is_empty(),
            "finalize() must NOT fire on client disconnect"
        );
        assert_eq!(
            agent.session_live_state_for(&sid),
            Some(SessionLiveState::Working),
            "a kept-resident session with live work is Working"
        );
    });
}
/// Keep-resident must hold even if the `current_prompt_id` lock is poisoned:
/// an unknown state is treated as "busy" (never unload). Guards against a
/// regression flipping the `unwrap_or(true)` fallback to `false`.
#[test]
fn disconnect_keeps_resident_on_poisoned_lock() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-poison");
        let (handle, _tx, _rx) = make_live_session_handle(&sid, None);
        let poison_target = handle.current_prompt_id.clone();
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        let _ = std::thread::spawn(move || {
            let _g = poison_target.lock().unwrap();
            panic!("poison current_prompt_id");
        })
        .join();
        assert!(
            agent
                .sessions
                .borrow()
                .get(&sid)
                .unwrap()
                .current_prompt_id
                .lock()
                .is_err(),
            "precondition: the lock must be poisoned"
        );
        drive_disconnect(&agent, &sid).await;
        assert!(
            agent.sessions.borrow().contains_key(&sid),
            "a session with an unknown (poisoned) state must be kept resident"
        );
        assert_eq!(
            agent.session_live_state_for(&sid),
            Some(SessionLiveState::Working),
        );
    });
}
/// Idle-unload stub (memory bound) + supervisor interaction: a *fully idle*
/// session is unloaded to disk on disconnect (actor `Shutdown`, handle
/// dropped) while the `SessionThread` is **retained** for
/// `drain_old_session_thread`. It is not finalized, and once the kept thread
/// finishes the supervisor reaps it as a *clean* exit — never `DeadFailed`.
#[test]
fn disconnect_unloads_idle_session_without_finalize() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-idle");
        let (handle, _cmd_tx, cmd_rx) = make_live_session_handle(&sid, None);
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        let mut observed = spawn_fake_actor(cmd_rx, false);
        let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
        agent.session_threads.borrow_mut().insert(
            sid.clone(),
            crate::session::SessionThread::from_handle(std::thread::spawn(move || {
                let _ = release_rx.recv();
            })),
        );
        agent.ensure_session_supervisor();
        drive_disconnect(&agent, &sid).await;
        assert!(
            !agent.sessions.borrow().contains_key(&sid),
            "idle session must be unloaded from the resident map on disconnect"
        );
        assert!(
            agent.session_threads.borrow().contains_key(&sid),
            "idle-unload must keep the SessionThread for reconnect drain"
        );
        let shutdown = tokio::time::timeout(std::time::Duration::from_secs(1), observed.recv())
            .await
            .expect("idle-unload must send a command within 1s")
            .expect("fake actor channel must stay open");
        assert!(
            matches!(shutdown, TestSessionCommand::Shutdown),
            "idle-unload must send SessionCommand::Shutdown"
        );
        assert!(
            agent.finalize_spy.borrow().is_empty(),
            "idle-unload on disconnect must NOT finalize the cloud replica"
        );
        assert_eq!(
            agent.session_live_state_for(&sid),
            Some(SessionLiveState::Dormant),
            "an idle-unloaded session demotes to Dormant"
        );
        drop(release_tx);
        let deadline = tokio::time::Instant::now() + (SESSION_SUPERVISOR_TICK * 6);
        while tokio::time::Instant::now() < deadline {
            if !agent.session_threads.borrow().contains_key(&sid) {
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
        }
        assert!(
            !agent.session_threads.borrow().contains_key(&sid),
            "supervisor must drop the finished kept thread"
        );
        assert!(
            !agent
                .roster_delta_spy
                .borrow()
                .iter()
                .any(|(id, st)| id == sid.0.as_ref() && *st == SessionLiveState::DeadFailed),
            "a cleanly idle-unloaded session must not be reaped as DeadFailed"
        );
        assert_eq!(
            agent.session_live_state_for(&sid),
            None,
            "clean-exit sweep must drop the Dormant live-state entry"
        );
    });
}
/// The `IsBusy` keep-resident path. A between-turns session
/// (`current_prompt_id = None`) whose actor answers `IsBusy = true` (queued
/// inputs at the turn boundary) must be kept resident — NOT unloaded — and
/// must receive no `Shutdown`. This exercises the async round-trip that the
/// sync fast-path tests skip.
#[test]
fn disconnect_keeps_resident_when_actor_reports_busy() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-busy");
        let (handle, _cmd_tx, cmd_rx) = make_live_session_handle(&sid, None);
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        let mut observed = spawn_fake_actor(cmd_rx, true);
        drive_disconnect(&agent, &sid).await;
        assert!(
            agent.sessions.borrow().contains_key(&sid),
            "a between-turns session with queued work (IsBusy=true) must stay resident"
        );
        assert_eq!(
            agent.session_live_state_for(&sid),
            Some(SessionLiveState::Working),
            "an actor-reported-busy session is kept Working"
        );
        tokio::task::yield_now().await;
        assert!(
            matches!(
                observed.try_recv(),
                Err(tokio::sync::mpsc::error::TryRecvError::Empty)
            ),
            "a busy session must not be sent Shutdown"
        );
    });
}
/// A between-turns session whose ONLY outstanding work is a parked
/// `PlanApproval` reverse-request (the resume re-park) must be kept resident on
/// disconnect. The actor answers `IsBusy = false`, so the keep-resident outcome
/// can come ONLY from the parked-approval sync fast path in `session_has_live_work`
/// — deleting that check would let this session unload (mutation-killing).
#[test]
fn disconnect_keeps_resident_when_plan_approval_parked() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-plan-parked");
        let (handle, _cmd_tx, cmd_rx) = make_live_session_handle(&sid, None);
        handle.pending_interactions.lock().unwrap().insert(
            "exit-plan-mode-resume".to_string(),
            crate::session::pending_interaction::PendingKind::PlanApproval,
        );
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        let mut observed = spawn_fake_actor(cmd_rx, false);
        drive_disconnect(&agent, &sid).await;
        assert!(
            agent.sessions.borrow().contains_key(&sid),
            "a session with a parked plan-approval must stay resident"
        );
        assert_eq!(
            agent.session_live_state_for(&sid),
            Some(SessionLiveState::Working),
            "a parked-approval session is kept Working"
        );
        tokio::task::yield_now().await;
        assert!(
            matches!(
                observed.try_recv(),
                Err(tokio::sync::mpsc::error::TryRecvError::Empty)
            ),
            "a parked-approval session must not be sent Shutdown"
        );
    });
}
/// Mixed batch in a *single* `kigi/internal/evict_sessions` notification —
/// the realistic disconnect shape and the path that exercises
/// `handle_evict_sessions`' `join_all` two-pass (concurrent `IsBusy` checks,
/// then sequential act). One session's actor reports busy (→ kept resident,
/// `Working`, no `Shutdown`); the other is idle (→ unloaded, `Dormant`,
/// `Shutdown` sent). Each must get its own outcome with no cross-contamination
/// between the concurrent check pass and the sequential act pass.
#[test]
fn disconnect_mixed_batch_keeps_busy_unloads_idle() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid_busy = acp::SessionId::new("sess-batch-busy");
        let sid_idle = acp::SessionId::new("sess-batch-idle");
        let (busy_handle, _busy_tx, busy_rx) = make_live_session_handle(&sid_busy, None);
        let (idle_handle, _idle_tx, idle_rx) = make_live_session_handle(&sid_idle, None);
        agent
            .sessions
            .borrow_mut()
            .insert(sid_busy.clone(), busy_handle);
        agent
            .sessions
            .borrow_mut()
            .insert(sid_idle.clone(), idle_handle);
        let mut busy_observed = spawn_fake_actor(busy_rx, true);
        let mut idle_observed = spawn_fake_actor(idle_rx, false);
        drive_disconnect_many(&agent, &[&sid_busy, &sid_idle]).await;
        assert!(
            agent.sessions.borrow().contains_key(&sid_busy),
            "the busy session in the batch must stay resident"
        );
        assert_eq!(
            agent.session_live_state_for(&sid_busy),
            Some(SessionLiveState::Working),
            "the busy session must be Working"
        );
        assert!(
            !agent.sessions.borrow().contains_key(&sid_idle),
            "the idle session in the batch must be unloaded"
        );
        assert_eq!(
            agent.session_live_state_for(&sid_idle),
            Some(SessionLiveState::Dormant),
            "the idle session must be Dormant"
        );
        let idle_shutdown =
            tokio::time::timeout(std::time::Duration::from_secs(1), idle_observed.recv())
                .await
                .expect("idle session must receive a command within 1s")
                .expect("fake actor channel must stay open");
        assert!(
            matches!(idle_shutdown, TestSessionCommand::Shutdown),
            "the idle session must be sent Shutdown"
        );
        tokio::task::yield_now().await;
        assert!(
            matches!(
                busy_observed.try_recv(),
                Err(tokio::sync::mpsc::error::TryRecvError::Empty)
            ),
            "the busy session must not be sent Shutdown in a mixed batch"
        );
        assert!(
            agent.finalize_spy.borrow().is_empty(),
            "neither batch outcome may finalize on a mere disconnect"
        );
    });
}
/// The bounded `session_live_state` map does not grow without bound
/// across repeated create/close cycles — every terminal close drops its
/// entry, so the map size stays at the live count, not the cumulative count.
#[test]
fn session_live_state_map_is_bounded_across_cycles() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        for i in 0..50 {
            let sid = acp::SessionId::new(format!("sess-cycle-{i}"));
            let (handle, _tx, _rx) = make_live_session_handle(&sid, Some("turn"));
            agent.sessions.borrow_mut().insert(sid.clone(), handle);
            agent.set_session_live_state(&sid, SessionLiveState::IdleResident);
            agent.close_session_explicit(&sid);
        }
        assert_eq!(
            agent.session_live_state.borrow().len(),
            0,
            "terminal closes must leave no residual live-state entries (bounded map)"
        );
    });
}
/// Finalize fires on a genuine terminal close — driven through the **real**
/// `kigi/session/close` dispatch (`ext_method` → `handle_session_close`),
/// not the internal helper. Proves finalize was *moved* (not removed) and
/// guards the handler's `existed` gate. (Finalize assertion is
/// invocation-level; see note in `finalize_session_replica`.)
#[test]
fn explicit_close_finalizes_the_replica() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-close");
        let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, Some("turn-1"));
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        drive_close(&agent, "no-such-session")
            .await
            .expect("close of a missing session must succeed as a no-op");
        assert!(
            agent.finalize_spy.borrow().is_empty(),
            "closing a missing session must NOT finalize"
        );
        drive_close(&agent, sid.0.as_ref())
            .await
            .expect("session close must be handled");
        assert!(
            matches!(cmd_rx.try_recv(), Ok(TestSessionCommand::Shutdown)),
            "handle_session_close must send Shutdown to the actor"
        );
        assert_eq!(
            agent.finalize_spy.borrow().as_slice(),
            &[sid.0.to_string()],
            "explicit close must finalize the cloud replica exactly once"
        );
        assert!(
            !agent.sessions.borrow().contains_key(&sid),
            "explicit close removes the session"
        );
        assert_eq!(
            agent.session_live_state_for(&sid),
            None,
            "terminal removal must drop the live-state entry (bounded map)"
        );
        assert!(
            agent
                .roster_delta_spy
                .borrow()
                .iter()
                .any(|(id, st)| id == sid.0.as_ref() && *st == SessionLiveState::Completed),
            "explicit close must emit a Completed roster delta"
        );
    });
}
/// Join-handle supervisor: a *resident* actor that panics is reaped
/// promptly — removed from `sessions`/`session_threads`, demoted to
/// `DeadFailed` (observed via the roster delta, since the live-state entry
/// is dropped on removal), and NOT finalized (the conversation persists).
///
/// Polls in real time (the panic unwinds on a real OS thread, independent of
/// the tokio clock); the reap lands within a small number of supervisor
/// ticks. The injected-panic backtrace on stderr is expected and harmless.
#[test]
fn supervisor_reaps_panicked_resident_actor() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-panic");
        let (handle, _tx, _rx) = make_live_session_handle(&sid, Some("turn-1"));
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        let panic_thread = std::thread::spawn(|| panic!("injected actor panic"));
        agent.session_threads.borrow_mut().insert(
            sid.clone(),
            crate::session::SessionThread::from_handle(panic_thread),
        );
        agent.set_session_live_state(&sid, SessionLiveState::Working);
        agent.ensure_session_supervisor();
        let deadline = tokio::time::Instant::now() + (SESSION_SUPERVISOR_TICK * 6);
        while tokio::time::Instant::now() < deadline {
            if !agent.session_threads.borrow().contains_key(&sid) {
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
        }
        assert!(
            !agent.session_threads.borrow().contains_key(&sid),
            "supervisor must reap the dead thread"
        );
        assert!(
            !agent.sessions.borrow().contains_key(&sid),
            "reaped session must be removed from the resident map"
        );
        assert_eq!(
            agent.session_live_state_for(&sid),
            None,
            "terminal removal drops the live-state entry (bounded map)"
        );
        assert!(
            agent
                .roster_delta_spy
                .borrow()
                .iter()
                .any(|(id, st)| id == sid.0.as_ref() && *st == SessionLiveState::DeadFailed),
            "a reaped resident actor must emit a DeadFailed roster delta"
        );
        assert!(
            agent.finalize_spy.borrow().is_empty(),
            "reaping a dead actor must NOT finalize (conversation persists)"
        );
    });
}
/// `ensure_session_supervisor` is idempotent: calling it repeatedly spawns
/// the sweeper loop exactly once.
#[test]
fn ensure_session_supervisor_is_idempotent() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        assert_eq!(agent.supervisor_spawn_count.get(), 0);
        agent.ensure_session_supervisor();
        agent.ensure_session_supervisor();
        agent.ensure_session_supervisor();
        assert_eq!(
            agent.supervisor_spawn_count.get(),
            1,
            "the supervisor task must be spawned at most once"
        );
        assert!(agent.supervisor_started.get());
    });
}
/// After a terminal removal (reap/close drops the live-state entry), a later
/// reload of the same SessionId starts clean at `IdleResident` with no stale
/// terminal state leaking in (ties to the bounded-map fix).
#[test]
fn reload_after_terminal_removal_starts_clean() {
    run_local_for_bridge_test(|| async {
        let agent = build_minimal_agent_for_tests();
        let sid = acp::SessionId::new("sess-reload");
        let (handle, _tx, _rx) = make_live_session_handle(&sid, Some("turn-1"));
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent.close_session_explicit(&sid);
        assert_eq!(
            agent.session_live_state_for(&sid),
            None,
            "terminal removal must leave no stale state"
        );
        let (handle2, _tx2, _rx2) = make_live_session_handle(&sid, None);
        agent.sessions.borrow_mut().insert(sid.clone(), handle2);
        agent.set_session_live_state(&sid, SessionLiveState::IdleResident);
        assert_eq!(
            agent.session_live_state_for(&sid),
            Some(SessionLiveState::IdleResident),
            "a reloaded session must start at IdleResident, not a stale terminal state"
        );
    });
}
/// Build an agent whose gateway is wired to a live receiver, so a test can
/// observe (and answer) agent→client reverse-requests like the dormant
/// `kigi/folder_trust/request` round-trip.
fn build_agent_with_gateway_rx() -> (
    MvpAgent,
    tokio::sync::mpsc::UnboundedReceiver<kigi_acp_lib::AcpClientMessage>,
) {
    use crate::agent::config::Config as AgentConfig;
    use crate::auth::{AuthManager, KimiCodeConfig};
    let temp_dir = tempfile::tempdir().unwrap();
    let auth_manager =
        std::sync::Arc::new(AuthManager::new(temp_dir.path(), KimiCodeConfig::default()));
    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
    let gateway = GatewaySender::new(tx);
    let cfg = AgentConfig::default();
    let agent = MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config");
    (agent, rx)
}
/// A git repo whose only repo-local config is a project `.mcp.json` declaring
/// `projsrv` — so it is untrusted-with-configs, and the project server should
/// reappear after a trust grant.
fn repo_with_project_mcp_server() -> tempfile::TempDir {
    let tmp = tempfile::tempdir().unwrap();
    git2::Repository::init(tmp.path()).unwrap();
    std::fs::write(
        tmp.path().join(".mcp.json"),
        r#"{"mcpServers":{"projsrv":{"command":"echo","args":["hi"]}}}"#,
    )
    .unwrap();
    tmp
}
fn folder_trust_on() -> crate::util::config::RemoteSettings {
    crate::util::config::RemoteSettings {
        folder_trust_enabled: Some(true),
        ..Default::default()
    }
}
/// Pull the next `kigi/folder_trust/request` reverse-request off the gateway and
/// answer it with `outcome`. Returns the request's decoded params.
async fn answer_folder_trust_request(
    gw_rx: &mut tokio::sync::mpsc::UnboundedReceiver<kigi_acp_lib::AcpClientMessage>,
    outcome: &str,
) -> serde_json::Value {
    let msg = tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv())
        .await
        .expect("trust request must be sent")
        .expect("gateway channel open");
    let kigi_acp_lib::AcpClientMessage::ExtMethod(args) = msg else {
        panic!("expected an ext_method reverse-request, got a different message");
    };
    assert_eq!(args.request.method.as_ref(), "kigi/folder_trust/request");
    let params: serde_json::Value = serde_json::from_str(args.request.params.get()).unwrap();
    let resp: acp::ExtResponse = acp::ExtResponse::new(std::sync::Arc::from(
        serde_json::value::to_raw_value(&serde_json::json!({ "outcome" : outcome })).unwrap(),
    ));
    let _ = args.response_tx.send(Ok(resp));
    params
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_grant_reloads_project_mcp() {
    use kigi_test_support::EnvGuard;
    use kigi_workspace::trust::{TrustStore, workspace_key};
    let home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
    let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
    let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
    let repo = repo_with_project_mcp_server();
    let repo_path = repo.path().to_path_buf();
    let remote = folder_trust_on();
    run_local_for_bridge_test(|| async {
        let (agent, mut gw_rx) = build_agent_with_gateway_rx();
        agent.interactive_trust_client.set(true);
        crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
        assert!(
            !crate::agent::folder_trust::project_scope_allowed(&repo_path),
            "untrusted-with-configs workspace must gate project scope before the grant"
        );
        let sid = acp::SessionId::new("sess-trust");
        let (mut handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
        handle.info.cwd = repo_path.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        let params = answer_folder_trust_request(&mut gw_rx, "trust").await;
        assert!(
            params["configKinds"]
                .as_array()
                .is_some_and(|k| k.iter().any(|v| v == "mcp")),
            "request must summarize detected config kinds; got {params}"
        );
        assert_eq!(
            params["sessionId"], "sess-trust",
            "trust request must carry the session id for leader routing; got {params}"
        );
        let mut saw_project_mcp = false;
        let mut saw_reload_plugins = false;
        let mut saw_reload_hooks = false;
        for _ in 0..8 {
            match tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await {
                Ok(Some(TestSessionCommand::UpdateMcpServers { mcp_servers, .. })) => {
                    saw_project_mcp |= mcp_servers
                        .iter()
                        .any(|s| crate::session::managed_mcp::mcp_server_name(s) == "projsrv");
                }
                Ok(Some(TestSessionCommand::ReloadPlugins { .. })) => {
                    saw_reload_plugins = true;
                }
                Ok(Some(TestSessionCommand::ReloadHooks)) => saw_reload_hooks = true,
                Ok(Some(_other)) => continue,
                _ => break,
            }
            if saw_project_mcp && saw_reload_plugins && saw_reload_hooks {
                break;
            }
        }
        assert!(
            saw_project_mcp,
            "trust grant must reload the session's now-trusted project MCP server"
        );
        assert!(
            saw_reload_plugins,
            "trust grant must reload plugins (plugin-contributed hooks/MCP)"
        );
        assert!(
            saw_reload_hooks,
            "trust grant must reload the session's own project hooks"
        );
        assert!(
            TrustStore::load().is_trusted(&workspace_key(&repo_path)),
            "accepting the prompt must persist the trust grant"
        );
        assert!(
            crate::agent::folder_trust::project_scope_allowed(&repo_path),
            "the in-process gate must flip to trusted after the grant"
        );
    });
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_reject_keeps_gated() {
    use kigi_test_support::EnvGuard;
    use kigi_workspace::trust::{TrustStore, workspace_key};
    let home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
    let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
    let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
    let repo = repo_with_project_mcp_server();
    let repo_path = repo.path().to_path_buf();
    let remote = folder_trust_on();
    run_local_for_bridge_test(|| async {
        let (agent, mut gw_rx) = build_agent_with_gateway_rx();
        agent.interactive_trust_client.set(true);
        crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
        let sid = acp::SessionId::new("sess-reject");
        let (mut handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
        handle.info.cwd = repo_path.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        let _ = answer_folder_trust_request(&mut gw_rx, "reject").await;
        assert!(
            tokio::time::timeout(std::time::Duration::from_millis(300), cmd_rx.recv())
                .await
                .is_err(),
            "rejecting trust must leave the session's project servers gated (no reload)"
        );
        assert!(
            !TrustStore::load().is_trusted(&workspace_key(&repo_path)),
            "rejecting trust must leave the store unchanged"
        );
        assert!(
            !crate::agent::folder_trust::project_scope_allowed(&repo_path),
            "rejecting trust must keep the workspace gated"
        );
    });
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_dormant_when_feature_off() {
    use kigi_test_support::EnvGuard;
    let home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
    let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
    let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
    let repo = repo_with_project_mcp_server();
    let repo_path = repo.path().to_path_buf();
    let remote = crate::util::config::RemoteSettings {
        folder_trust_enabled: Some(false),
        ..Default::default()
    };
    run_local_for_bridge_test(|| async {
        let (agent, mut gw_rx) = build_agent_with_gateway_rx();
        agent.interactive_trust_client.set(true);
        let sid = acp::SessionId::new("sess-dormant");
        let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
        handle.info.cwd = repo_path.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        assert!(
            tokio::time::timeout(std::time::Duration::from_millis(300), gw_rx.recv())
                .await
                .is_err(),
            "feature off must emit no trust request (dormant)"
        );
    });
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_no_request_without_capability() {
    use kigi_test_support::EnvGuard;
    let home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
    let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
    let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
    let repo = repo_with_project_mcp_server();
    let repo_path = repo.path().to_path_buf();
    let remote = folder_trust_on();
    run_local_for_bridge_test(|| async {
        let (agent, mut gw_rx) = build_agent_with_gateway_rx();
        assert!(!agent.interactive_trust_client.get());
        let sid = acp::SessionId::new("sess-nocap");
        let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
        handle.info.cwd = repo_path.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        assert!(
            tokio::time::timeout(std::time::Duration::from_millis(300), gw_rx.recv())
                .await
                .is_err(),
            "a client without the capability must get no trust request"
        );
    });
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_client_error_fails_closed() {
    use kigi_test_support::EnvGuard;
    use kigi_workspace::trust::{TrustStore, workspace_key};
    let home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
    let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
    let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
    let repo = repo_with_project_mcp_server();
    let repo_path = repo.path().to_path_buf();
    let remote = folder_trust_on();
    run_local_for_bridge_test(|| async {
        let (agent, mut gw_rx) = build_agent_with_gateway_rx();
        agent.interactive_trust_client.set(true);
        crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
        let sid = acp::SessionId::new("sess-err");
        let (mut handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
        handle.info.cwd = repo_path.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        let msg = tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv())
            .await
            .expect("trust request must be sent")
            .expect("gateway channel open");
        assert!(matches!(msg, kigi_acp_lib::AcpClientMessage::ExtMethod(_)));
        drop(msg);
        assert!(
            tokio::time::timeout(std::time::Duration::from_millis(300), cmd_rx.recv())
                .await
                .is_err(),
            "a failed client round-trip must not reload the session"
        );
        assert!(
            !TrustStore::load().is_trusted(&workspace_key(&repo_path)),
            "a failed client round-trip must not grant trust"
        );
        assert!(!crate::agent::folder_trust::project_scope_allowed(
            &repo_path
        ));
    });
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_dedups_same_workspace() {
    use kigi_test_support::EnvGuard;
    let home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
    let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
    let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
    let repo = repo_with_project_mcp_server();
    let repo_path = repo.path().to_path_buf();
    let remote = folder_trust_on();
    run_local_for_bridge_test(|| async {
        let (agent, mut gw_rx) = build_agent_with_gateway_rx();
        agent.interactive_trust_client.set(true);
        crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
        let sid = acp::SessionId::new("sess-dedup");
        let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
        handle.info.cwd = repo_path.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        let first = tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv()).await;
        assert!(
            matches!(first, Ok(Some(kigi_acp_lib::AcpClientMessage::ExtMethod(_)))),
            "first prompt for an untrusted workspace must emit a request"
        );
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        assert!(
            tokio::time::timeout(std::time::Duration::from_millis(300), gw_rx.recv())
                .await
                .is_err(),
            "a workspace already prompted this process must not be re-prompted"
        );
    });
}
/// Which reload commands a session received after a grant.
struct ReloadCmds {
    update_mcp: bool,
    reload_plugins: bool,
    reload_hooks: bool,
    mcp_names: Vec<String>,
}
/// Drain a session's command channel for the post-grant reload trio
/// (`UpdateMcpServers` + `ReloadPlugins` + `ReloadHooks`), capturing the merged
/// MCP server names so a test can assert per-cwd reload.
async fn drain_reload_commands(
    cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<TestSessionCommand>,
) -> ReloadCmds {
    let mut out = ReloadCmds {
        update_mcp: false,
        reload_plugins: false,
        reload_hooks: false,
        mcp_names: Vec::new(),
    };
    for _ in 0..8 {
        match tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await {
            Ok(Some(TestSessionCommand::UpdateMcpServers { mcp_servers, .. })) => {
                out.update_mcp = true;
                out.mcp_names = mcp_servers
                    .iter()
                    .map(|s| crate::session::managed_mcp::mcp_server_name(s).to_string())
                    .collect();
            }
            Ok(Some(TestSessionCommand::ReloadPlugins { .. })) => {
                out.reload_plugins = true;
            }
            Ok(Some(TestSessionCommand::ReloadHooks)) => out.reload_hooks = true,
            Ok(Some(_other)) => continue,
            _ => break,
        }
        if out.update_mcp && out.reload_plugins && out.reload_hooks {
            break;
        }
    }
    out
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_reloads_all_same_workspace_sessions() {
    use kigi_test_support::EnvGuard;
    let home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
    let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
    let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
    let repo = repo_with_project_mcp_server();
    let root = repo.path().to_path_buf();
    let subdir = root.join("sub");
    std::fs::create_dir_all(&subdir).unwrap();
    std::fs::write(
        subdir.join(".mcp.json"),
        r#"{"mcpServers":{"subsrv":{"command":"echo","args":["hi"]}}}"#,
    )
    .unwrap();
    let other = repo_with_project_mcp_server();
    let other_path = other.path().to_path_buf();
    let remote = folder_trust_on();
    run_local_for_bridge_test(|| async {
        let (agent, mut gw_rx) = build_agent_with_gateway_rx();
        agent.interactive_trust_client.set(true);
        crate::agent::folder_trust::resolve_and_record(&root, Some(&remote), false);
        let sid_root = acp::SessionId::new("sess-root");
        let (mut h_root, _t1, mut rx_root) = make_live_session_handle(&sid_root, None);
        h_root.info.cwd = root.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid_root.clone(), h_root);
        let sid_sub = acp::SessionId::new("sess-sub");
        let (mut h_sub, _t2, mut rx_sub) = make_live_session_handle(&sid_sub, None);
        h_sub.info.cwd = subdir.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid_sub.clone(), h_sub);
        let sid_other = acp::SessionId::new("sess-other");
        let (mut h_other, _t3, mut rx_other) = make_live_session_handle(&sid_other, None);
        h_other.info.cwd = other_path.to_string_lossy().to_string();
        agent
            .sessions
            .borrow_mut()
            .insert(sid_other.clone(), h_other);
        agent.maybe_spawn_interactive_trust_prompt(&sid_root, &root, Some(&remote));
        let _ = answer_folder_trust_request(&mut gw_rx, "trust").await;
        let root_cmds = drain_reload_commands(&mut rx_root).await;
        assert!(
            root_cmds.update_mcp && root_cmds.reload_plugins && root_cmds.reload_hooks,
            "root session must get UpdateMcpServers + ReloadPlugins + ReloadHooks"
        );
        let sub_cmds = drain_reload_commands(&mut rx_sub).await;
        assert!(
            sub_cmds.update_mcp && sub_cmds.reload_plugins && sub_cmds.reload_hooks,
            "subdir session must get UpdateMcpServers + ReloadPlugins + ReloadHooks"
        );
        assert!(
            sub_cmds.mcp_names.iter().any(|n| n == "subsrv"),
            "subdir session must reload against its own cwd (expect subsrv); got {:?}",
            sub_cmds.mcp_names
        );
        assert!(
            tokio::time::timeout(std::time::Duration::from_millis(300), rx_other.recv())
                .await
                .is_err(),
            "a session under a different workspace_key must not be reloaded"
        );
    });
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_reprompts_after_untrust() {
    use kigi_test_support::EnvGuard;
    use kigi_hooks_plugins_types::HooksAction;
    let home = tempfile::tempdir().unwrap();
    let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
    let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
    let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
    let repo = repo_with_project_mcp_server();
    let repo_path = repo.path().to_path_buf();
    let remote = folder_trust_on();
    run_local_for_bridge_test(|| async {
        let (agent, mut gw_rx) = build_agent_with_gateway_rx();
        agent.interactive_trust_client.set(true);
        crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
        let sid = acp::SessionId::new("sess-reprompt");
        let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
        handle.info.cwd = repo_path.to_string_lossy().to_string();
        agent.sessions.borrow_mut().insert(sid.clone(), handle);
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        assert!(
            matches!(
                tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv()).await,
                Ok(Some(kigi_acp_lib::AcpClientMessage::ExtMethod(_)))
            ),
            "first prompt must emit a request"
        );
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        assert!(
            tokio::time::timeout(std::time::Duration::from_millis(200), gw_rx.recv())
                .await
                .is_err(),
            "a prompted workspace must be suppressed before untrust"
        );
        let _ = tokio::time::timeout(
            std::time::Duration::from_millis(200),
            agent.execute_hooks_action(&sid, HooksAction::Untrust),
        )
        .await;
        agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
        assert!(
            matches!(
                tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv()).await,
                Ok(Some(kigi_acp_lib::AcpClientMessage::ExtMethod(_)))
            ),
            "after untrust clears the dedup, the workspace must be promptable again"
        );
    });
}
mod direct_hub_cloud_removed {
    use super::super::{DIRECT_HUB_CLOUD_REMOVED_MSG, reject_direct_hub_cloud_meta};
    fn assert_direct_hub_error(err: agent_client_protocol::Error) {
        assert_eq!(
            err.data.as_ref(),
            Some(&serde_json::Value::String(
                DIRECT_HUB_CLOUD_REMOVED_MSG.to_string()
            )),
            "error data must be the exact D8 message, got: {err:?}"
        );
        assert_eq!(
            err.code,
            agent_client_protocol::ErrorCode::InvalidParams,
            "must be invalid_params, got: {err:?}"
        );
    }
    #[test]
    fn cloud_server_id_meta_is_hard_error() {
        let meta = serde_json::json!({ "kigi/cloud_server_id" : "srv-123" });
        let err = reject_direct_hub_cloud_meta(meta.as_object()).expect_err("must reject");
        assert_direct_hub_error(err);
    }
    #[test]
    fn cloud_server_id_null_still_present_is_hard_error() {
        let meta = serde_json::json!({ "kigi/cloud_server_id" : null });
        let err = reject_direct_hub_cloud_meta(meta.as_object()).expect_err("must reject");
        assert_direct_hub_error(err);
    }
    #[test]
    fn cloud_server_id_with_gateway_meta_still_hard_error() {
        let meta = serde_json::json!(
            { "kigi/cloud_server_id" : "srv-legacy", "envId" : "env-1",
            "kigi/cloud_existing_workspace" : { "server_id" : "ws-1", "cwd" :
            "/workspace" } }
        );
        let err = reject_direct_hub_cloud_meta(meta.as_object()).expect_err("Direct stamp wins");
        assert_direct_hub_error(err);
    }
    #[test]
    fn absent_or_empty_meta_ok() {
        assert!(reject_direct_hub_cloud_meta(None).is_ok());
        assert!(reject_direct_hub_cloud_meta(serde_json::json!({}).as_object()).is_ok());
        assert!(
            reject_direct_hub_cloud_meta(serde_json::json!({ "envId" : "env-1" }).as_object())
                .is_ok()
        );
        assert!(
            reject_direct_hub_cloud_meta(
                serde_json::json!({
            "kigi/cloud_existing_workspace" : { "server_id" : "ws-1", "cwd" :
            "/workspace" } })
                .as_object()
            )
            .is_ok()
        );
    }
}
mod soft_default_settings_emit {
    use super::*;
    #[tokio::test]
    async fn emit_settings_update_carries_permission_mode_from_cfg() {
        use crate::agent::config::Config as AgentConfig;
        use crate::auth::{AuthManager, KimiCodeConfig};
        let local = tokio::task::LocalSet::new();
        local
            .run_until(async {
                let temp_dir = tempfile::tempdir().unwrap();
                let auth_manager = std::sync::Arc::new(AuthManager::new(
                    temp_dir.path(),
                    KimiCodeConfig::default(),
                ));
                let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
                let gateway = GatewaySender::new(tx);
                let cfg = AgentConfig {
                    remote_settings: Some(crate::util::config::RemoteSettings {
                        permission_mode: Some("always-approve".into()),
                        ..Default::default()
                    }),
                    ..Default::default()
                };
                let agent =
                    MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config");
                agent.cfg.borrow_mut().remote_settings = cfg.remote_settings.clone();
                agent.emit_settings_update_notification();
                let msg = rx.try_recv().expect("settings/update must be emitted");
                let kigi_acp_lib::AcpClientMessage::ExtNotification(args) = msg else {
                    panic!("expected ExtNotification, got {msg:?}");
                };
                assert_eq!(args.request.method.as_ref(), "kigi/settings/update");
                let params: serde_json::Value =
                    serde_json::from_str(args.request.params.get()).expect("parse params");
                assert_eq!(
                    params.get("permission_mode").and_then(|v| v.as_str()),
                    Some("always-approve"),
                    "post-auth emit must carry remote permission_mode for first session"
                );
                let _ = args.response_tx.send(Ok(()));
            })
            .await;
    }
}
