//! Tests for async task-result application arms.

use super::super::task_result::{
    X11_PRIMARY_PASTE_HINT, maybe_show_x11_primary_paste_hint, show_clipboard_failure,
};
use super::*;

fn foreign_resume_hint(
    tool: kigi_workspace::foreign_sessions::ForeignSessionTool,
) -> kigi_workspace::foreign_sessions::RecentForeignSession {
    kigi_workspace::foreign_sessions::RecentForeignSession {
        tool,
        native_id: "native-session".into(),
        age: std::time::Duration::from_secs(30),
    }
}

#[test]
fn foreign_resume_results_require_launch_token_and_canonical_cwd() {
    let mut launch = test_app();
    launch.foreign_session_compat =
        kigi_workspace::foreign_sessions::EnabledForeignSessionSources {
            cursor: true,
            ..Default::default()
        };
    let Effect::CanonicalizeForeignResumeCwd {
        requested_cwd,
        launch_token,
    } = launch
        .begin_foreign_resume_detection()
        .expect("pristine launch schedules canonicalization")
    else {
        panic!("expected canonicalization effect");
    };
    let canonical_cwd = dunce::canonicalize(&requested_cwd).unwrap();
    let effects = dispatch(
        Action::TaskComplete(TaskResult::ForeignResumeCwdCanonicalized {
            requested_cwd: requested_cwd.clone(),
            canonical_cwd: Some(canonical_cwd.clone()),
            launch_token,
        }),
        &mut launch,
    );
    assert!(matches!(
        effects.as_slice(),
        [Effect::DetectForeignResumeHint {
            canonical_cwd: effect_cwd,
            launch_token: effect_token,
            ..
        }] if effect_cwd == &canonical_cwd && *effect_token == launch_token
    ));
    assert!(launch.foreign_resume_hint().is_none());

    dispatch(
        Action::TaskComplete(TaskResult::ForeignResumeHintDetected {
            canonical_cwd: canonical_cwd.clone(),
            launch_token,
            hint: Some(foreign_resume_hint(
                kigi_workspace::foreign_sessions::ForeignSessionTool::Cursor,
            )),
        }),
        &mut launch,
    );
    assert_eq!(
        launch.foreign_resume_hint().map(|hint| hint.tool),
        Some(kigi_workspace::foreign_sessions::ForeignSessionTool::Cursor)
    );

    let mut stale = test_app();
    stale.foreign_session_compat = launch.foreign_session_compat;
    let Effect::CanonicalizeForeignResumeCwd {
        requested_cwd,
        launch_token,
    } = stale.begin_foreign_resume_detection().unwrap()
    else {
        panic!("expected canonicalization effect");
    };
    let canonical_cwd = dunce::canonicalize(&requested_cwd).unwrap();
    dispatch(
        Action::TaskComplete(TaskResult::ForeignResumeHintDetected {
            canonical_cwd: canonical_cwd.clone(),
            launch_token: launch_token + 1,
            hint: Some(foreign_resume_hint(
                kigi_workspace::foreign_sessions::ForeignSessionTool::Codex,
            )),
        }),
        &mut stale,
    );
    assert!(stale.foreign_resume_hint().is_none());

    stale.cwd = tempfile::tempdir().unwrap().path().to_path_buf();
    dispatch(
        Action::TaskComplete(TaskResult::ForeignResumeCwdCanonicalized {
            requested_cwd,
            canonical_cwd: Some(canonical_cwd),
            launch_token,
        }),
        &mut stale,
    );
    assert!(stale.foreign_resume_hint().is_none());
}

#[test]
fn foreign_resume_result_rejects_startup_conflict_before_completion() {
    let mut app = test_app();
    app.foreign_session_compat = kigi_workspace::foreign_sessions::EnabledForeignSessionSources {
        cursor: true,
        ..Default::default()
    };
    let Effect::CanonicalizeForeignResumeCwd {
        requested_cwd,
        launch_token,
    } = app.begin_foreign_resume_detection().unwrap()
    else {
        panic!("expected canonicalization effect");
    };
    let canonical_cwd = dunce::canonicalize(&requested_cwd).unwrap();
    assert!(app.accept_foreign_resume_canonical_cwd(
        launch_token,
        &requested_cwd,
        Some(canonical_cwd.clone()),
    ));
    app.deferred_startup.new_session = true;

    dispatch(
        Action::TaskComplete(TaskResult::ForeignResumeHintDetected {
            canonical_cwd,
            launch_token,
            hint: Some(foreign_resume_hint(
                kigi_workspace::foreign_sessions::ForeignSessionTool::Cursor,
            )),
        }),
        &mut app,
    );

    assert!(app.foreign_resume_hint().is_none());
}

#[test]
fn x11_primary_hint_requires_canonical_full_miss_outcome() {
    use crate::app::actions::{ClipboardPasteCompletion, ClipboardPasteTarget};
    assert_eq!(
        X11_PRIMARY_PASTE_HINT,
        "Try Shift+Insert to paste selected text"
    );
    let target = ClipboardPasteTarget::AgentPrompt {
        agent_id: AgentId(0),
        images_dir: None,
    };

    for completion in [
        ClipboardPasteCompletion::Handled,
        ClipboardPasteCompletion::Dropped,
        ClipboardPasteCompletion::Failed(
            crate::app::actions::ClipboardPasteFailure::AttachmentRead,
        ),
    ] {
        let mut app = test_app_with_agent();
        maybe_show_x11_primary_paste_hint(true, completion, &target, &mut app);
        assert!(
            app.agents[&AgentId(0)].toast.is_none(),
            "{completion:?} must not show full-miss guidance"
        );
    }

    let mut app = test_app_with_agent();
    maybe_show_x11_primary_paste_hint(false, ClipboardPasteCompletion::FullMiss, &target, &mut app);
    assert!(
        app.agents[&AgentId(0)].toast.is_none(),
        "non-X11 FullMiss must not show X11 guidance"
    );
}

