//! 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![("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![("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")); }