#![cfg_attr(rustfmt, rustfmt::skip)]
use super::*;
/// The invalid-params server detail survives `attach_prompt_usage`
/// wrapping `error.data` as `{message, promptUsage}`.
#[test]
fn format_acp_error_reads_detail_from_wrapped_data() {
    let bare = acp::Error::invalid_params().data("model does not support tools");
    assert_eq!(format_acp_error(& bare, false), "model does not support tools");
    let wrapped = acp::Error::invalid_params()
        .data(
            serde_json::json!(
                { "message" : "model does not support tools", "promptUsage" : {
                "inputTokens" : 12, "outputTokens" : 0, "numTurns" : 1 } }
            ),
        );
    assert_eq!(format_acp_error(& wrapped, false), "model does not support tools");
}
#[test]
fn format_acp_error_rate_limit_is_auth_aware() {
    use kigi_shell::sampling::error::{
        RATE_LIMITED_ERROR_CODE, RATE_LIMITED_USER_MESSAGE_API_KEY,
        RATE_LIMITED_USER_MESSAGE_OAUTH,
    };
    let err = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited").data("slow down");
    assert_eq!(format_acp_error(&err, false), RATE_LIMITED_USER_MESSAGE_OAUTH.as_str());
    assert_eq!(format_acp_error(& err, true), RATE_LIMITED_USER_MESSAGE_API_KEY);
}
/// Non-empty token ranges ride the wire block meta as `skillTokenRanges`
/// byte pairs; the text itself is untouched.
#[test]
fn plain_prompt_block_stamps_skill_token_ranges_meta() {
    let block = plain_prompt_content_block(
        "great /pr-workflow go".into(),
        std::slice::from_ref(&(6..18)),
    );
    let acp::ContentBlock::Text(tb) = block else {
        panic!("expected text block");
    };
    assert_eq!(tb.text, "great /pr-workflow go");
    let meta = tb.meta.expect("meta stamped when ranges non-empty");
    assert_eq!(meta["skillTokenRanges"], serde_json::json!([[6, 18]]));
}
/// Empty ranges keep `meta: None` — the legacy wire shape is unchanged.
#[test]
fn plain_prompt_block_no_meta_when_ranges_empty() {
    let block = plain_prompt_content_block("hello".into(), &[]);
    let acp::ContentBlock::Text(tb) = block else {
        panic!("expected text block");
    };
    assert_eq!(tb.text, "hello");
    assert!(tb.meta.is_none());
}
/// With a screen mode, `_meta` carries both `promptId` and `screenMode`
/// (the shell threads the latter into `prompt_submitted.screen_mode`).
#[test]
fn prompt_request_meta_stamps_screen_mode() {
    let meta = prompt_request_meta("p-1", Some("minimal"));
    assert_eq!(
        meta, serde_json::json!({ "promptId" : "p-1", "screenMode" : "minimal" })
    );
}
/// Without a screen mode (`SessionFlags::default()` in tests), the key is
/// omitted — the legacy `{"promptId": …}` wire shape stays byte-identical.
#[test]
fn prompt_request_meta_omits_screen_mode_when_unset() {
    let meta = prompt_request_meta("p-2", None);
    assert_eq!(meta, serde_json::json!({ "promptId" : "p-2" }));
}
/// Text-only interjections must omit the `content` key entirely — the
/// legacy `kigi/interject` wire shape stays byte-identical.
#[test]
fn interject_params_omit_content_when_no_blocks() {
    let sid = acp::SessionId::new("s1");
    let params = build_interject_params(&sid, "steer", "i1", None);
    let obj = params.as_object().unwrap();
    assert!(! obj.contains_key("content"), "content key must be absent");
    assert_eq!(obj["sessionId"], "s1");
    assert_eq!(obj["text"], "steer");
    assert_eq!(obj["interjectionId"], "i1");
    assert_eq!(obj.len(), 3, "no extra keys on the legacy shape");
}
#[test]
fn picker_keeps_conversation_with_empty_cwd_and_missing_updated_at() {
    let payload = serde_json::json!(
        { "sessions" : [{ "sessionId" : "conv_abc", "cwd" : "", "summary" :
        "Compare GPU vendors", "source" : "conversation", "_meta" : { "kigi/session" : {
        "kind" : "chat" } } }] }
    );
    let entries = parse_session_picker_entries(&payload);
    assert_eq!(entries.len(), 1, "conversation must not vanish");
    assert_eq!(entries[0].id, "conv_abc");
    assert_eq!(entries[0].cwd, "");
    assert_eq!(entries[0].source, "conversation");
}
#[test]
fn picker_keeps_old_conversation_past_cutoff() {
    let payload = serde_json::json!(
        { "sessions" : [{ "sessionId" : "conv_old", "cwd" : "", "summary" :
        "Ancient chat", "source" : "conversation", "updatedAt" : "2020-01-01T00:00:00Z",
        "_meta" : { "kigi/session" : { "kind" : "chat" } } }] }
    );
    let entries = parse_session_picker_entries(&payload);
    assert_eq!(entries.len(), 1, "old conversation must still render");
    assert_eq!(entries[0].source, "conversation");
}
#[test]
fn picker_drops_local_with_missing_updated_at() {
    let payload = serde_json::json!(
        { "sessions" : [{ "sessionId" : "local_no_ts", "cwd" : "/Users/me/xai", "summary"
        : "no timestamp", "source" : "local" }] }
    );
    let entries = parse_session_picker_entries(&payload);
    assert!(entries.is_empty(), "local rows still require a parseable updatedAt");
}
/// Untitled kimi.com chats must stay listed, rendered as "Untitled".
#[test]
fn picker_keeps_untitled_conversation_as_untitled() {
    let payload = serde_json::json!(
        { "sessions" : [{ "sessionId" : "conv_untitled", "cwd" : "", "summary" : "",
        "source" : "conversation", "updatedAt" : "2026-07-01T00:00:00Z", "_meta" : {
        "kigi/session" : { "kind" : "chat" } } }] }
    );
    let entries = parse_session_picker_entries(&payload);
    assert_eq!(entries.len(), 1, "untitled conversation must not vanish");
    assert_eq!(entries[0].summary, "Untitled");
    assert_eq!(entries[0].source, "conversation");
}
/// Canary: the empty-summary drop still applies to Build rows.
#[test]
fn picker_still_drops_build_row_with_empty_summary() {
    let payload = serde_json::json!(
        { "sessions" : [{ "sessionId" : "local_empty", "cwd" :
        "/nonexistent/effects-test", "summary" : "", "source" : "local", "updatedAt" :
        "2026-07-01T00:00:00Z" }] }
    );
    let entries = parse_session_picker_entries(&payload);
    assert!(entries.is_empty(), "empty-summary Build rows stay dropped");
}
#[test]
fn parse_kill_outcome_reads_result_envelope() {
    use kigi_tools::types::KillOutcome;
    let resp = r#"{"result":{"taskId":"t-1","outcome":"not_found"}}"#;
    assert_eq!(parse_kill_outcome(resp), Some(KillOutcome::NotFound));
    let resp = r#"{"result":{"taskId":"t-1","outcome":"killed"}}"#;
    assert_eq!(parse_kill_outcome(resp), Some(KillOutcome::Killed));
    let resp = r#"{"result":{"taskId":"t-1","outcome":"already_exited"}}"#;
    assert_eq!(parse_kill_outcome(resp), Some(KillOutcome::AlreadyExited));
}
/// Round-trip through the agent's own serializer: what
/// `extensions::task::respond()` produces must parse back to the same
/// typed outcome (guards against the two sides drifting apart).
#[test]
fn parse_kill_outcome_round_trips_agent_serialization() {
    use kigi_shell::extensions::task::KillTaskResponse;
    use kigi_shell::session::result::ExtMethodResult;
    use kigi_tools::types::KillOutcome;
    let wire = serde_json::to_string(
            &ExtMethodResult::success(KillTaskResponse {
                task_id: "t-1".into(),
                outcome: KillOutcome::NotFound,
            }),
        )
        .unwrap();
    assert_eq!(parse_kill_outcome(& wire), Some(KillOutcome::NotFound));
}
/// Error envelopes and malformed payloads yield `None` (clear pending
/// state, keep the row).
#[test]
fn parse_kill_outcome_none_for_error_or_malformed() {
    assert_eq!(
        parse_kill_outcome(r#"{"result":null,"error":"session not found"}"#), None
    );
    assert_eq!(parse_kill_outcome("not json"), None);
    assert_eq!(parse_kill_outcome("{}"), None);
    assert_eq!(
        parse_kill_outcome(r#"{"result":{"taskId":"t-1","outcome":"exploded"}}"#), None
    );
}
/// Typed `outcome`: `Cancelled` → `StoppedLive`; `AlreadyFinished` /
/// `NotFound` → `NothingLive` (carrying the real status when known).
#[test]
fn parse_subagent_kill_outcome_reads_typed_outcome() {
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":true,"outcome":{"kind":"cancelled"}}}"#),
        SubagentKillOutcome::StoppedLive)
    );
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":false,"outcome":{"kind":"already_finished","status":"completed"}}}"#),
        SubagentKillOutcome::NothingLive { status : Some(s) } if s == "completed")
    );
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":false,"outcome":{"kind":"not_found"}}}"#),
        SubagentKillOutcome::NothingLive { status : None })
    );
}
/// An older shell sends no `outcome`; the parser falls back to the legacy
/// `cancelled` bool (true → `StoppedLive`, false → `NothingLive`).
#[test]
fn parse_subagent_kill_outcome_falls_back_to_legacy_bool() {
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":true}}"#),
        SubagentKillOutcome::StoppedLive)
    );
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":false}}"#),
        SubagentKillOutcome::NothingLive { status : None })
    );
}
/// An unknown future `kind` deserializes to `Unknown` (via `#[serde(other)]`)
/// and falls back to the always-present `cancelled` bool — not `RpcFailed`,
/// which would leave the row stuck.
#[test]
fn parse_subagent_kill_outcome_unknown_kind_falls_back_to_legacy_bool() {
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":true,"outcome":{"kind":"some_future_kind"}}}"#),
        SubagentKillOutcome::StoppedLive)
    );
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":false,"outcome":{"kind":"some_future_kind"}}}"#),
        SubagentKillOutcome::NothingLive { status : None })
    );
}
/// Round-trip through the agent's own serializer guards the two sides
/// against drifting apart.
#[test]
fn parse_subagent_kill_outcome_round_trips_agent_serialization() {
    use kigi_shell::extensions::task::{
        CancelSubagentResponse, SubagentCancelOutcomeDto,
    };
    let wire = serde_json::to_string(
            &ExtMethodResult::success(CancelSubagentResponse {
                subagent_id: "sa-1".into(),
                cancelled: false,
                outcome: Some(SubagentCancelOutcomeDto::AlreadyFinished {
                    status: "failed".into(),
                }),
            }),
        )
        .unwrap();
    assert!(
        matches!(parse_subagent_kill_outcome(& wire), SubagentKillOutcome::NothingLive {
        status : Some(s) } if s == "failed")
    );
}
/// A top-level payload (no `result` envelope), error envelopes, and
/// malformed payloads are a failed RPC (`RpcFailed`) — the caller must NOT
/// finalize a possibly-live row.
#[test]
fn parse_subagent_kill_outcome_rpc_failed_for_error_or_malformed() {
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"cancelled":true}"#),
        SubagentKillOutcome::RpcFailed)
    );
    assert!(
        matches!(parse_subagent_kill_outcome(r#"{"result":null,"error":"session not found"}"#),
        SubagentKillOutcome::RpcFailed)
    );
    assert!(
        matches!(parse_subagent_kill_outcome("not json"), SubagentKillOutcome::RpcFailed)
    );
    assert!(matches!(parse_subagent_kill_outcome("{}"), SubagentKillOutcome::RpcFailed));
}
/// Image-bearing interjections carry the blocks as a `content` array.
#[test]
fn interject_params_carry_content_when_blocks_present() {
    let sid = acp::SessionId::new("s1");
    let blocks = vec![
        acp::ContentBlock::Text(acp::TextContent::new("look at [Image #1]",))
    ];
    let params = build_interject_params(
        &sid,
        "look at [Image #1]",
        "i1",
        Some(blocks.as_slice()),
    );
    let content = params["content"].as_array().expect("content array");
    assert_eq!(content.len(), 1);
    assert_eq!(content[0] ["text"], "look at [Image #1]");
}
/// `kigi/billing` ext result (a serialized shell `UsageResponse`) parses
/// into typed rows; unknown labels/reset hints survive the round trip.
#[test]
fn parse_usage_response_reads_rows_from_fixture() {
    let fixture = serde_json::json!({
        "rows": [
            {
                "label": "Weekly limit",
                "used": 250,
                "limit": 1000,
                "resetHint": "resets in 2d 1h"
            },
            { "label": "5h limit", "used": 20, "limit": 50 }
        ]
    });
    let rows = parse_usage_response(&fixture).expect("fixture must parse");
    assert_eq!(rows.len(), 2);
    assert_eq!(rows[0].label, "Weekly limit");
    assert_eq!(rows[0].used, 250);
    assert_eq!(rows[0].limit, 1000);
    assert_eq!(rows[0].reset_hint.as_deref(), Some("resets in 2d 1h"));
    assert_eq!(rows[1].label, "5h limit");
    assert!(rows[1].reset_hint.is_none());
}
/// Round-trip through the shell's own serializer: what the billing
/// extension emits must parse back to the same typed rows.
#[test]
fn parse_usage_response_round_trips_shell_serialization() {
    use kigi_shell::extensions::billing::{UsageResponse, UsageRow};
    let wire = serde_json::to_value(&UsageResponse {
        rows: vec![UsageRow {
            label: "RPM".into(),
            used: 12,
            limit: 60,
            reset_hint: None,
        }],
    })
    .expect("serialize");
    let rows = parse_usage_response(&wire).expect("round trip");
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].label, "RPM");
    assert_eq!(rows[0].used, 12);
    assert_eq!(rows[0].limit, 60);
}
/// Empty rows parse to an empty list (→ "No usage data available." in the
/// dispatch layer); a malformed body is an error, not an empty quota list.
#[test]
fn parse_usage_response_empty_and_malformed() {
    assert!(
        parse_usage_response(&serde_json::json!({ "rows": [] }))
            .expect("empty rows parse")
            .is_empty()
    );
    assert!(parse_usage_response(&serde_json::json!({ "bogus": 1 })).is_err());
    assert!(parse_usage_response(&serde_json::json!(null)).is_err());
}
#[test]
fn parse_worktree_restore_payload_full() {
    use kigi_workspace::session::git::RestoreDegree;
    let value = serde_json::json!(
        { "codeRestored" : true, "restoreSummary" :
        "checked out abc12345, staged: true, unstaged: false, untracked: 3",
        "restoreDegree" : "full", }
    );
    let (restored, summary, degree) = parse_worktree_restore_payload(&value);
    assert!(restored);
    assert_eq!(degree, Some(RestoreDegree::Full));
    assert!(summary.unwrap().contains("staged: true"));
}
#[test]
fn parse_worktree_restore_payload_head_only() {
    use kigi_workspace::session::git::RestoreDegree;
    let value = serde_json::json!(
        { "codeRestored" : true, "restoreSummary" :
        "checked out abc (session registry disabled — staged/unstaged/untracked not restored)",
        "restoreDegree" : "head_only", }
    );
    let (_, _, degree) = parse_worktree_restore_payload(&value);
    assert_eq!(degree, Some(RestoreDegree::HeadOnly));
}
#[test]
fn parse_worktree_restore_payload_missing_fields() {
    let value = serde_json::json!({ "codeRestored" : false });
    let (restored, summary, degree) = parse_worktree_restore_payload(&value);
    assert!(! restored);
    assert!(summary.is_none());
    assert!(degree.is_none());
}
/// A typo / unknown variant must parse as `None` rather than
/// silently round-tripping a bogus value.
#[test]
fn parse_worktree_restore_payload_rejects_unknown_degree() {
    let value = serde_json::json!(
        { "codeRestored" : true, "restoreSummary" : "x", "restoreDegree" : "full_", }
    );
    let (_, _, degree) = parse_worktree_restore_payload(&value);
    assert!(degree.is_none(), "typo must produce None");
}
#[test]
fn parse_session_load_restore_meta_full_shape() {
    use kigi_workspace::session::git::RestoreDegree;
    let meta = serde_json::json!(
        { "codeRestore" : { "restored" : true, "summary" : "checked out abc12345",
        "degree" : "head_only", } }
    );
    let (restored, summary, degree) = parse_session_load_restore_meta(meta.as_object());
    assert!(restored);
    assert_eq!(summary.as_deref(), Some("checked out abc12345"));
    assert_eq!(degree, Some(RestoreDegree::HeadOnly));
}
#[test]
fn parse_session_load_restore_meta_absent_returns_false() {
    let (restored, summary, degree) = parse_session_load_restore_meta(None);
    assert!(! restored);
    assert!(summary.is_none());
    assert!(degree.is_none());
}
#[test]
fn parse_session_load_restore_meta_no_coderestore_key() {
    let meta = serde_json::json!({ "other" : 1 });
    let (restored, summary, degree) = parse_session_load_restore_meta(meta.as_object());
    assert!(! restored);
    assert!(summary.is_none());
    assert!(degree.is_none());
}
/// Parser must reject unknown degree strings in the meta path.
#[test]
fn parse_session_load_restore_meta_rejects_unknown_degree() {
    let meta = serde_json::json!(
        { "codeRestore" : { "restored" : true, "summary" : "x", "degree" : "weird" } }
    );
    let (_, _, degree) = parse_session_load_restore_meta(meta.as_object());
    assert!(degree.is_none());
}
/// Unknown keys return a descriptive error.
#[tokio::test]
async fn persist_setting_unknown_key_returns_err() {
    use crate::settings::SettingValue;
    let result = persist_setting("not-a-real-setting", SettingValue::Bool(true)).await;
    match result {
        Err(msg) => {
            assert!(
                msg.contains("unknown setting key"),
                "expected error to mention unknown setting key, got: {msg}",
            )
        }
        Ok(()) => panic!("expected Err for unknown key"),
    }
}
/// Type-mismatch returns Err (not panic) for spawned-task safety.
#[tokio::test]
async fn persist_setting_type_mismatch_errors_compact_mode() {
    use crate::settings::SettingValue;
    let r = persist_setting("compact_mode", SettingValue::String("nope".into())).await;
    let err = r.expect_err("compact_mode with String payload must return Err");
    assert!(
        err.contains("persist_setting(compact_mode) expected Bool"),
        "error message must mention key + expected kind, got: {err}",
    );
}
/// Type-mismatch for `show_timestamps`.
#[tokio::test]
async fn persist_setting_type_mismatch_errors_show_timestamps() {
    use crate::settings::SettingValue;
    let r = persist_setting("show_timestamps", SettingValue::String("nope".into()))
        .await;
    let err = r.expect_err("show_timestamps with String payload must return Err");
    assert!(
        err.contains("persist_setting(show_timestamps) expected Bool"),
        "error message must mention key + expected kind, got: {err}",
    );
}
/// Type-mismatch for `show_timeline`.
#[tokio::test]
async fn persist_setting_type_mismatch_errors_show_timeline() {
    use crate::settings::SettingValue;
    let r = persist_setting("show_timeline", SettingValue::String("nope".into())).await;
    let err = r.expect_err("show_timeline with String payload must return Err");
    assert!(
        err.contains("persist_setting(show_timeline) expected Bool"),
        "error message must mention key + expected kind, got: {err}",
    );
}
/// Type-mismatch for `simple_mode`.
#[tokio::test]
async fn persist_setting_type_mismatch_errors_simple_mode() {
    use crate::settings::SettingValue;
    let r = persist_setting("simple_mode", SettingValue::Int(42)).await;
    let err = r.expect_err("simple_mode with Int payload must return Err");
    assert!(
        err.contains("persist_setting(simple_mode) expected Bool"),
        "error message must mention key + expected kind, got: {err}",
    );
}
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Spawn a fake ACP agent that counts `kigi/yolo_mode_changed`
/// notifications. Exits when the channel closes.
fn spawn_fake_acp_agent(
    mut rx: tokio::sync::mpsc::UnboundedReceiver<kigi_acp_lib::AcpAgentMessage>,
) -> Arc<AtomicUsize> {
    let counter = Arc::new(AtomicUsize::new(0));
    let counter_clone = counter.clone();
    tokio::spawn(async move {
        while let Some(msg) = rx.recv().await {
            if let kigi_acp_lib::AcpAgentMessage::ExtNotification(args) = msg {
                if args.request.method.as_ref() == "kigi/yolo_mode_changed" {
                    counter_clone.fetch_add(1, Ordering::SeqCst);
                }
                let _ = args.response_tx.send(Ok(()));
            }
        }
    });
    counter
}
/// Redirect `KIGI_SHARE_DIR` to a tempdir for test isolation.
fn setup_kigi_home_in_tempdir() -> tempfile::TempDir {
    let tmp = tempfile::tempdir().expect("tempdir creation");
    unsafe {
        std::env::set_var("KIGI_SHARE_DIR", tmp.path());
    }
    tmp
}
fn register_session_in(root: &std::path::Path, id: &str) -> acp::SessionId {
    use kigi_shell::active_sessions::{ActiveSession, register_in};
    let session_id = acp::SessionId::new(id);
    register_in(
            root,
            ActiveSession {
                session_id: session_id.clone(),
                pid: std::process::id(),
                cwd: "/tmp/test".into(),
                opened_at: chrono::Utc::now(),
            },
        )
        .expect("register");
    session_id
}
/// Lock-free: the helper removes the registry entry (the normal path).
#[test]
fn unregister_best_effort_removes_entry_when_lock_free() {
    let dir = tempfile::tempdir().expect("tempdir");
    let sid = register_session_in(dir.path(), "s1");
    unregister_active_session_best_effort_in(dir.path(), &sid);
    assert!(
        kigi_shell::active_sessions::list_in(dir.path()).expect("list").is_empty(),
        "lock-free unregister must remove the entry",
    );
}
/// Contended: the quit path must skip the shared flock rather than block.
/// The unregister runs on a worker joined against a deadline so a blocking
/// regression fails fast here instead of deadlocking the test binary.
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn unregister_best_effort_is_nonblocking_under_lock_contention() {
    use std::os::unix::io::AsRawFd;
    use std::sync::mpsc;
    use std::time::Duration;
    let dir = tempfile::tempdir().expect("tempdir");
    let sid = register_session_in(dir.path(), "s1");
    let lock = std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(dir.path().join("active_sessions.lock"))
        .expect("open lock");
    assert_eq!(unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) }, 0);
    let (tx, rx) = mpsc::channel();
    let root = dir.path().to_path_buf();
    let worker = std::thread::spawn(move || {
        unregister_active_session_best_effort_in(&root, &sid);
        let _ = tx.send(());
    });
    let returned = rx.recv_timeout(Duration::from_secs(2)).is_ok();
    assert_eq!(unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_UN) }, 0);
    worker.join().expect("worker thread");
    assert!(
        returned, "contended unregister blocked on the shared flock instead of skipping",
    );
    assert_eq!(
        kigi_shell::active_sessions::list_in(dir.path()).expect("list").len(), 1,
        "contended unregister must leave the entry for collect_crashed",
    );
}
/// A real I/O error (uncreatable registry root) is swallowed: the
/// best-effort helper logs and returns instead of panicking.
#[test]
fn unregister_best_effort_swallows_io_error() {
    let file = tempfile::NamedTempFile::new().expect("tempfile");
    let bad_root = file.path().join("not-a-dir");
    unregister_active_session_best_effort_in(&bad_root, &acp::SessionId::new("s1"));
}
/// BestEffort path fires exactly one ACP notification regardless
/// of disk outcome.
#[tokio::test]
async fn persist_permission_mode_acp_notification_fires_once_on_best_effort() {
    use agent_client_protocol as acp;
    let _guard = setup_kigi_home_in_tempdir();
    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
    let counter = spawn_fake_acp_agent(rx);
    let session_id = Some(acp::SessionId::new(Arc::from("test-session")));
    let result = persist_permission_mode_and_notify(
            "always-approve",
            session_id,
            PermissionModePersist::BestEffort,
            tx,
        )
        .await;
    tokio::task::yield_now().await;
    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    assert_eq!(
        counter.load(Ordering::SeqCst), 1,
        "ACP `kigi/yolo_mode_changed` notification must fire exactly once \
             on BestEffort path (regardless of disk outcome)",
    );
    assert!(
        matches!(result, TaskResult::SettingPersisted { .. } |
        TaskResult::SettingPersistFailedBestEffort { .. },),
        "BestEffort path must return SettingPersisted (Ok) or \
             SettingPersistFailedBestEffort (Err), got {result:?}",
    );
}
/// WithRollback: notification count matches disk outcome
/// (1 on Ok, 0 on Err).
#[tokio::test]
async fn persist_permission_mode_acp_notification_gated_on_disk_for_with_rollback() {
    use agent_client_protocol as acp;
    let _guard = setup_kigi_home_in_tempdir();
    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
    let counter = spawn_fake_acp_agent(rx);
    let session_id = Some(acp::SessionId::new(Arc::from("test-session")));
    let result = persist_permission_mode_and_notify(
            "always-approve",
            session_id,
            PermissionModePersist::WithRollback("ask"),
            tx,
        )
        .await;
    tokio::task::yield_now().await;
    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    let count = counter.load(Ordering::SeqCst);
    match result {
        TaskResult::SettingPersisted { .. } => {
            assert_eq!(
                count, 1,
                "WithRollback + disk Ok must fire ACP notification exactly once",
            );
        }
        TaskResult::SettingPersistFailed { .. } => {
            assert_eq!(
                count, 0,
                "WithRollback + disk Err must SUPPRESS the ACP notification \
                     (Issue 3 — keeps agent and pager state consistent on rollback)",
            );
        }
        other => {
            panic!("expected SettingPersisted or SettingPersistFailed, got {other:?}")
        }
    }
}
/// `session_id: None` suppresses ACP notification unconditionally.
#[tokio::test]
async fn persist_permission_mode_no_session_id_suppresses_acp() {
    let _guard = setup_kigi_home_in_tempdir();
    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
    let counter = spawn_fake_acp_agent(rx);
    let _result = persist_permission_mode_and_notify(
            "always-approve",
            None,
            PermissionModePersist::BestEffort,
            tx,
        )
        .await;
    tokio::task::yield_now().await;
    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    assert_eq!(
        counter.load(Ordering::SeqCst), 0,
        "session_id=None must suppress the ACP notification — sessionless \
             agents have no ACP channel to notify",
    );
}
/// BestEffort + disk failure must NOT return `SettingPersisted`.
#[tokio::test]
async fn persist_permission_mode_best_effort_failure_returns_dedicated_variant() {
    let _guard = setup_kigi_home_in_tempdir();
    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
    let _counter = spawn_fake_acp_agent(rx);
    let result = persist_permission_mode_and_notify(
            "always-approve",
            None,
            PermissionModePersist::BestEffort,
            tx,
        )
        .await;
    match result {
        TaskResult::SettingPersisted { key, value } => {
            assert_eq!(key, "permission_mode");
            assert_eq!(value, crate ::settings::SettingValue::Enum("always-approve"));
        }
        TaskResult::SettingPersistFailedBestEffort { key, error: _ } => {
            assert_eq!(
                key, "permission_mode",
                "BestEffort failure MUST report key=permission_mode and \
                     NOT lie about success via SettingPersisted",
            );
        }
        other => {
            panic!(
                "BestEffort must return SettingPersisted (Ok) or \
                 SettingPersistFailedBestEffort (Err), got {other:?} — \
                 a regression to `SettingPersisted` on failure is Round-2 Issue 2",
            )
        }
    }
}
/// (Err, WithRollback) → SUPPRESS for all canonicals.
#[test]
fn should_send_yolo_acp_with_rollback_suppresses_on_err() {
    let result: Result<(), String> = Err("simulated disk failure".to_string());
    assert!(
        ! should_send_yolo_acp_notification(& result,
        PermissionModePersist::WithRollback("ask")),
        "WithRollback + Err MUST suppress the ACP notification",
    );
    assert!(
        ! should_send_yolo_acp_notification(& result,
        PermissionModePersist::WithRollback("always-approve")),
        "WithRollback + Err MUST suppress regardless of the prior canonical",
    );
    assert!(
        ! should_send_yolo_acp_notification(& result,
        PermissionModePersist::WithRollback("default")),
        "WithRollback + Err MUST suppress for the 'default' prior canonical too",
    );
}
/// (Ok, WithRollback) → FIRE for all canonicals.
#[test]
fn should_send_yolo_acp_with_rollback_fires_on_ok() {
    let ok: Result<(), String> = Ok(());
    assert!(
        should_send_yolo_acp_notification(& ok,
        PermissionModePersist::WithRollback("ask")),
        "WithRollback + Ok must fire the ACP notification (happy path)",
    );
    assert!(
        should_send_yolo_acp_notification(& ok,
        PermissionModePersist::WithRollback("always-approve")),
        "WithRollback + Ok fires regardless of the prior canonical",
    );
    assert!(
        should_send_yolo_acp_notification(& ok,
        PermissionModePersist::WithRollback("default")),
        "WithRollback + Ok fires for 'default' prior canonical too",
    );
}
#[test]
fn should_send_yolo_acp_best_effort_fires_on_both_outcomes() {
    let ok: Result<(), String> = Ok(());
    let err: Result<(), String> = Err("simulated".to_string());
    assert!(
        should_send_yolo_acp_notification(& ok, PermissionModePersist::BestEffort),
        "BestEffort + Ok must notify",
    );
    assert!(
        should_send_yolo_acp_notification(& err, PermissionModePersist::BestEffort),
        "BestEffort + Err must STILL notify (cycle_mode contract \
             — the cycle_mode state machine doesn't have a clean \
             single-field rollback)",
    );
}
#[test]
fn route_permission_mode_result_ok_returns_persisted() {
    let result = route_permission_mode_result(
        Ok(()),
        PermissionModePersist::WithRollback("ask"),
        "always-approve",
    );
    match result {
        TaskResult::SettingPersisted { key, value } => {
            assert_eq!(key, "permission_mode");
            assert_eq!(value, crate ::settings::SettingValue::Enum("always-approve"));
        }
        other => panic!("Ok must return SettingPersisted, got {other:?}"),
    }
}
#[test]
fn route_permission_mode_result_err_with_rollback_off_routes_to_failed() {
    let result = route_permission_mode_result(
        Err("simulated".to_string()),
        PermissionModePersist::WithRollback("ask"),
        "always-approve",
    );
    match result {
        TaskResult::SettingPersistFailed { key, rollback_value, error } => {
            assert_eq!(key, "permission_mode");
            assert_eq!(rollback_value, crate ::settings::SettingValue::Enum("ask"));
            assert_eq!(error, "simulated");
        }
        other => {
            panic!("WithRollback + Err must return SettingPersistFailed, got {other:?}")
        }
    }
}
#[test]
fn route_permission_mode_result_err_with_rollback_on_routes_to_failed() {
    let result = route_permission_mode_result(
        Err("simulated".to_string()),
        PermissionModePersist::WithRollback("always-approve"),
        "ask",
    );
    match result {
        TaskResult::SettingPersistFailed { key, rollback_value, error } => {
            assert_eq!(key, "permission_mode");
            assert_eq!(
                rollback_value, crate ::settings::SettingValue::Enum("always-approve"),
                "prev_canonical='always-approve' must route to canonical \
                     'always-approve' for rollback",
            );
            assert_eq!(error, "simulated");
        }
        other => {
            panic!("WithRollback + Err must return SettingPersistFailed, got {other:?}")
        }
    }
}
/// Rollback preserves "default" canonical (not collapsed to "ask").
#[test]
fn route_permission_mode_result_err_with_rollback_default_routes_to_failed() {
    let result = route_permission_mode_result(
        Err("simulated".to_string()),
        PermissionModePersist::WithRollback("default"),
        "always-approve",
    );
    match result {
        TaskResult::SettingPersistFailed { key, rollback_value, error } => {
            assert_eq!(key, "permission_mode");
            assert_eq!(
                rollback_value, crate ::settings::SettingValue::Enum("default"),
                "PR 11: prev_canonical='default' must roll back to canonical 'default', \
                     NOT collapse onto 'ask' through a bool projection",
            );
            assert_eq!(error, "simulated");
        }
        other => {
            panic!("WithRollback + Err must return SettingPersistFailed, got {other:?}")
        }
    }
}
/// Ok path preserves "default" canonical verbatim.
#[test]
fn route_permission_mode_result_ok_preserves_default_canonical() {
    let result = route_permission_mode_result(
        Ok(()),
        PermissionModePersist::WithRollback("ask"),
        "default",
    );
    match result {
        TaskResult::SettingPersisted { key, value } => {
            assert_eq!(key, "permission_mode");
            assert_eq!(
                value, crate ::settings::SettingValue::Enum("default"),
                "PR 11: 'default' canonical must survive the route fn intact",
            );
        }
        other => panic!("Ok must return SettingPersisted, got {other:?}"),
    }
}
/// `(Err, BestEffort)` must NOT return `SettingPersisted`.
#[test]
fn route_permission_mode_result_err_best_effort_routes_to_dedicated_variant() {
    let result = route_permission_mode_result(
        Err("simulated".to_string()),
        PermissionModePersist::BestEffort,
        "always-approve",
    );
    match result {
        TaskResult::SettingPersistFailedBestEffort { key, error } => {
            assert_eq!(key, "permission_mode");
            assert_eq!(error, "simulated");
        }
        TaskResult::SettingPersisted { .. } => {
            panic!(
                "BestEffort + Err MUST NOT return SettingPersisted — that would lie about \
                 success on disk failure (Round-2 Issue 2 regression)",
            )
        }
        other => {
            panic!(
                "BestEffort + Err must return SettingPersistFailedBestEffort, got {other:?}",
            )
        }
    }
}
#[tokio::test]
async fn foreign_scan_task_echoes_sequence_without_enabled_sources() {
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    let (progress_tx, _progress_rx) = tokio::sync::mpsc::unbounded_channel();
    let mut tasks = JoinSet::new();
    let app_coordinator = crate::app::ForeignScanCoordinator::default();
    app_coordinator.begin_request(41);
    execute(
        Effect::ScanForeignSessions {
            cwd: PathBuf::from("/path/that/must/not/be-read"),
            compat: kigi_workspace::foreign_sessions::EnabledForeignSessionSources::default(),
            kigi_home: PathBuf::from("/path/that/must/not/be-read"),
            coordinator: app_coordinator.clone(),
            seq: 41,
        },
        &mut tasks,
        &tx,
        Path::new("."),
        &SessionFlags::default(),
        &progress_tx,
    );
    match tasks.join_next().await.expect("task").expect("no panic") {
        TaskResult::ForeignSessionsScanned { entries, seq } => {
            assert!(entries.is_empty());
            assert_eq!(seq, 41);
        }
        other => panic!("expected ForeignSessionsScanned, got {other:?}"),
    }
    drop(app_coordinator);
}
#[tokio::test]
async fn foreign_resume_detection_runs_as_task_result() {
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    let (progress_tx, _progress_rx) = tokio::sync::mpsc::unbounded_channel();
    let mut tasks = JoinSet::new();
    let (quit, _) = execute(
        Effect::CanonicalizeForeignResumeCwd {
            requested_cwd: PathBuf::from("/path/that/does-not-exist"),
            launch_token: 7,
        },
        &mut tasks,
        &tx,
        Path::new("."),
        &SessionFlags::default(),
        &progress_tx,
    );
    assert!(! quit);
    match tasks.join_next().await.expect("task").expect("no panic") {
        TaskResult::ForeignResumeCwdCanonicalized {
            canonical_cwd,
            launch_token,
            ..
        } => {
            assert!(canonical_cwd.is_none());
            assert_eq!(launch_token, 7);
        }
        other => panic!("expected ForeignResumeCwdCanonicalized, got {other:?}"),
    }
    let canonical_cwd = dunce::canonicalize(tempfile::tempdir().unwrap().path())
        .unwrap();
    let (quit, _) = execute(
        Effect::DetectForeignResumeHint {
            canonical_cwd: canonical_cwd.clone(),
            compat: kigi_workspace::foreign_sessions::EnabledForeignSessionSources::default(),
            kigi_home: PathBuf::from("/path/that/must/not-be-read"),
            launch_token: 8,
        },
        &mut tasks,
        &tx,
        Path::new("."),
        &SessionFlags::default(),
        &progress_tx,
    );
    assert!(! quit);
    match tasks.join_next().await.expect("task").expect("no panic") {
        TaskResult::ForeignResumeHintDetected {
            canonical_cwd: result_cwd,
            launch_token,
            hint,
        } => {
            assert_eq!(result_cwd, canonical_cwd);
            assert_eq!(launch_token, 8);
            assert!(hint.is_none());
        }
        other => panic!("expected ForeignResumeHintDetected, got {other:?}"),
    }
}
/// `Effect::FetchSessionList` wire shape + echoes: a search fetch puts
/// `query` into the outgoing params, a plain fetch keeps the key ABSENT,
/// and every outcome echoes `seq`/`query` (the stale-drop guard and the
/// fetch-query stamp depend on the echoes).
#[tokio::test]
async fn fetch_session_list_pushes_query_and_echoes_seq() {
    use std::sync::{Arc, Mutex};
    use kigi_acp_lib::AcpAgentMessage;
    let captured: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
    let captured_for_task = captured.clone();
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
    tokio::spawn(async move {
        while let Some(msg) = rx.recv().await {
            if let AcpAgentMessage::ExtMethod(args) = msg {
                assert_eq!(args.request.method.as_ref(), "kigi/session/list");
                let params: serde_json::Value = serde_json::from_str(
                        args.request.params.get(),
                    )
                    .expect("params JSON");
                let fail = params.get("query").and_then(|q| q.as_str())
                    == Some("fail-me");
                captured_for_task.lock().unwrap().push(params);
                let body = if fail {
                    serde_json::json!({ "error" : "boom" })
                } else {
                    serde_json::json!({ "result" : { "sessions" : [] } })
                };
                let raw = serde_json::value::RawValue::from_string(body.to_string())
                    .expect("serialize list response");
                let _ = args.response_tx.send(Ok(acp::ExtResponse::new(Arc::from(raw))));
            }
        }
    });
    let (progress_tx, _progress_rx) = tokio::sync::mpsc::unbounded_channel();
    let run = |effect: Effect| {
        let mut tasks = JoinSet::new();
        execute(
            effect,
            &mut tasks,
            &tx,
            Path::new("."),
            &SessionFlags::default(),
            &progress_tx,
        );
        tasks
    };
    let mut tasks = run(Effect::FetchSessionList {
        query: Some("hit".into()),
        seq: 7,
    });
    match tasks.join_next().await.expect("task").expect("no panic") {
        TaskResult::SessionListLoaded { sessions, seq, query, .. } => {
            assert!(sessions.is_empty());
            assert_eq!(seq, 7, "seq must be echoed, not reconstructed");
            assert_eq!(query.as_deref(), Some("hit"), "query must be echoed");
        }
        other => panic!("expected SessionListLoaded, got {other:?}"),
    }
    let mut tasks = run(Effect::FetchSessionList {
        query: None,
        seq: 8,
    });
    match tasks.join_next().await.expect("task").expect("no panic") {
        TaskResult::SessionListLoaded { seq, query, .. } => {
            assert_eq!(seq, 8);
            assert_eq!(query, None);
        }
        other => panic!("expected SessionListLoaded, got {other:?}"),
    }
    let mut tasks = run(Effect::FetchSessionList {
        query: Some("fail-me".into()),
        seq: 9,
    });
    match tasks.join_next().await.expect("task").expect("no panic") {
        TaskResult::SessionListFailed { error, seq, query } => {
            assert_eq!(error, "boom");
            assert_eq!(seq, 9);
            assert_eq!(
                query.as_deref(), Some("fail-me"),
                "failure must echo the query (gates the indicator clear)"
            );
        }
        other => panic!("expected SessionListFailed, got {other:?}"),
    }
    let captured = captured.lock().unwrap();
    assert_eq!(captured.len(), 3);
    assert_eq!(captured[0] ["query"], "hit");
    assert_eq!(captured[0] ["limit"], 30);
    assert!(captured[0] ["cwd"].is_string());
    assert!(
        captured[1].get("query").is_none(),
        "plain fetch must not send a query key: {:?}", captured[1]
    );
    assert_eq!(captured[2] ["query"], "fail-me");
}
/// The debounce arm must echo `query` and `seq` exactly. Awaits the real
/// 250 ms debounce (tokio's paused clock needs `test-util`, not enabled
/// in this crate).
#[tokio::test]
async fn debounce_session_search_echoes_query_and_seq() {
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    let (progress_tx, _progress_rx) = tokio::sync::mpsc::unbounded_channel();
    let mut tasks = JoinSet::new();
    execute(
        Effect::DebounceSessionSearch {
            query: "abc".into(),
            seq: 9,
        },
        &mut tasks,
        &tx,
        Path::new("."),
        &SessionFlags::default(),
        &progress_tx,
    );
    match tasks.join_next().await.expect("task").expect("no panic") {
        TaskResult::SessionSearchDebounceExpired { query, seq } => {
            assert_eq!(query, "abc");
            assert_eq!(seq, 9);
        }
        other => panic!("expected SessionSearchDebounceExpired, got {other:?}"),
    }
}
/// Verify that every profile name produced by `SessionFlags::agent_profile()`
/// is a valid `BuiltinAgentName` that the shell can resolve.
#[test]
fn agent_profile_names_are_valid_builtins() {
    use std::str::FromStr;
    use kigi_agent::config::BuiltinAgentName;
    let test_cases: &[(SessionFlags, &str)] = &[
        (
            SessionFlags {
                plan_mode: true,
                subagents: true,
                ask_user: false,
                ..Default::default()
            },
            "kigi-plan",
        ),
        (
            SessionFlags {
                plan_mode: true,
                subagents: false,
                ask_user: false,
                ..Default::default()
            },
            "kigi-plan-no-subagents",
        ),
        (
            SessionFlags {
                plan_mode: true,
                subagents: true,
                ask_user: true,
                ..Default::default()
            },
            "kigi-plan",
        ),
        (
            SessionFlags {
                plan_mode: true,
                subagents: false,
                ask_user: true,
                ..Default::default()
            },
            "kigi-plan-no-subagents",
        ),
        (
            SessionFlags {
                plan_mode: false,
                subagents: false,
                ask_user: true,
                ..Default::default()
            },
            "kigi-ask-user",
        ),
        (
            SessionFlags {
                plan_mode: false,
                subagents: true,
                ask_user: true,
                ..Default::default()
            },
            "kigi-ask-user",
        ),
    ];
    for (flags, expected_name) in test_cases {
        let profile = flags.agent_profile();
        assert_eq!(
            profile, Some(* expected_name),
            "flags {flags:?} should produce profile {expected_name:?}"
        );
        let builtin = BuiltinAgentName::from_str(expected_name);
        assert!(
            builtin.is_ok(),
            "profile name {expected_name:?} is not a valid BuiltinAgentName: {:?}",
            builtin.err()
        );
    }
}
/// Default flags produce no agent profile (uses kigi default).
#[test]
fn default_flags_produce_no_profile() {
    let flags = SessionFlags::default();
    assert_eq!(flags.agent_profile(), None);
}
/// --subagents alone produces no profile (kigi already has TaskTool).
#[test]
fn subagents_without_plan_produces_no_profile() {
    let flags = SessionFlags {
        plan_mode: false,
        subagents: true,
        ask_user: false,
        ..Default::default()
    };
    assert_eq!(flags.agent_profile(), None);
}
/// Neutralize `KIGI_AGENT` for the profile-matrix tests below: agent-driven
/// dev shells export it, which flips `to_meta` into the defer-to-shell
/// escape hatch and drops `agentProfile` — the tests would then assert the
/// wrong branch. Empty string counts as unset (`!s.trim().is_empty()`).
/// Callers must be `#[serial_test::serial(KIGI_AGENT)]` (process-global env).
fn without_kigi_agent() -> crate::test_util::EnvVarGuard {
    crate::test_util::EnvVarGuard::set("KIGI_AGENT", "")
}
/// At the runtime defaults (every `--no-*` flag false → every
/// `SessionFlags` bool true via `!args.no_*`), `to_meta()` reflects the
/// full plan profile and no separate `askUserQuestion` toggle.
#[serial_test::serial(KIGI_AGENT)]
#[test]
fn runtime_default_flags_produce_plan_meta() {
    let _env = without_kigi_agent();
    let flags = SessionFlags {
        plan_mode: true,
        subagents: true,
        ask_user: true,
        ..Default::default()
    };
    let meta = flags.to_meta().unwrap();
    assert_eq!(meta["agentProfile"], "kigi-plan");
    assert!(meta.get("askUserQuestion").is_none());
    assert_eq!(meta["yoloMode"], false);
}
/// --plan alone produces meta with `agentProfile` only and a
/// `askUserQuestion: false` since `ask_user` is off here.
#[serial_test::serial(KIGI_AGENT)]
#[test]
fn plan_only_meta() {
    let _env = without_kigi_agent();
    let flags = SessionFlags {
        plan_mode: true,
        subagents: false,
        ask_user: false,
        ..Default::default()
    };
    let meta = flags.to_meta().unwrap();
    assert_eq!(meta["agentProfile"], "kigi-plan-no-subagents");
    assert_eq!(meta["askUserQuestion"], false);
    assert_eq!(meta["yoloMode"], false);
}
/// --plan --subagents selects the full plan profile.
#[serial_test::serial(KIGI_AGENT)]
#[test]
fn plan_with_subagents_meta() {
    let _env = without_kigi_agent();
    let flags = SessionFlags {
        plan_mode: true,
        subagents: true,
        ask_user: false,
        ..Default::default()
    };
    let meta = flags.to_meta().unwrap();
    assert_eq!(meta["agentProfile"], "kigi-plan");
    assert_eq!(meta["askUserQuestion"], false);
    assert_eq!(meta["yoloMode"], false);
}
/// --ask-user alone selects the kigi-ask-user profile.
#[serial_test::serial(KIGI_AGENT)]
#[test]
fn ask_user_alone_meta() {
    let _env = without_kigi_agent();
    let flags = SessionFlags {
        plan_mode: false,
        subagents: false,
        ask_user: true,
        ..Default::default()
    };
    let meta = flags.to_meta().unwrap();
    assert_eq!(meta["agentProfile"], "kigi-ask-user");
    assert!(meta.get("askUserQuestion").is_none());
    assert_eq!(meta["yoloMode"], false);
}
/// --plan --ask-user: plan already includes ask-user; profile is plan.
#[serial_test::serial(KIGI_AGENT)]
#[test]
fn plan_with_ask_user_uses_plan_profile() {
    let _env = without_kigi_agent();
    let flags = SessionFlags {
        plan_mode: true,
        subagents: false,
        ask_user: true,
        ..Default::default()
    };
    let meta = flags.to_meta().unwrap();
    assert_eq!(meta["agentProfile"], "kigi-plan-no-subagents");
    assert!(meta.get("askUserQuestion").is_none());
    assert_eq!(meta["yoloMode"], false);
}
/// --no-plan --no-subagents --no-ask-user picks the default profile but
/// must still emit `askUserQuestion: false` so the shell can strip the
/// tool at the builder. Mirrors the runtime: `subagents` toggle alone
/// does not need an `agentProfile` (default `kigi` already has it).
#[test]
fn subagents_alone_emits_only_ask_user_question_disable() {
    let flags = SessionFlags {
        plan_mode: false,
        subagents: true,
        ask_user: false,
        ..Default::default()
    };
    let meta = flags.to_meta().expect("askUserQuestion=false must produce meta");
    assert!(meta.get("agentProfile").is_none());
    assert_eq!(meta["askUserQuestion"], false);
}
/// All three flags on at the runtime default produce kigi-plan
/// and no `askUserQuestion` field.
#[serial_test::serial(KIGI_AGENT)]
#[test]
fn all_flags_meta() {
    let _env = without_kigi_agent();
    let flags = SessionFlags {
        plan_mode: true,
        subagents: true,
        ask_user: true,
        ..Default::default()
    };
    let meta = flags.to_meta().unwrap();
    assert_eq!(meta["agentProfile"], "kigi-plan");
    assert!(meta.get("askUserQuestion").is_none());
    assert_eq!(meta["yoloMode"], false);
}
/// `--no-ask-user` is the user-discovered bug — the flag must surface
/// as `_meta.askUserQuestion = false` regardless of which profile (if
/// any) the other flags select.
#[test]
fn to_meta_emits_ask_user_question_false_when_disabled() {
    for plan in [false, true] {
        for subagents in [false, true] {
            let flags = SessionFlags {
                plan_mode: plan,
                subagents,
                ask_user: false,
                ..Default::default()
            };
            let meta = flags
                .to_meta()
                .unwrap_or_else(|| {
                    panic!(
                        "ask_user=false must always emit meta (plan={plan}, subagents={subagents})"
                    )
                });
            assert_eq!(
                meta["askUserQuestion"], false,
                "askUserQuestion must be false (plan={plan}, subagents={subagents}); meta={meta:?}"
            );
        }
    }
}
/// Symmetric positive control: when `ask_user` is enabled the field is
/// omitted entirely (the shell defaults to enabled when the key is
/// absent — see `parse_ask_user_question_from_meta`).
#[test]
fn to_meta_omits_ask_user_question_when_enabled() {
    for plan in [false, true] {
        for subagents in [false, true] {
            let flags = SessionFlags {
                plan_mode: plan,
                subagents,
                ask_user: true,
                ..Default::default()
            };
            if let Some(meta) = flags.to_meta() {
                assert!(
                    meta.get("askUserQuestion").is_none(),
                    "askUserQuestion must be absent when enabled (plan={plan}, subagents={subagents}); meta={meta:?}"
                );
            }
        }
    }
}
#[test]
fn to_meta_emits_auto_mode_when_enabled() {
    let flags = SessionFlags {
        auto_mode: true,
        yolo_mode: false,
        ..Default::default()
    };
    let meta = flags.to_meta().expect("auto_mode must emit meta");
    assert_eq!(meta["autoMode"], true);
    assert_eq!(
        meta["yoloMode"], false,
        "yoloMode must be explicitly false, not omitted (absent key falls \
             back to the shell's connect-time default / leader injection)"
    );
}
/// yoloMode must ride the meta explicitly for BOTH polarities — absent
/// key ≠ off (see the emit-site comment in `to_meta`). Pins the
/// pre-session Always-Approve → Normal cycle not creating a yolo session.
#[test]
fn to_meta_always_emits_yolo_mode_explicitly() {
    for yolo in [false, true] {
        let flags = SessionFlags {
            yolo_mode: yolo,
            ..Default::default()
        };
        let meta = flags.to_meta().expect("permission seeds must always emit meta");
        assert_eq!(
            meta["yoloMode"], serde_json::json!(yolo),
            "yoloMode must be explicit (yolo={yolo}); meta={meta:?}"
        );
    }
}
#[test]
fn to_meta_chat_mode_stamps_kind_and_omits_agent_profile() {
    let flags = SessionFlags {
        chat_mode: true,
        plan_mode: true,
        subagents: true,
        ask_user: true,
        ..Default::default()
    };
    let meta = flags.to_meta().expect("chat_mode must emit meta");
    assert_eq!(meta["kigi/session"] ["kind"], "chat");
    assert!(
        meta.get("agentProfile").is_none(), "K12: chat mode must omit Build agentProfile"
    );
    assert_chat_meta_has_no_workspace_bind_keys(
        &serde_json::Value::Object(meta.clone()),
    );
}
/// Load meta merge: explicit `chat_kind` alone (no process-wide chat_mode)
/// stamps kind and strips agentProfile — conversation resume acceptance.
#[test]
fn load_meta_chat_kind_alone_stamps_kind_and_strips_profile() {
    let flags = SessionFlags {
        chat_mode: false,
        plan_mode: true,
        subagents: true,
        ask_user: true,
        ..Default::default()
    };
    let mut meta = flags.to_meta();
    let chat_kind = true;
    if chat_kind || flags.chat_mode {
        apply_chat_kind_meta(&mut meta);
        scrub_chat_workspace_bind_meta(&mut meta);
    }
    let meta = meta.expect("chat_kind must produce meta");
    assert_eq!(meta["kigi/session"] ["kind"], "chat");
    assert!(
        meta.get("agentProfile").is_none(),
        "entry chat_kind must strip Build agentProfile"
    );
    assert_chat_meta_has_no_workspace_bind_keys(
        &serde_json::Value::Object(meta.clone()),
    );
}
/// Chat create/load meta must never include client workspace-bind keys
/// (`envId`, Direct hub id, gateway attach), even if cloud fields are
/// present on the effect — backend owns workspace for `kind=chat`.
fn assert_chat_meta_has_no_workspace_bind_keys(meta: &serde_json::Value) {
    for key in CHAT_FORBIDDEN_WORKSPACE_BIND_KEYS {
        assert!(
            meta.get(* key).is_none(),
            "chat meta must not include workspace-bind key {key:?}: {meta}"
        );
    }
}
#[test]
fn chat_create_meta_never_includes_workspace_bind_keys_when_cloud_fields_set() {
    let flags = SessionFlags {
        chat_mode: true,
        ..Default::default()
    };
    let mut meta = flags.to_meta();
    apply_chat_kind_meta(&mut meta);
    scrub_chat_workspace_bind_meta(&mut meta);
    let meta = meta.expect("chat create must emit meta");
    assert_eq!(meta["kigi/session"] ["kind"], "chat");
    assert_chat_meta_has_no_workspace_bind_keys(
        &serde_json::Value::Object(meta.clone()),
    );
}
#[test]
fn chat_load_meta_never_includes_workspace_bind_keys() {
    let flags = SessionFlags::default();
    let mut meta = flags.to_meta();
    apply_chat_kind_meta(&mut meta);
    {
        let obj = meta.get_or_insert_with(acp::Meta::new);
        obj.insert("envId".into(), serde_json::json!("env-poison"));
        obj.insert("kigi/cloud_server_id".into(), serde_json::json!("srv-poison"));
        obj.insert(
            "kigi/cloud_existing_workspace".into(),
            serde_json::json!({ "server_id" : "srv-poison", "cwd" : "/ws", }),
        );
    }
    scrub_chat_workspace_bind_meta(&mut meta);
    let meta = meta.expect("chat load must emit meta");
    assert_eq!(meta["kigi/session"] ["kind"], "chat");
    assert_chat_meta_has_no_workspace_bind_keys(
        &serde_json::Value::Object(meta.clone()),
    );
}
#[test]
fn to_meta_yolo_suppresses_auto_mode() {
    let flags = SessionFlags {
        auto_mode: true,
        yolo_mode: true,
        ..Default::default()
    };
    let meta = flags.to_meta().expect("yolo must emit meta");
    assert_eq!(meta["yoloMode"], true);
    assert_eq!(
        meta["autoMode"], false,
        "yolo wins; autoMode must be explicitly false (not omitted)"
    );
}
/// Verify that each resolved profile name produces a valid
/// `AgentDefinition` whose name matches the expected kebab-case string.
#[test]
fn agent_profile_definitions_have_correct_names() {
    use std::str::FromStr;
    use kigi_agent::config::BuiltinAgentName;
    for name in [
        "kigi-plan",
        "kigi-plan-no-subagents",
        "kigi-ask-user",
    ] {
        let builtin = BuiltinAgentName::from_str(name).unwrap();
        let def = builtin.definition();
        assert_eq!(
            def.name, name, "definition name should match the kebab-case profile name"
        );
    }
}
fn make_session_info(
    model: &str,
    resolved: Option<&str>,
    used: u64,
    total: u64,
) -> kigi_shell::session::SessionInfoResponse {
    use kigi_shell::session::acp_types::{ContextInfo, SessionInfoData};
    kigi_shell::session::SessionInfoResponse {
        session_id: "test-session-id".into(),
        cwd: "/tmp/test".into(),
        data: SessionInfoData {
            agent_name: None,
            model: Some(model.into()),
            model_display_name: None,
            resolved_model_id: resolved.map(Into::into),
            model_fingerprint: None,
            show_model_fingerprint: false,
            api_backend: None,
            conversation_id: None,
            turns: 0,
            turn_index: 0,
            context: ContextInfo {
                used,
                total,
                auto_compact_threshold_percent: 85,
                ..Default::default()
            },
        },
    }
}
#[test]
fn format_session_info_shows_conversation_id_when_present() {
    let mut info = make_session_info("auto", None, 1000, 10000);
    info.data.conversation_id = Some("conv_abc123".into());
    let text = format_session_info(&info, None, false);
    assert!(text.contains("Conversation ID: conv_abc123"));
    assert!(text.contains("Session ID: test-session-id"));
}
#[test]
fn format_session_info_shows_resolved_when_enabled_and_different() {
    let info = make_session_info("kigi-4.5", Some("kigi-4.3"), 1000, 10000);
    let text = format_session_info(&info, None, true);
    assert!(text.contains("Model: kigi-4.5 (kigi-4.3)"));
}
#[test]
fn format_session_info_hides_resolved_when_disabled() {
    let info = make_session_info("kigi-4.5", Some("kigi-4.3"), 1000, 10000);
    let text = format_session_info(&info, None, false);
    assert!(text.contains("Model: kigi-4.5"));
    assert!(! text.contains("kigi-4.3"));
}
#[test]
fn format_session_info_no_parens_when_resolved_matches_requested() {
    let info = make_session_info("kigi-4.5", Some("kigi-4.5"), 1000, 10000);
    let text = format_session_info(&info, None, true);
    assert!(text.contains("Model: kigi-4.5"));
    assert!(! text.contains("(kigi-4.5)"));
}
#[test]
fn format_session_info_shows_model_hash_when_catalog_flag_set() {
    let mut info = make_session_info("v9", None, 1000, 10000);
    info.data.model_fingerprint = Some("abc123".into());
    info.data.show_model_fingerprint = true;
    let text = format_session_info(&info, None, false);
    assert!(text.contains("Model Hash: abc123"));
}
#[test]
fn format_session_info_hides_model_hash_for_noncoding_without_flag() {
    let mut info = make_session_info("v9", None, 1000, 10000);
    info.data.model_fingerprint = Some("abc123".into());
    info.data.show_model_fingerprint = false;
    let text = format_session_info(&info, None, false);
    assert!(! text.contains("Model Hash"));
}
#[test]
fn format_session_info_shows_model_hash_for_coding_slug_without_flag() {
    let mut info = make_session_info("kimi-for-coding", None, 1000, 10000);
    info.data.model_fingerprint = Some("abc123".into());
    info.data.show_model_fingerprint = false;
    let text = format_session_info(&info, None, false);
    assert!(text.contains("Model Hash: abc123"));
}
#[test]
fn session_picker_summary_strips_skill_xml() {
    use kigi_tools::implementations::skills::skill::extract_skill_display_text;
    let summary = "<command-name>pr-babysit</command-name>\n\
                        <command-message>/pr-babysit</command-message>\n\
                        <command-args>check</command-args>"
        .to_string();
    let display = extract_skill_display_text(&summary).unwrap_or(summary);
    assert_eq!(display, "/pr-babysit check");
}
#[test]
fn session_picker_summary_preserves_normal_text() {
    use kigi_tools::implementations::skills::skill::extract_skill_display_text;
    let summary = "Fix authentication bug in login flow".to_string();
    let display = extract_skill_display_text(&summary).unwrap_or(summary);
    assert_eq!(display, "Fix authentication bug in login flow");
}
#[test]
fn sanitize_user_error_strips_auth_prefixes() {
    assert_eq!(
        sanitize_user_error("Authentication required: Login timed out after 10 minutes. Please try again."),
        "Login timed out after 10 minutes. Please try again."
    );
    assert_eq!(
        sanitize_user_error("Authentication failed: something went wrong"),
        "something went wrong"
    );
    assert_eq!(
        sanitize_user_error("Login timed out after 10 minutes. Please try again."),
        "Login timed out after 10 minutes. Please try again."
    );
}
#[test]
fn sanitize_user_error_collapses_disk_full() {
    assert_eq!(
        sanitize_user_error("couldn't create worktree: Internal error: \"workspace error: Worktree creation failed: not enough free disk space\""),
        "Out of disk space."
    );
    assert_eq!(
        sanitize_user_error("couldn't create worktree: failed to copy index: No space left on device (os error 28)"),
        "Out of disk space."
    );
    assert_eq!(
        sanitize_user_error("couldn't create worktree: failed to get HEAD commit from source"),
        "couldn't create worktree: failed to get HEAD commit from source"
    );
}
/// A resume-picker entry converts to a **dormant** dashboard roster row
/// (the non-leader idle source) preserving title, cwd, model, worktree
/// flag, origin, and last-change time.
#[test]
fn session_picker_entry_maps_to_dormant_roster_row() {
    use crate::app::app_view::SessionPickerEntry;
    use crate::app::roster::RosterActivity;
    let updated = chrono::Utc::now();
    let entry = SessionPickerEntry {
        id: "sess-1".to_string(),
        summary: "Wire up dashboard".to_string(),
        updated_at: updated,
        created_at: updated,
        cwd: "/repo/app".to_string(),
        hostname: Some("box".to_string()),
        source: "local".to_string(),
        model_id: Some("kigi-4".to_string()),
        num_messages: 3,
        last_active_at: Some(updated),
        branch: None,
        repo_name: "repo-app".to_string(),
        worktree_label: Some("wt".to_string()),
        card_detail: None,
    };
    let roster = session_picker_entry_to_roster(&entry);
    assert_eq!(roster.session_id, "sess-1");
    assert_eq!(roster.title.as_deref(), Some("Wire up dashboard"));
    assert_eq!(roster.cwd, "/repo/app");
    assert!(roster.is_worktree, "worktree_label present → is_worktree");
    assert_eq!(roster.model_id.as_deref(), Some("kigi-4"));
    assert_eq!(roster.activity, RosterActivity::Dormant);
    assert!(! roster.resident);
    assert_eq!(roster.last_change_unix_ms, updated.timestamp_millis());
    assert_eq!(roster.origin.kind, "local");
    assert_eq!(roster.origin.host.as_deref(), Some("box"));
}