#[test]
fn x11_primary_hint_routes_to_originating_agent() {
    let mut app = test_app_with_agent();
    let origin = AgentId(0);
    let active = AgentId(1);
    let session = make_test_agent_session(&app, active, "active-session");
    app.agents
        .insert(active, AgentView::new(session, ScrollbackState::new()));
    app.active_view = ActiveView::Agent(active);
    let target = crate::app::actions::ClipboardPasteTarget::AgentPrompt {
        agent_id: origin,
        images_dir: None,
    };

    maybe_show_x11_primary_paste_hint(
        true,
        crate::app::actions::ClipboardPasteCompletion::FullMiss,
        &target,
        &mut app,
    );

    assert_eq!(
        app.agents[&origin]
            .toast
            .as_ref()
            .map(|(msg, _)| msg.as_str()),
        Some(X11_PRIMARY_PASTE_HINT),
    );
    assert!(
        app.agents[&active].toast.is_none(),
        "an unrelated active agent must not receive X11 paste guidance"
    );
}

#[test]
fn x11_primary_hint_routes_to_originating_dashboard() {
    let mut app = test_app_with_agent();
    open_dashboard(&mut app);
    app.active_view = ActiveView::Agent(AgentId(0));

    maybe_show_x11_primary_paste_hint(
        true,
        crate::app::actions::ClipboardPasteCompletion::FullMiss,
        &crate::app::actions::ClipboardPasteTarget::DashboardDispatch,
        &mut app,
    );

    assert_eq!(
        app.dashboard
            .as_ref()
            .and_then(|dashboard| dashboard.error_toast.as_deref()),
        Some(X11_PRIMARY_PASTE_HINT),
    );
    assert!(
        app.agents[&AgentId(0)].toast.is_none(),
        "an unrelated active agent must not receive dashboard guidance"
    );
}

#[test]
fn clipboard_failure_routes_to_originating_agent_without_duplicate() {
    let mut app = test_app_with_agent();
    let origin = AgentId(0);
    let active = AgentId(1);
    let session = make_test_agent_session(&app, active, "active-session");
    app.agents
        .insert(active, AgentView::new(session, ScrollbackState::new()));
    app.active_view = ActiveView::Agent(active);
    let target = crate::app::actions::ClipboardPasteTarget::AgentPrompt {
        agent_id: origin,
        images_dir: None,
    };

    show_clipboard_failure(
        &target,
        crate::app::actions::ClipboardPasteFailure::TextRead,
        &mut app,
    );
    assert_eq!(
        app.agents[&origin]
            .toast
            .as_ref()
            .map(|(text, _)| text.as_str()),
        Some("Couldn't read clipboard text")
    );
    assert!(app.agents[&active].toast.is_none());

    app.agents[&origin].toast = Some(("Couldn't save pasted image".to_owned(), 90));
    show_clipboard_failure(
        &target,
        crate::app::actions::ClipboardPasteFailure::AlreadyReported,
        &mut app,
    );
    assert_eq!(
        app.agents[&origin]
            .toast
            .as_ref()
            .map(|(text, _)| text.as_str()),
        Some("Couldn't save pasted image")
    );
}

#[test]
fn clipboard_failure_routes_to_originating_dashboard() {
    let mut app = test_app_with_agent();
    open_dashboard(&mut app);
    app.active_view = ActiveView::Agent(AgentId(0));

    show_clipboard_failure(
        &crate::app::actions::ClipboardPasteTarget::DashboardDispatch,
        crate::app::actions::ClipboardPasteFailure::AttachmentRead,
        &mut app,
    );

    assert_eq!(
        app.dashboard
            .as_ref()
            .and_then(|dashboard| dashboard.error_toast.as_deref()),
        Some("Couldn't read clipboard contents")
    );
    assert!(app.agents[&AgentId(0)].toast.is_none());
}

#[test]
fn plugins_action_success_sets_result_notice_and_autoreload_preserves_it() {
    use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab};

    let mut app = test_app_with_agent();
    let id = AgentId(0);
    {
        let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
        // Simulate a per-row action (`u` update) in flight on row 2.
        modal.pending_entry_index = Some(2);
        app.agents.get_mut(&id).unwrap().extensions_modal = Some(modal);
    }

    // A successful update surfaces its message as a row-anchored result notice
    // (the fix for "I can't tell what happened after r/u"), non-covering.
    dispatch(
        Action::TaskComplete(TaskResult::PluginsActionResult {
            agent_id: id,
            result: Ok(kigi_hooks_plugins_types::ActionOutcome {
                status: kigi_hooks_plugins_types::OutcomeStatus::Success,
                message: "user/abcd1234/my-plugin: updated".into(),
                requires_reload: true,
                requires_restart: false,
            }),
        }),
        &mut app,
    );
    {
        let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
        let n = modal
            .result_notice
            .as_ref()
            .expect("success must set a result notice");
        assert_eq!(n.message, "user/abcd1234/my-plugin: updated");
        assert_eq!(n.entry_index, Some(2), "anchored to the acted row");
    }

    // The auto-reload chained from `requires_reload` returns a generic message;
    // it must NOT clobber the specific update notice already on screen.
    dispatch(
        Action::TaskComplete(TaskResult::PluginsActionResult {
            agent_id: id,
            result: Ok(kigi_hooks_plugins_types::ActionOutcome {
                status: kigi_hooks_plugins_types::OutcomeStatus::Success,
                message: "Plugin registry rebuilt: 5 plugin(s).".into(),
                requires_reload: false,
                requires_restart: false,
            }),
        }),
        &mut app,
    );
    let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
    let n = modal.result_notice.as_ref().expect("notice still present");
    assert_eq!(
        n.message, "user/abcd1234/my-plugin: updated",
        "auto-reload must not clobber the triggering action's notice"
    );
}

#[test]
fn tab_wide_action_success_sets_tab_wide_result_notice() {
    use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab};

    let mut app = test_app_with_agent();
    let id = AgentId(0);
    {
        let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
        // Tab-wide reload carries no row anchor.
        modal.pending_entry_index = None;
        app.agents.get_mut(&id).unwrap().extensions_modal = Some(modal);
    }

    dispatch(
        Action::TaskComplete(TaskResult::PluginsActionResult {
            agent_id: id,
            result: Ok(kigi_hooks_plugins_types::ActionOutcome {
                status: kigi_hooks_plugins_types::OutcomeStatus::Success,
                message: "Plugin registry rebuilt: 7 plugin(s).".into(),
                requires_reload: false,
                requires_restart: false,
            }),
        }),
        &mut app,
    );
    let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
    let n = modal.result_notice.as_ref().expect("result notice set");
    assert_eq!(n.entry_index, None, "tab-wide action → footer status line");
    assert_eq!(n.message, "Plugin registry rebuilt: 7 plugin(s).");
}

#[test]
fn uninstall_result_notice_is_footer_only_not_row_anchored() {
    use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab};

    let mut app = test_app_with_agent();
    let id = AgentId(0);
    {
        let mut modal = ExtensionsModalState::new(ExtensionsTab::Plugins);
        // A row action was in flight, but it's an uninstall — the row goes away.
        modal.pending_entry_index = Some(1);
        modal.last_plugins_action = Some(kigi_hooks_plugins_types::PluginsAction::Uninstall {
            plugin_id: "user/ab12/gone".into(),
            confirmed: true,
        });
        app.agents.get_mut(&id).unwrap().extensions_modal = Some(modal);
    }

    dispatch(
        Action::TaskComplete(TaskResult::PluginsActionResult {
            agent_id: id,
            result: Ok(kigi_hooks_plugins_types::ActionOutcome {
                status: kigi_hooks_plugins_types::OutcomeStatus::Success,
                message: "Uninstalled repo \"user/ab12/gone\" (1 plugin(s): gone)".into(),
                requires_reload: true,
                requires_restart: false,
            }),
        }),
        &mut app,
    );
    let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
    let n = modal.result_notice.as_ref().expect("result notice set");
    assert_eq!(
        n.entry_index, None,
        "uninstall removes the row → footer-only, no stale row checkmark"
    );
}

/// Regression (Bugbot): a failed `kigi/subagent/cancel` RPC must NOT
/// finalize the row — the subagent may still be running. Only a shell
/// response of "nothing live" finalizes it.
#[test]
fn kill_rpc_failure_does_not_finalize_but_nothing_live_does() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let sid = acp::SessionId::new("test-session".to_owned());
    {
        let agent = app.agents.get_mut(&id).unwrap();
        let mut info = make_test_subagent("child-1", "sa-1");
        info.pending_kill = true;
        agent.subagent_sessions.insert("child-1".into(), info);
    }

    // RPC failed → leave the row (the subagent may still be running).
    dispatch_task_result(
        TaskResult::KillSubagentComplete {
            session_id: sid.clone(),
            subagent_id: "sa-1".into(),
            outcome: SubagentKillOutcome::RpcFailed,
        },
        &mut app,
    );
    assert!(
        !app.agents[&id].subagent_sessions["child-1"].finished,
        "a failed cancel RPC must not finalize the row"
    );

    // Nothing live → no finish coming, so the pager finalizes the row.
    dispatch_task_result(
        TaskResult::KillSubagentComplete {
            session_id: sid,
            subagent_id: "sa-1".into(),
            outcome: SubagentKillOutcome::NothingLive { status: None },
        },
        &mut app,
    );
    assert!(
        app.agents[&id].subagent_sessions["child-1"].finished,
        "nothing-live must finalize the orphan row"
    );
}

/// An already-finished orphan's real terminal status (`NothingLive { status:
/// Some(..) }`) is forwarded to the finalized row, not flattened to "cancelled".
#[test]
fn kill_nothing_live_with_status_stamps_real_terminal_status() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let sid = acp::SessionId::new("test-session".to_owned());
    {
        let agent = app.agents.get_mut(&id).unwrap();
        let mut info = make_test_subagent("child-1", "sa-1");
        info.pending_kill = true;
        agent.subagent_sessions.insert("child-1".into(), info);
    }

    dispatch_task_result(
        TaskResult::KillSubagentComplete {
            session_id: sid,
            subagent_id: "sa-1".into(),
            outcome: SubagentKillOutcome::NothingLive {
                status: Some("completed".into()),
            },
        },
        &mut app,
    );
    let info = &app.agents[&id].subagent_sessions["child-1"];
    assert!(info.finished, "already-finished orphan must be finalized");
    assert_eq!(
        info.status.as_deref(),
        Some("completed"),
        "the shell's real terminal status must be stamped, not 'cancelled'"
    );
}

#[test]
fn cancel_complete_does_nothing() {
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::TaskComplete(TaskResult::CancelComplete), &mut app);
    assert!(effects.is_empty());
}

#[test]
fn switch_model_complete_success_updates_model_and_pushes_message() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));

    // Set up available models so the display name can be resolved.
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .models
        .available
        .insert(
            model_id.clone(),
            acp::ModelInfo::new(model_id.clone(), "Kigi 4.5".to_string()),
        );
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .model_switch_pending = true;

    let initial_scrollback = app.agents[&id].scrollback.len();

    let effects = dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id: model_id.clone(),
            effort: None,
            result: Ok(()),
            prev_model_id: None,
        }),
        &mut app,
    );

    // Pending flag cleared.
    assert!(!app.agents[&id].session.model_switch_pending);
    // Current model updated.
    assert_eq!(
        app.agents[&id].session.models.current,
        Some(model_id.clone())
    );
    // Success message pushed to scrollback.
    assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
    // PersistPreferredModel effect emitted.
    assert_eq!(effects.len(), 1);
    assert!(matches!(
        &effects[0],
        Effect::PersistPreferredModel { model_id: mid, .. } if *mid == model_id.clone()
    ));
}

#[test]
fn switch_model_complete_skips_message_and_persist_when_unchanged() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));

    let agent = app.agents.get_mut(&id).unwrap();
    agent.session.models.available.insert(
        model_id.clone(),
        acp::ModelInfo::new(model_id.clone(), "Kigi 4.5".to_string()),
    );
    agent.session.models.current = Some(model_id.clone());
    agent.session.models.reasoning_effort = None;
    agent.session.model_switch_pending = true;

    let before = app.agents[&id].scrollback.len();
    let effects = dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id: model_id.clone(),
            effort: None,
            result: Ok(()),
            prev_model_id: None,
        }),
        &mut app,
    );

    assert!(!app.agents[&id].session.model_switch_pending);
    assert_eq!(app.agents[&id].scrollback.len(), before, "no message added");
    assert!(
        !effects
            .iter()
            .any(|e| matches!(e, Effect::PersistPreferredModel { .. })),
        "no persist effect for no-op switch"
    );
}

#[test]
fn switch_model_complete_persists_resolved_effort_from_catalog_meta() {
    use kigi_shell::sampling::types::ReasoningEffort;
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("byok-model-47"));

    let mut meta = serde_json::Map::new();
    meta.insert(
        "supportsReasoningEffort".into(),
        serde_json::Value::Bool(true),
    );
    meta.insert(
        "reasoningEffort".into(),
        serde_json::Value::String("xhigh".into()),
    );
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .models
        .available
        .insert(
            model_id.clone(),
            acp::ModelInfo::new(model_id.clone(), "BYOK Model 4.7".to_string())
                .meta(serde_json::Value::Object(meta).as_object().cloned()),
        );
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .model_switch_pending = true;

    let effects = dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id: model_id.clone(),
            effort: None, // user typed `/model Blackbox 4.7` with no effort
            result: Ok(()),
            prev_model_id: None,
        }),
        &mut app,
    );

    assert_eq!(
        app.agents[&id].session.models.reasoning_effort,
        Some(ReasoningEffort::Xhigh)
    );

    assert_eq!(effects.len(), 1);
    match &effects[0] {
        Effect::PersistPreferredModel {
            model_id: mid,
            reasoning_effort,
        } => {
            assert_eq!(*mid, model_id);
            assert_eq!(
                *reasoning_effort,
                Some(ReasoningEffort::Xhigh),
                "persisted effort must mirror the live UI effort so a \
                     fresh chat does not reset reasoning to a stale default",
            );
        }
        other => panic!("expected PersistPreferredModel, got {other:?}"),
    }
}

#[test]
fn switch_to_non_reasoning_model_clears_persisted_effort() {
    use kigi_shell::sampling::types::ReasoningEffort;
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));

    // Simulate prior reasoning effort from a previous model.
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .models
        .reasoning_effort = Some(ReasoningEffort::High);

    // The new model does NOT have supportsReasoningEffort in its meta.
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .models
        .available
        .insert(
            model_id.clone(),
            acp::ModelInfo::new(model_id.clone(), "Kigi".to_string()),
        );
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .model_switch_pending = true;

    let effects = dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id: model_id.clone(),
            effort: None,
            result: Ok(()),
            prev_model_id: None,
        }),
        &mut app,
    );

    assert_eq!(
        app.agents[&id].session.models.reasoning_effort, None,
        "reasoning_effort must be cleared when switching to a non-reasoning model",
    );

    assert_eq!(effects.len(), 1);
    match &effects[0] {
        Effect::PersistPreferredModel {
            reasoning_effort, ..
        } => {
            assert_eq!(
                *reasoning_effort, None,
                "persisted effort must be None so config.toml clears the stale value",
            );
        }
        other => panic!("expected PersistPreferredModel, got {other:?}"),
    }
}

#[test]
fn switch_model_complete_failure_pushes_error_and_clears_pending() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("bad-model"));

    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .model_switch_pending = true;
    let old_current = app.agents[&id].session.models.current.clone();
    let initial_scrollback = app.agents[&id].scrollback.len();

    let effects = dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id,
            effort: None,
            result: Err(SwitchModelError::Other("model not found".into())),
            prev_model_id: None,
        }),
        &mut app,
    );

    assert!(effects.is_empty());
    // Pending flag cleared.
    assert!(!app.agents[&id].session.model_switch_pending);
    // Current model unchanged.
    assert_eq!(app.agents[&id].session.models.current, old_current);
    // Error message pushed to scrollback.
    assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
}

#[test]
fn switch_model_incompatible_agent_shows_question_modal() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("cursor-model"));

    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .model_switch_pending = true;
    let initial_scrollback = app.agents[&id].scrollback.len();

    let err = kigi_shell::agent::config::ModelSwitchIncompatibleAgentError {
        code: "MODEL_SWITCH_INCOMPATIBLE_AGENT".into(),
        active_agent_type: "kigi".into(),
        required_agent_type: "cursor".into(),
        model_id: "cursor-model".into(),
        suggestion: "start_new_session".into(),
    };
    let effects = dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id,
            effort: None,
            result: Err(SwitchModelError::IncompatibleAgent {
                error: err,
                prev_model_id: None,
            }),
            prev_model_id: None,
        }),
        &mut app,
    );

    // No effects emitted (modal is synchronous state).
    assert!(effects.is_empty());
    // Pending flag cleared.
    assert!(!app.agents[&id].session.model_switch_pending);
    // Question modal is open.
    assert!(app.agents[&id].question_view.is_some());
    let qv = app.agents[&id].question_view.as_ref().unwrap();
    assert!(matches!(
        qv.local_kind,
        Some(crate::views::question_view::LocalQuestionKind::AgentTypeMismatch { .. })
    ));
    // No error message pushed to scrollback.
    assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback);
}

#[test]
fn incompatible_agent_rollback_restores_previous_model() {
    // When SetDefaultModel optimistically updates models.current and the
    // shell rejects with IncompatibleAgent, the handler must roll back
    // models.current to the prev_model_id.
    let mut app = test_app_with_agent();
    let id = AgentId(0);

    let prev_model = acp::ModelId::new(std::sync::Arc::from("original-model"));
    let new_model = acp::ModelId::new(std::sync::Arc::from("cursor-model"));

    // Set up catalog with both models.
    let agent = app.agents.get_mut(&id).unwrap();
    agent.session.models.available.insert(
        prev_model.clone(),
        acp::ModelInfo::new(prev_model.clone(), "Original".to_string()),
    );
    agent.session.models.available.insert(
        new_model.clone(),
        acp::ModelInfo::new(new_model.clone(), "Cursor".to_string()),
    );
    // Simulate the optimistic update that set_default_model_inner does.
    agent.session.models.set_current(new_model.clone(), None);
    agent.session.model_switch_pending = true;

    assert_eq!(agent.session.models.current, Some(new_model.clone()));

    let err = kigi_shell::agent::config::ModelSwitchIncompatibleAgentError {
        code: "MODEL_SWITCH_INCOMPATIBLE_AGENT".into(),
        active_agent_type: "kigi".into(),
        required_agent_type: "cursor".into(),
        model_id: "cursor-model".into(),
        suggestion: "start_new_session".into(),
    };
    dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id: new_model,
            effort: None,
            result: Err(SwitchModelError::IncompatibleAgent {
                error: err,
                prev_model_id: Some(prev_model.clone()),
            }),
            prev_model_id: Some(prev_model.clone()),
        }),
        &mut app,
    );

    // models.current must be rolled back to the previous model.
    assert_eq!(
        app.agents[&id].session.models.current,
        Some(prev_model),
        "models.current must be rolled back on IncompatibleAgent",
    );
}

#[test]
fn incompatible_agent_closes_active_modal() {
    use crate::views::modal::ActiveModal;

    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("cursor-model"));

    // Open any modal to verify it gets closed.
    let agent = app.agents.get_mut(&id).unwrap();
    agent.active_modal = Some(ActiveModal::CommandPalette {
        entries: vec![],
        state: crate::views::picker::PickerState::input_active(),
        window: crate::views::modal_window::ModalWindowState::new(),
    });
    agent.session.model_switch_pending = true;

    let err = kigi_shell::agent::config::ModelSwitchIncompatibleAgentError {
        code: "MODEL_SWITCH_INCOMPATIBLE_AGENT".into(),
        active_agent_type: "kigi".into(),
        required_agent_type: "cursor".into(),
        model_id: "cursor-model".into(),
        suggestion: "start_new_session".into(),
    };
    dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id,
            effort: None,
            result: Err(SwitchModelError::IncompatibleAgent {
                error: err,
                prev_model_id: None,
            }),
            prev_model_id: None,
        }),
        &mut app,
    );

    // Active modal must be closed.
    assert!(
        app.agents[&id].active_modal.is_none(),
        "active modal must be closed when IncompatibleAgent fires",
    );
    // Question modal must be open.
    assert!(
        app.agents[&id].question_view.is_some(),
        "question modal must be open",
    );
}

#[test]
fn same_agent_type_switch_no_modal() {
    // Switching between two models with the same (or no) agent type
    // should succeed normally — no modal, no IncompatibleAgent error.
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_a = acp::ModelId::new(std::sync::Arc::from("kigi-a"));
    let model_b = acp::ModelId::new(std::sync::Arc::from("kigi-b"));

    // Add both models to the catalog (no agentType → both use kigi).
    let agent = app.agents.get_mut(&id).unwrap();
    agent.session.models.available.insert(
        model_a.clone(),
        acp::ModelInfo::new(model_a.clone(), "Kigi A".to_string()),
    );
    agent.session.models.set_current(model_a, None);
    agent.session.models.available.insert(
        model_b.clone(),
        acp::ModelInfo::new(model_b.clone(), "Kigi B".to_string()),
    );
    agent.session.model_switch_pending = true;

    // Shell returns Ok (same agent type, no mismatch).
    let effects = dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id: model_b.clone(),
            effort: None,
            result: Ok(()),
            prev_model_id: None,
        }),
        &mut app,
    );

    // Model should be switched, no modal.
    assert_eq!(app.agents[&id].session.models.current, Some(model_b));
    assert!(app.agents[&id].question_view.is_none());
    // Should emit PersistPreferredModel.
    assert!(
        effects
            .iter()
            .any(|e| matches!(e, Effect::PersistPreferredModel { .. }))
    );
}

#[test]
fn switch_model_pending_lifecycle() {
    // Full lifecycle: false -> dispatch SwitchModel -> true -> SwitchModelComplete -> false
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));

    // Initially false.
    assert!(!app.agents[&id].session.model_switch_pending);

    // Action sets pending.
    dispatch(
        Action::SwitchModel {
            model_id: model_id.clone(),
            effort: None,
        },
        &mut app,
    );
    assert!(app.agents[&id].session.model_switch_pending);

    // TaskResult clears pending.
    dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id,
            effort: None,
            result: Ok(()),
            prev_model_id: None,
        }),
        &mut app,
    );
    assert!(!app.agents[&id].session.model_switch_pending);
}

#[test]
fn no_deferred_switch_means_no_extra_effect() {
    // When there is no deferred model switch, SessionCreated should
    // not produce a SwitchModel effect.
    let mut app = test_app_with_agent();
    let id = AgentId(0);

    // Remove session_id to simulate pre-session state.
    app.agents.get_mut(&id).unwrap().session.session_id = None;
    assert!(app.agents[&id].session.deferred_model_switch.is_none());

    let effects = dispatch(
        Action::TaskComplete(TaskResult::SessionCreated {
            agent_id: id,
            session_id: "new-session".into(),
            models: None,
        }),
        &mut app,
    );

    // No SwitchModel effect.
    assert!(
        !effects
            .iter()
            .any(|e| matches!(e, Effect::SwitchModel { .. }))
    );
    assert!(!app.agents[&id].session.model_switch_pending);
}

#[test]
fn bundle_status_ready_populates_state() {
    let mut app = test_app();

    dispatch(
        Action::TaskComplete(TaskResult::BundleStatusReady {
            has_cache: true,
            version: Some("v2".into()),
            personas: vec!["researcher".into(), "auditor".into()],
            roles: vec!["reviewer".into()],
            agents: vec!["default".into()],
            skills: vec!["commit".into(), "code-review".into()],
            persona_details: vec![crate::app::bundle::PersonaDetail {
                name: "researcher".into(),
                description: Some("thorough researcher".into()),
                has_inputs: true,
                has_outputs: false,
                source_path: None,
                scope_label: None,
            }],
            role_details: vec![crate::app::bundle::RoleDetail {
                name: "reviewer".into(),
                description: "code reviewer".into(),
            }],
        }),
        &mut app,
    );

    assert!(app.bundle_state.has_cache);
    assert_eq!(app.bundle_state.version, "v2");
    assert_eq!(app.bundle_state.personas, vec!["researcher", "auditor"]);
    assert_eq!(app.bundle_state.roles, vec!["reviewer"]);
    assert_eq!(app.bundle_state.agents, vec!["default"]);
    assert_eq!(app.bundle_state.skills, vec!["commit", "code-review"]);
    assert_eq!(app.bundle_state.persona_details.len(), 1);
    assert_eq!(app.bundle_state.persona_details[0].name, "researcher");
    assert_eq!(app.bundle_state.role_details.len(), 1);
    assert_eq!(app.bundle_state.role_details[0].name, "reviewer");
}

#[test]
fn bundle_status_failed_logs_but_keeps_state() {
    let mut app = test_app();
    app.bundle_state.has_cache = true;
    app.bundle_state.version = "keep-me".into();

    dispatch(
        Action::TaskComplete(TaskResult::BundleStatusFailed {
            error: "status fetch failed".into(),
        }),
        &mut app,
    );

    assert!(app.bundle_state.has_cache);
    assert_eq!(app.bundle_state.version, "keep-me");
}

#[test]
fn catalog_entry_ready_opens_viewer() {
    let mut app = test_app_with_agent();

    dispatch(
        Action::TaskComplete(TaskResult::CatalogEntryReady {
            kind: "persona".into(),
            name: "researcher".into(),
            content: "instructions = \"deep research\"".into(),
        }),
        &mut app,
    );

    let ActiveView::Agent(id) = app.active_view else {
        panic!("expected agent view");
    };
    let agent = app.agents.get(&id).unwrap();
    assert!(agent.block_viewer.is_some());
    let viewer = agent.block_viewer.as_ref().unwrap();
    assert_eq!(
        viewer.kind,
        crate::views::block_viewer::ViewerKind::PlainText
    );
}

#[test]
fn catalog_entry_failed_shows_system_message() {
    let mut app = test_app_with_agent();
    let initial_len = {
        let ActiveView::Agent(id) = app.active_view else {
            panic!("expected agent view");
        };
        app.agents[&id].scrollback.len()
    };

    let effects = dispatch(
        Action::TaskComplete(TaskResult::CatalogEntryFailed {
            error: "not found".into(),
        }),
        &mut app,
    );

    assert!(effects.is_empty());
    let ActiveView::Agent(id) = app.active_view else {
        panic!("expected agent view");
    };
    let agent = app.agents.get(&id).unwrap();
    assert!(agent.block_viewer.is_none());
    assert!(agent.scrollback.len() > initial_len);
}

#[test]
fn available_commands_refreshed_updates_generation() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let gen_before = app.agents[&id].session.available_commands_generation;

    let commands = vec![
        acp::AvailableCommand::new("commit", "Create a commit").meta(
            serde_json::json!({"scope": "local", "path": "/skill/SKILL.md"})
                .as_object()
                .cloned(),
        ),
    ];
    let effects = dispatch(
        Action::TaskComplete(TaskResult::AvailableCommandsRefreshed {
            agent_id: id,
            commands,
        }),
        &mut app,
    );
    assert!(effects.is_empty());
    assert_eq!(
        app.agents[&id].session.available_commands_generation,
        gen_before + 1
    );
    assert_eq!(app.agents[&id].session.available_commands.len(), 1);
    assert_eq!(app.agents[&id].session.available_commands[0].name, "commit");
}

#[test]
fn available_commands_refreshed_empty_is_noop() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let gen_before = app.agents[&id].session.available_commands_generation;

    let effects = dispatch(
        Action::TaskComplete(TaskResult::AvailableCommandsRefreshed {
            agent_id: id,
            commands: vec![],
        }),
        &mut app,
    );
    assert!(effects.is_empty());
    assert_eq!(
        app.agents[&id].session.available_commands_generation,
        gen_before,
    );
}

// -- Session deletion from the /resume picker -----------------------

#[test]
fn delete_session_complete_removes_only_matching_source_and_id() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    let mut foreign_same_id = make_picker_entry("s1", "/r");
    foreign_same_id.source = "codex".into();
    let mut remote_same_id = make_picker_entry("s1", "/r");
    remote_same_id.source = "remote".into();
    open_session_picker_with(
        &mut app,
        vec![
            make_picker_entry("s0", "/r"),
            foreign_same_id,
            make_picker_entry("s1", "/r"),
            remote_same_id,
            make_picker_entry("s2", "/r"),
        ],
    );
    let mut welcome_foreign = make_picker_entry("s1", "/r");
    welcome_foreign.source = "codex".into();
    let mut welcome_remote = make_picker_entry("s1", "/r");
    welcome_remote.source = "remote".into();
    app.session_picker_entries = Some(vec![
        welcome_foreign,
        make_picker_entry("s1", "/r"),
        welcome_remote,
    ]);
    if let Some(ActiveModal::SessionPicker { pending_delete, .. }) = get_active_agent_mut(&mut app)
        .unwrap()
        .active_modal
        .as_mut()
    {
        *pending_delete = Some(("local".into(), "s1".into(), "/r".into()));
    }

    let _ = dispatch_task_result(
        TaskResult::DeleteSessionComplete {
            source: "local".into(),
            session_id: "s1".into(),
        },
        &mut app,
    );

    let agent = get_active_agent(&app).expect("active agent");
    let Some(ActiveModal::SessionPicker {
        entries: Some(list),
        pending_delete,
        ..
    }) = agent.active_modal.as_ref()
    else {
        panic!("expected SessionPicker modal");
    };
    let identities: Vec<_> = list
        .iter()
        .map(|entry| (entry.source.as_str(), entry.id.as_str()))
        .collect();
    assert_eq!(
        identities,
        vec![
            ("local", "s0"),
            ("codex", "s1"),
            ("remote", "s1"),
            ("local", "s2"),
        ]
    );
    assert!(
        pending_delete.is_none(),
        "pending_delete must be cleared after deletion completes"
    );
    let welcome_identities: Vec<_> = app
        .session_picker_entries
        .as_ref()
        .unwrap()
        .iter()
        .map(|entry| (entry.source.as_str(), entry.id.as_str()))
        .collect();
    assert_eq!(welcome_identities, vec![("codex", "s1"), ("remote", "s1")]);
}

#[test]
fn delete_both_session_clears_modal_and_welcome_content_hits() {
    use crate::views::modal::ActiveModal;
    use crate::views::session_picker::{PickerItem, SourceFilter, build_entry_map};

    let mut app = test_app_with_agent();
    let mut both = make_picker_entry("shared", "/r");
    both.source = "both".into();
    let mut foreign = make_picker_entry("shared", "/r");
    foreign.source = "codex".into();
    open_session_picker_with(&mut app, vec![both.clone(), foreign.clone()]);
    let hit = kigi_shell::extensions::session_search::SearchSessionHit {
        session_id: "shared".into(),
        summary: "shared".into(),
        cwd: "/r".into(),
        updated_at: chrono::Utc::now().to_rfc3339(),
        snippet: Some("deleted content".into()),
        score: 1.0,
        matched_fields: vec![],
    };
    if let Some(ActiveModal::SessionPicker {
        state,
        content_results,
        ..
    }) = get_active_agent_mut(&mut app)
        .unwrap()
        .active_modal
        .as_mut()
    {
        state.query = "shared".into();
        *content_results = Some(vec![hit.clone()]);
    }
    app.session_picker_entries = Some(vec![both, foreign]);
    app.session_picker_state.query = "shared".into();
    app.session_picker_content_results = Some(vec![hit]);

    let _ = dispatch_task_result(
        TaskResult::DeleteSessionComplete {
            source: "both".into(),
            session_id: "shared".into(),
        },
        &mut app,
    );

    let agent = get_active_agent(&app).unwrap();
    let Some(ActiveModal::SessionPicker {
        entries: Some(modal_entries),
        content_results: Some(modal_hits),
        state: modal_state,
        ..
    }) = agent.active_modal.as_ref()
    else {
        panic!("expected modal picker");
    };
    assert_eq!(
        modal_entries
            .iter()
            .map(|entry| (entry.source.as_str(), entry.id.as_str()))
            .collect::<Vec<_>>(),
        vec![("codex", "shared")]
    );
    assert!(modal_hits.is_empty());
    let modal_map = build_entry_map(
        Some(modal_entries),
        Some(modal_hits),
        "shared",
        true,
        false,
        SourceFilter::All,
        None,
    );
    assert!(
        !modal_map
            .iter()
            .any(|item| matches!(item, Some(PickerItem::Content { .. })))
    );
    assert_eq!(modal_state.query.as_str(), "shared");

    let welcome_entries = app.session_picker_entries.as_deref().unwrap();
    let welcome_hits = app.session_picker_content_results.as_deref().unwrap();
    assert_eq!(
        welcome_entries
            .iter()
            .map(|entry| (entry.source.as_str(), entry.id.as_str()))
            .collect::<Vec<_>>(),
        vec![("codex", "shared")]
    );
    assert!(welcome_hits.is_empty());
    let welcome_map = build_entry_map(
        Some(welcome_entries),
        Some(welcome_hits),
        "shared",
        false,
        false,
        SourceFilter::All,
        None,
    );
    assert!(
        !welcome_map
            .iter()
            .any(|item| matches!(item, Some(PickerItem::Content { .. })))
    );
}

#[test]
fn delete_remote_session_clears_modal_and_welcome_content_hits() {
    use crate::views::modal::ActiveModal;

    let mut app = test_app_with_agent();
    let mut remote = make_picker_entry("remote-only", "/r");
    remote.source = "remote".into();
    open_session_picker_with(&mut app, vec![remote.clone()]);
    let hit = kigi_shell::extensions::session_search::SearchSessionHit {
        session_id: "remote-only".into(),
        summary: "remote-only".into(),
        cwd: "/r".into(),
        updated_at: chrono::Utc::now().to_rfc3339(),
        snippet: Some("stale deleted content".into()),
        score: 1.0,
        matched_fields: vec![],
    };
    if let Some(ActiveModal::SessionPicker {
        content_results, ..
    }) = get_active_agent_mut(&mut app)
        .unwrap()
        .active_modal
        .as_mut()
    {
        *content_results = Some(vec![hit.clone()]);
    }
    app.session_picker_entries = Some(vec![remote]);
    app.session_picker_content_results = Some(vec![hit]);

    let _ = dispatch_task_result(
        TaskResult::DeleteSessionComplete {
            source: "remote".into(),
            session_id: "remote-only".into(),
        },
        &mut app,
    );

    let Some(ActiveModal::SessionPicker {
        entries: Some(modal_entries),
        content_results: Some(modal_hits),
        ..
    }) = app.agents[&AgentId(0)].active_modal.as_ref()
    else {
        panic!("expected modal picker");
    };
    assert!(modal_entries.is_empty());
    assert!(modal_hits.is_empty());
    assert!(app.session_picker_entries.as_ref().unwrap().is_empty());
    assert!(
        app.session_picker_content_results
            .as_ref()
            .unwrap()
            .is_empty()
    );
}

#[test]
fn delete_session_failed_keeps_all_entries() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    open_session_picker_with(
        &mut app,
        vec![make_picker_entry("s0", "/r"), make_picker_entry("s1", "/r")],
    );

    let _ = dispatch_task_result(
        TaskResult::DeleteSessionFailed {
            source: "local".into(),
            session_id: "s1".into(),
            error: "boom".into(),
        },
        &mut app,
    );

    let agent = get_active_agent(&app).expect("active agent");
    let Some(ActiveModal::SessionPicker {
        entries: Some(list),
        ..
    }) = agent.active_modal.as_ref()
    else {
        panic!("expected SessionPicker modal");
    };
    assert_eq!(list.len(), 2, "a failed delete must not remove any entry");
}

#[test]
fn rename_session_failed_keeps_local_display_name_and_pushes_system_block() {
    // Pins the documented design decision: a failed on-disk rename
    // does NOT roll back the local `display_name` cache (the cache
    // is the source of truth for the modal's rendering and the
    // disk write is best-effort). The user sees the failure via
    // the system-block message instead.
    let mut app = test_app_with_agent();
    // Seed the cache as the rename path would have done.
    if let Some(a) = app.agents.get_mut(&AgentId(0)) {
        a.display_name = Some("optimistic title".into());
    }
    let scrollback_len_before = app.agents[&AgentId(0)].scrollback.len();

    let _effects = dispatch_task_result(
        TaskResult::RenameSessionFailed {
            agent_id: AgentId(0),
            error: "boom".into(),
        },
        &mut app,
    );

    // No rollback.
    assert_eq!(
        app.agents[&AgentId(0)].display_name.as_deref(),
        Some("optimistic title"),
        "display_name must NOT roll back on RenameSessionFailed"
    );
    // System block appended with the error.
    let scrollback = &app.agents[&AgentId(0)].scrollback;
    assert_eq!(
        scrollback.len(),
        scrollback_len_before + 1,
        "system block must be appended"
    );
    let last = scrollback.entry(scrollback.len() - 1).expect("last entry");
    let text = match &last.block {
        crate::scrollback::block::RenderBlock::System(b) => b.text.clone(),
        other => panic!("expected System block, got {other:?}"),
    };
    assert!(
        text.contains("Couldn't rename session: boom"),
        "system block must surface the error; got: {text:?}"
    );
}

/// `apply_setting_rollback` on a known key reverts the in-memory
/// cache without emitting any new effects.
#[test]
fn rollback_known_key_reverts_cache_and_no_effect() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    // Toggle to true first.
    let _ = dispatch(Action::SetCompactMode(true), &mut app);
    assert!(app.current_ui.compact_mode);
    // Now simulate persist failure that rolls back to false.
    let effects = dispatch(
        Action::TaskComplete(TaskResult::SettingPersistFailed {
            key: "compact_mode",
            rollback_value: SettingValue::Bool(false),
            error: "test-error".into(),
        }),
        &mut app,
    );
    assert!(
        effects.is_empty(),
        "rollback path must NOT emit any new Effects (would loop)",
    );
    // Cache is reverted.
    assert!(!app.current_ui.compact_mode);
}

/// `apply_setting_rollback` on an unknown key surfaces a tracing
/// error and a secondary toast, but does not panic.
#[test]
fn rollback_unknown_key_does_not_panic() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    let effects = dispatch(
        Action::TaskComplete(TaskResult::SettingPersistFailed {
            key: "nonexistent_setting",
            rollback_value: SettingValue::Bool(true),
            error: "test-error".into(),
        }),
        &mut app,
    );
    assert!(effects.is_empty());
    // No assertion on cache state (no arm to rollback through);
    // the toast surfaces the inconsistency to the user.
}

#[test]
fn persist_failed_toast_contains_key_and_error() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    let _ = dispatch(
        Action::TaskComplete(TaskResult::SettingPersistFailed {
            key: "compact_mode",
            rollback_value: SettingValue::Bool(false),
            error: "permission denied".into(),
        }),
        &mut app,
    );
    let toast = read_toast(&app);
    assert!(toast.contains("compact_mode"));
    assert!(toast.contains("permission denied"));
    assert!(toast.contains('\u{2717}'));
}

/// Rollback path must revert BOTH `app.current_ui` AND the
/// thread-local cache — failing only on one is the bug class
/// the inner/outer split exists to prevent.
#[test]
fn rollback_reverts_thread_local_cache_too() {
    use crate::settings::SettingValue;
    std::thread::spawn(|| {
        let mut app = test_app_with_agent();
        // Optimistic set: cache → true.
        let _ = dispatch(Action::SetCompactMode(true), &mut app);
        assert!(crate::appearance::cache::load());

        // Synthesize failure: rollback to false.
        let _ = dispatch(
            Action::TaskComplete(TaskResult::SettingPersistFailed {
                key: "compact_mode",
                rollback_value: SettingValue::Bool(false),
                error: "test-error".into(),
            }),
            &mut app,
        );
        assert!(
            !app.current_ui.compact_mode,
            "rollback must update app.current_ui"
        );
        assert!(
            !crate::appearance::cache::load(),
            "rollback must also revert the cache — otherwise the renderer \
                 still shows the optimistic value while current_ui shows the rolled-back one"
        );
    })
    .join()
    .unwrap();
}

/// A default_model persist failure must NOT revert the live session's
/// model. The switch already succeeded in the session (its own failure
/// path reports separately); a DISK write failure only means the default
/// won't stick for the next launch — same policy as PersistPreferredModel
/// ("still active for this session"). Regression: the rollback arm
/// re-showed the ORIGINAL model and issued a reverse SwitchModel, which
/// on Windows (persist failures from AV/indexer file locks) made every
/// picker selection appear to not take.
#[test]
fn default_model_persist_failure_keeps_live_model() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    for (mid, name) in [("old-model", "Old Model"), ("new-model", "New Model")] {
        let model_id = acp::ModelId::new(std::sync::Arc::from(mid));
        let info = acp::ModelInfo::new(model_id.clone(), name.to_string());
        app.agents
            .get_mut(&id)
            .unwrap()
            .session
            .models
            .available
            .insert(model_id.clone(), info.clone());
        app.models.available.insert(model_id, info);
    }
    // User picked the new model; optimistic update applied.
    let _ = dispatch(
        Action::SetDefaultModel(acp::ModelId::new(std::sync::Arc::from("new-model"))),
        &mut app,
    );
    assert_eq!(
        app.agents[&id]
            .session
            .models
            .current
            .as_ref()
            .map(|m| m.0.as_ref()),
        Some("new-model"),
    );

    // Disk persist fails (the Windows sharing-violation shape).
    let effects = dispatch(
        Action::TaskComplete(TaskResult::SettingPersistFailed {
            key: "default_model",
            rollback_value: SettingValue::String("Old Model".into()),
            error: "Access is denied. (os error 5)".into(),
        }),
        &mut app,
    );

    assert_eq!(
        app.agents[&id]
            .session
            .models
            .current
            .as_ref()
            .map(|m| m.0.as_ref()),
        Some("new-model"),
        "a persist failure must not revert the live session's model"
    );
    assert!(
        !effects
            .iter()
            .any(|e| matches!(e, Effect::SwitchModel { .. })),
        "no reverse SwitchModel may be issued for a disk-persist failure"
    );
    assert!(
        read_toast(&app).contains("Could not save"),
        "the save failure must still be surfaced"
    );
}

/// `set_yolo_mode_inner` is the backstop: even a (stale) rollback
/// value of "always-approve" must not re-enable yolo under the pin.
#[test]
fn rollback_to_always_approve_blocked_by_policy_pin() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    app.yolo_policy_block = Some(POLICY_WARNING);

    let effects = dispatch(
        Action::TaskComplete(TaskResult::SettingPersistFailed {
            key: "permission_mode",
            rollback_value: SettingValue::Enum("always-approve"),
            error: "permission denied".into(),
        }),
        &mut app,
    );

    assert!(effects.is_empty(), "rollback path never re-emits effects");
    assert!(
        !app.agents[&AgentId(0)].session.is_yolo(),
        "inner backstop must hold on the rollback path"
    );
    assert!(!app.default_yolo);
}

// -- SessionListLoaded ------------------------------------------------

/// Canary: an empty list surfaces the generic "no sessions" toast.
#[test]
fn session_list_empty_shows_generic_toast() {
    let mut app = test_app_with_agent();
    open_session_picker_with(&mut app, vec![]);
    let _ = dispatch(
        Action::TaskComplete(TaskResult::SessionListLoaded {
            sessions: vec![],
            seq: 0,
            query: None,
        }),
        &mut app,
    );
    assert!(read_toast(&app).contains("No sessions found"));
}
