//! Tests for session create, exit, trust, startup actions, worktree creation, and cloud lifecycle. use super::*; /// Simulate a release-stamped build so folder-trust is active (a local/dev /// build auto-trusts and persists nothing). Mirrors this module's raw env idiom. fn simulate_release_build() { unsafe { std::env::set_var(kigi_version::TEST_VERSION_ENV, "0.0.0-sim") }; } #[test] fn chip_submit_without_session_keeps_chips_and_does_not_send() { let mut app = test_app_with_agent(); let id = AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.session.session_id = None; agent.apply_follow_ups("resp-1".into(), vec!["Summarize".into()]); assert!(agent.follow_ups.is_some(), "precondition: chips shown"); } let effects = dispatch(Action::SubmitFollowUp("Summarize".into()), &mut app); assert!( !effects .iter() .any(|e| matches!(e, Effect::SendPrompt { .. })), "an unbound-session submit must emit no SendPrompt, got {effects:?}" ); assert!( app.agents[&id].follow_ups.is_some(), "an unbound-session submit must NOT clear the chips" ); } #[test] fn send_prompt_without_session_queues_but_no_effect() { let mut app = test_app_with_agent(); let id = AgentId(0); app.agents.get_mut(&id).unwrap().session.session_id = None; let effects = dispatch(Action::SendPrompt("hello".into()), &mut app); assert!(effects.is_empty()); assert_eq!(app.agents[&id].session.queue_len(), 1); assert_eq!(app.agents[&id].session.pending_prompts[0].text, "hello"); } #[test] fn session_created_sets_session_id() { let mut app = test_app_with_agent(); let id = AgentId(0); app.agents.get_mut(&id).unwrap().session.session_id = None; let effects = dispatch( Action::TaskComplete(TaskResult::SessionCreated { agent_id: id, session_id: "new-session-123".into(), models: None, }), &mut app, ); assert_eq!(effects.len(), 4); assert!( matches!(& effects[0], Effect::FetchPromptHistory { session_id, .. } if session_id == "new-session-123") ); assert!(matches!(&effects[1], Effect::FetchSessionAgentName { .. })); assert!(matches!( &effects[2], Effect::RefreshAvailableCommands { .. } )); assert!(matches!(&effects[3], Effect::RegisterActiveSession { .. })); assert_eq!( app.agents[&id] .session .session_id .as_ref() .map(|s| s.0.as_ref()), Some("new-session-123") ); } /// All System-block texts in an agent's scrollback, in order. fn all_system_texts(app: &AppView, id: AgentId) -> Vec { let sb = &app.agents[&id].scrollback; (0..sb.len()) .filter_map(|i| match &sb.get(i).expect("index in range").block { RenderBlock::System(sys) => Some(sys.text.clone()), _ => None, }) .collect() } /// In minimal mode the `/new` session banner must advertise `/resume`, not /// `/dashboard` — the dashboard command is refused there, while the /// `/resume` session picker works. Deterministic regardless of the /// dashboard feature flag: the minimal branch of /// `session_switch_hint_command` never consults it. #[test] fn session_created_banner_advertises_resume_in_minimal_mode() { let mut app = test_app_with_agent(); app.screen_mode = crate::app::ScreenMode::Minimal; let id = AgentId(1); let mut session = make_test_agent_session(&app, id, "unused"); session.session_id = None; session.created_via_new = true; app.agents .insert(id, AgentView::new(session, ScrollbackState::new())); dispatch( Action::TaskComplete(TaskResult::SessionCreated { agent_id: id, session_id: "new-session-123".into(), models: None, }), &mut app, ); let texts = all_system_texts(&app, id); let banner = texts .iter() .find(|t| t.contains("switch between sessions")) .unwrap_or_else(|| panic!("expected a session-switch banner, got: {texts:?}")); assert!( banner.contains("Session new-session-123 \u{2014} use /resume to switch between sessions"), "minimal mode must advertise /resume: {banner}" ); assert!( !texts.iter().any(|t| t.contains("/dashboard")), "minimal mode must NOT advertise /dashboard: {texts:?}" ); } #[test] fn global_cancel_subagents_pref_skips_panel_without_session_override() { let mut app = test_app_with_agent(); let id = AgentId(0); { let agent = app.agents.get_mut(&id).unwrap(); agent.session.state = AgentState::TurnRunning; agent .subagent_sessions .insert("child-1".into(), make_test_subagent("child-1", "sa-1")); } app.current_ui.cancel_subagents_on_turn_cancel = Some("always_continue".into()); let effects = dispatch(Action::CancelTurn, &mut app); assert!(app.agents[&id].cancel_turn_view.is_none()); assert!(matches!( effects.as_slice(), [Effect::CancelTurn { cancel_subagents: false, .. }] )); } #[test] fn new_worktree_session_creates_agent_and_returns_effect() { let mut app = test_app_git(); let effects = dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); assert_eq!(effects.len(), 1); assert!(matches!(effects[0], Effect::CreateWorktreeSession { .. })); assert!(matches!(app.active_view, ActiveView::Agent(AgentId(0)))); assert!(app.agents.contains_key(&AgentId(0))); assert_eq!(app.agents[&AgentId(0)].scrollback.len(), 0); assert!(matches!( app.agents[&AgentId(0)].session.state, AgentState::CommandRunning { .. } )); } #[test] fn worktree_session_created_sets_session_and_cwd() { let mut app = test_app_git(); dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); let id = AgentId(0); let worktree_path = PathBuf::from("/tmp/kigi-worktrees/pager-123"); let session_cwd = worktree_path.clone(); let effects = dispatch( Action::TaskComplete(TaskResult::WorktreeSessionCreated { agent_id: id, session_id: acp::SessionId::new("wt-session-1"), worktree_path: worktree_path.clone(), session_cwd: session_cwd.clone(), models: None, }), &mut app, ); assert!( effects .iter() .any(|e| matches!(e, Effect::FetchPromptHistory { .. })) ); assert!( effects .iter() .any(|e| matches!(e, Effect::FetchSessionAgentName { .. })) ); assert!( effects .iter() .any(|e| matches!(e, Effect::RegisterActiveSession { .. })) ); assert_eq!( app.agents[&id] .session .session_id .as_ref() .map(|s| s.0.as_ref()), Some("wt-session-1") ); assert_eq!(app.agents[&id].session.cwd, session_cwd); assert_eq!(app.agents[&id].scrollback.len(), 1); assert!(app.agents[&id].session.state.is_idle()); } #[test] fn worktree_session_preserves_subdirectory_offset() { let mut app = test_app(); app.cwd = PathBuf::from("/repo/crates/codegen/kigi-tui"); app.cwd_has_git_ancestor = true; dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); let id = AgentId(0); let worktree_root = PathBuf::from("/home/user/.kigi/worktrees/repo/pager-123"); let session_cwd = worktree_root.join("crates/codegen/kigi-tui"); let effects = dispatch( Action::TaskComplete(TaskResult::WorktreeSessionCreated { agent_id: id, session_id: acp::SessionId::new("wt-nested-1"), worktree_path: worktree_root.clone(), session_cwd: session_cwd.clone(), models: None, }), &mut app, ); assert!( effects .iter() .any(|e| matches!(e, Effect::FetchPromptHistory { .. })) ); assert!( effects .iter() .any(|e| matches!(e, Effect::RegisterActiveSession { .. })) ); assert_eq!(app.agents[&id].session.cwd, session_cwd); assert_ne!(app.agents[&id].session.cwd, worktree_root); } #[test] fn worktree_session_failed_without_session_returns_to_welcome() { let mut app = test_app_git(); dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); let id = AgentId(0); assert!(app.agents.contains_key(&id)); assert!(matches!(app.active_view, ActiveView::Agent(AgentId(0)))); let error_msg = "worktree creation failed: not a git repo"; let effects = dispatch( Action::TaskComplete(TaskResult::WorktreeSessionFailed { agent_id: id, error: error_msg.into(), }), &mut app, ); assert!(effects.is_empty()); assert!(!app.agents.contains_key(&id)); assert!(matches!(app.active_view, ActiveView::Welcome)); assert!( app.startup_warnings .iter() .any(|w| w.message.contains(error_msg)), "expected warning containing the error message" ); } #[test] fn worktree_session_failed_with_fork_parent_keeps_agent() { let mut app = test_app_git(); dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); let id = AgentId(0); app.agents.get_mut(&id).unwrap().session.forked_from = Some(AgentId(99)); let error_msg = "worktree creation failed: permission denied"; let effects = dispatch( Action::TaskComplete(TaskResult::WorktreeSessionFailed { agent_id: id, error: error_msg.into(), }), &mut app, ); assert!(effects.is_empty()); assert!(app.agents.contains_key(&id)); assert!(app.agents[&id].session.state.is_idle()); let entry = app.agents[&id].scrollback.entry(0).unwrap(); match &entry.block { RenderBlock::SessionEvent(block) => match &block.event { SessionEvent::TurnFailed { error, .. } => { assert_eq!(error, error_msg); } other => panic!("expected TurnFailed, got {other:?}"), }, other => panic!("expected SessionEvent block, got {other:?}"), } } #[test] fn new_worktree_session_rejects_non_git_cwd() { let mut app = test_app(); let effects = dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); assert!(effects.is_empty()); assert!(app.agents.is_empty()); assert!(matches!(app.active_view, ActiveView::Welcome)); assert!( app.startup_warnings .iter() .any(|w| w.message.contains("Not inside a git repository")), "expected git-repo warning" ); } #[test] fn worktree_session_created_drains_queued_prompts() { let mut app = test_app_git(); dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); let id = AgentId(0); let effects = dispatch(Action::SendPrompt("hello".into()), &mut app); assert!(effects.is_empty(), "no session_id yet, can't drain"); assert_eq!(app.agents[&id].session.queue_len(), 1); let worktree_path = PathBuf::from("/tmp/kigi-worktrees/pager-abc"); let effects = dispatch( Action::TaskComplete(TaskResult::WorktreeSessionCreated { agent_id: id, session_id: acp::SessionId::new("wt-drain-1"), worktree_path, session_cwd: PathBuf::from("/tmp/kigi-worktrees/pager-abc"), models: None, }), &mut app, ); assert!( effects .iter() .any(|e| matches!(e, Effect::SendPrompt { text, .. } if text == "hello")) ); assert!( effects .iter() .any(|e| matches!(e, Effect::FetchPromptHistory { .. })) ); assert!( effects .iter() .any(|e| matches!(e, Effect::RegisterActiveSession { .. })) ); assert_eq!(app.agents[&id].session.queue_len(), 0); } #[test] fn session_created_drains_queued_prompts() { let mut app = test_app(); dispatch(Action::NewSession, &mut app); let id = AgentId(0); let effects = dispatch(Action::SendPrompt("queued msg".into()), &mut app); assert!(effects.is_empty()); assert_eq!(app.agents[&id].session.queue_len(), 1); let effects = dispatch( Action::TaskComplete(TaskResult::SessionCreated { agent_id: id, session_id: acp::SessionId::new("sess-drain-1"), models: None, }), &mut app, ); assert!( effects .iter() .any(|e| matches!(e, Effect::SendPrompt { text, .. } if text == "queued msg")) ); assert!( effects .iter() .any(|e| matches!(e, Effect::FetchPromptHistory { .. })) ); assert!( effects .iter() .any(|e| matches!(e, Effect::RegisterActiveSession { .. })) ); assert_eq!(app.agents[&id].session.queue_len(), 0); } #[test] fn session_created_with_flag_emits_four_fetches_and_clears_flag() { use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab}; let mut app = test_app_with_agent(); let id = AgentId(0); { let a = app.agents.get_mut(&id).unwrap(); a.session.session_id = None; a.pending_extensions_fetch = true; a.extensions_modal = Some(ExtensionsModalState::new(ExtensionsTab::Hooks)); } let effects = dispatch( Action::TaskComplete(TaskResult::SessionCreated { agent_id: id, session_id: acp::SessionId::new("s"), models: None, }), &mut app, ); assert_eq!(count_extension_fetches(&effects), 4); assert!( effects .iter() .any(|e| matches!(e, Effect::FetchMcpsList { cache: true, .. })) ); assert!(!app.agents[&id].pending_extensions_fetch); } #[test] fn session_created_without_flag_emits_no_extension_fetches() { let mut app = test_app_with_agent(); let id = AgentId(0); app.agents.get_mut(&id).unwrap().session.session_id = None; assert!(!app.agents[&id].pending_extensions_fetch); let effects = dispatch( Action::TaskComplete(TaskResult::SessionCreated { agent_id: id, session_id: acp::SessionId::new("s"), models: None, }), &mut app, ); assert_eq!(count_extension_fetches(&effects), 0); } #[test] fn session_failed_clears_flag_no_fetches() { use crate::views::extensions_modal::{ExtensionsModalState, ExtensionsTab}; let mut app = test_app_with_agent(); let id = AgentId(0); { let a = app.agents.get_mut(&id).unwrap(); a.session.session_id = None; a.pending_extensions_fetch = true; a.extensions_modal = Some(ExtensionsModalState::new(ExtensionsTab::Hooks)); } let effects = dispatch( Action::TaskComplete(TaskResult::SessionFailed { agent_id: id, error: "boom".to_string(), }), &mut app, ); assert_eq!(count_extension_fetches(&effects), 0); assert!(!app.agents[&id].pending_extensions_fetch); } #[test] fn switch_model_without_session_starts_the_session() { let mut app = test_app_with_agent(); let id = AgentId(0); app.agents.get_mut(&id).unwrap().session.session_id = None; let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5")); let effects = dispatch( Action::SwitchModel { model_id, effort: None, }, &mut app, ); // No create was in flight, so the switch starts the session its stash // drains into (the stash alone dangled forever — the /model-in-Downloads // silent no-op). `model_switch_pending` flips on SessionCreated. assert!( effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })) ); assert!(!app.agents[&id].session.model_switch_pending); } #[test] fn agent_type_mismatch_start_new_creates_session_with_model_id() { let mut app = test_app_with_agent(); let model_id = acp::ModelId::new(std::sync::Arc::from("cursor-model")); let effects = dispatch( Action::AgentTypeMismatchAnswered { start_new: true, model_id: model_id.clone(), effort: None, }, &mut app, ); let create = effects .iter() .find(|e| matches!(e, Effect::CreateSession { .. })); assert!(create.is_some(), "expected CreateSession effect"); match create.unwrap() { Effect::CreateSession { model_id: mid, .. } => { assert_eq!( mid.as_ref(), Some(&model_id), "CreateSession must carry the target model_id", ); } _ => unreachable!(), } if let ActiveView::Agent(new_aid) = app.active_view { let agent = &app.agents[&new_aid]; assert!( agent.session.deferred_model_switch.is_none(), "no-effort mismatch must not set deferred_model_switch", ); } else { panic!("expected active view to be an Agent"); } } #[test] fn new_session_seeds_mcp_init_progress() { let mut app = test_app(); dispatch(Action::NewSession, &mut app); let id = AgentId(0); let progress = app.agents[&id].mcp_init_progress.as_ref(); assert!( progress.is_some(), "new session must seed mcp_init_progress", ); let p = progress.unwrap(); assert_eq!( p.total, 0, "seeded total must be 0 (unknown until shell reports)" ); assert_eq!(p.connected, 0, "seeded connected must be 0"); } #[test] fn new_session_without_model_switch_has_no_model_id() { let mut app = test_app(); let effects = dispatch(Action::NewSession, &mut app); let create = effects .iter() .find(|e| matches!(e, Effect::CreateSession { .. })); assert!(create.is_some()); match create.unwrap() { Effect::CreateSession { model_id, .. } => { assert!(model_id.is_none(), "plain /new must not carry a model_id",); } _ => unreachable!(), } } #[test] fn new_session_seeds_available_commands_from_bootstrap() { let mut app = test_app(); app.bootstrap_acp_commands = vec![acp::AvailableCommand::new( "flush".to_string(), "Flush memory".to_string(), )]; dispatch(Action::NewSession, &mut app); let id = AgentId(0); assert_eq!(app.agents[&id].session.available_commands.len(), 1); assert_eq!(app.agents[&id].session.available_commands[0].name, "flush"); assert_eq!(app.agents[&id].session.available_commands_generation, 1); } #[test] fn new_session_empty_bootstrap_starts_at_generation_1() { let mut app = test_app(); app.bootstrap_acp_commands = Vec::new(); dispatch(Action::NewSession, &mut app); let id = AgentId(0); assert!(app.agents[&id].session.available_commands.is_empty()); assert_eq!(app.agents[&id].session.available_commands_generation, 1); } #[test] fn worktree_session_seeds_available_commands_from_bootstrap() { let mut app = test_app_git(); app.bootstrap_acp_commands = vec![acp::AvailableCommand::new( "plugins".to_string(), "List plugins".to_string(), )]; dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); let id = AgentId(0); assert_eq!(app.agents[&id].session.available_commands.len(), 1); assert_eq!( app.agents[&id].session.available_commands[0].name, "plugins" ); assert_eq!(app.agents[&id].session.available_commands_generation, 1); } #[test] fn exit_session_unregisters_active_session() { let mut app = test_app_with_agent(); let effects = dispatch(Action::ExitSession, &mut app); assert!( effects .iter() .any(|e| matches!(e, Effect::UnregisterActiveSession { .. })), "ExitSession must emit UnregisterActiveSession, got: {effects:?}" ); assert!(matches!(app.active_view, ActiveView::Welcome)); } #[test] fn slash_new_dispatches_new_session() { let mut app = test_app_with_agent(); let effects = dispatch(Action::SendPrompt("/new".into()), &mut app); assert!( effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })) ); } #[test] fn new_session_falls_back_to_app_cwd_on_welcome_screen() { let mut app = test_app(); assert!(matches!(app.active_view, ActiveView::Welcome)); let effects = dispatch(Action::NewSession, &mut app); let create = effects .iter() .find(|e| matches!(e, Effect::CreateSession { .. })); assert!(create.is_some(), "expected CreateSession effect"); match create.unwrap() { Effect::CreateSession { cwd, .. } => assert_eq!(cwd, &PathBuf::from("/tmp")), _ => unreachable!(), } let new_id = AgentId(0); assert!(!app.agents[&new_id].session.is_worktree); } #[test] fn new_session_starts_with_prompt_focused() { let mut app = test_app(); assert!(matches!(app.active_view, ActiveView::Welcome)); dispatch(Action::NewSession, &mut app); let id = AgentId(0); assert_eq!(app.agents[&id].active_pane, ActivePane::Prompt); } #[test] fn switch_model_deferred_when_no_session_id() { let mut app = test_app_with_agent(); let id = AgentId(0); let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5")); app.agents.get_mut(&id).unwrap().session.session_id = None; let effects = dispatch( Action::SwitchModel { model_id: model_id.clone(), effort: None, }, &mut app, ); // Stashed for SessionCreated — and the session it drains into is started // (no create was in flight; a bare stash never drained). assert_eq!( app.agents[&id].session.deferred_model_switch, Some((model_id, None)) ); assert!( effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })) ); assert!(!app.agents[&id].session.model_switch_pending); } #[test] fn deferred_model_switch_applied_on_session_created() { 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 session_id: acp::SessionId = "new-session".into(); app.agents.get_mut(&id).unwrap().session.session_id = None; app.agents .get_mut(&id) .unwrap() .session .deferred_model_switch = Some((model_id.clone(), None)); let effects = dispatch( Action::TaskComplete(TaskResult::SessionCreated { agent_id: id, session_id: session_id.clone(), models: None, }), &mut app, ); assert!(app.agents[&id].session.deferred_model_switch.is_none()); assert!(app.agents[&id].session.model_switch_pending); assert!( effects .iter() .any(|e| matches!(e, Effect::SwitchModel { agent_id : a_id, session_id : s_id, model_id : m_id, .. } if * a_id == id && * s_id == session_id && * m_id == model_id)) ); } #[test] fn deferred_model_switch_applied_on_worktree_session_created() { let mut app = test_app_git(); let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5")); dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); let id = AgentId(0); app.agents .get_mut(&id) .unwrap() .session .deferred_model_switch = Some((model_id.clone(), None)); let session_id: acp::SessionId = "wt-session".into(); let effects = dispatch( Action::TaskComplete(TaskResult::WorktreeSessionCreated { agent_id: id, session_id: session_id.clone(), worktree_path: PathBuf::from("/tmp/worktree"), session_cwd: PathBuf::from("/tmp/worktree"), models: None, }), &mut app, ); assert!(app.agents[&id].session.deferred_model_switch.is_none()); assert!(app.agents[&id].session.model_switch_pending); assert!( effects .iter() .any(|e| matches!(e, Effect::SwitchModel { agent_id : a_id, session_id : s_id, model_id : m_id, .. } if * a_id == id && * s_id == session_id && * m_id == model_id)) ); } /// The session-startup gate requires BOTH auth AND trust resolved. Trust is /// gated AFTER auth, so either one pending defers session creation. #[test] fn session_startup_allowed_requires_auth_and_trust() { let mut app = test_app(); assert!(app.session_startup_allowed()); app.trust_state = TrustState::Pending { workspace: PathBuf::from("/x"), }; assert!( !app.session_startup_allowed(), "pending trust must block session startup", ); app.trust_state = TrustState::Done; app.auth_state = AuthState::Pending { error: None }; assert!( !app.session_startup_allowed(), "pending auth must block session startup", ); app.trust_state = TrustState::Pending { workspace: PathBuf::from("/x"), }; assert!( !app.session_startup_allowed(), "both pending must block session startup", ); } /// Accepting the trust question (its `finish_trust` tail) resolves trust and /// replays the deferred startup when auth is already done. (Declining quits /// instead -- see `welcome_trust_decline_keys_quit` in `app_view`.) #[test] fn finish_trust_resolves_and_replays_startup() { let mut app = test_app(); app.trust_state = TrustState::Pending { workspace: PathBuf::from("/x"), }; app.deferred_startup.session = Some(crate::app::session_startup::DeferredSessionStartup::Load { session_id: "deferred-session".into(), session_cwd: None, chat_kind: false, }); let effects = finish_trust(&mut app); assert!(matches!(app.trust_state, TrustState::Done)); assert!( app.deferred_startup.session.is_none(), "resolving trust must drain the deferred startup", ); assert!( effects .iter() .any(|e| matches!(e, Effect::LoadSession { .. })), "deferred load must replay once trust resolves", ); } /// Accepting the trust question persists the grant to the store and resolves /// trust. KIGI_SHARE_DIR-isolated so the write hits a temp store, not the real one. #[serial_test::serial(KIGI_SHARE_DIR)] #[test] fn trust_folder_grants_and_resolves() { use kigi_workspace::trust::{TrustStore, workspace_key}; let home = tempfile::tempdir().expect("home tempdir"); unsafe { std::env::set_var("KIGI_SHARE_DIR", home.path()) }; simulate_release_build(); let repo = tempfile::tempdir().expect("repo tempdir"); let workspace = workspace_key(repo.path()); let mut app = test_app(); app.trust_state = TrustState::Pending { workspace: workspace.clone(), }; let _ = dispatch(Action::TrustFolder, &mut app); assert!(matches!(app.trust_state, TrustState::Done)); assert!( TrustStore::load().is_trusted(&workspace), "accepting must persist the trust grant for the workspace", ); } /// When BOTH auth and trust are pending, `AuthComplete` must NOT replay the /// deferred startup -- the trust question renders next, and its answer drains /// it. Verifies the symmetric two-gate hand-off. #[test] fn auth_complete_defers_startup_until_trust_resolved() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Pending, }; app.trust_state = TrustState::Pending { workspace: PathBuf::from("/x"), }; app.deferred_startup.session = Some(crate::app::session_startup::DeferredSessionStartup::Load { session_id: "deferred-session".into(), session_cwd: None, chat_kind: false, }); let effects = dispatch( Action::TaskComplete(TaskResult::AuthComplete { request_seq: 1, meta: None, }), &mut app, ); assert!(matches!(app.auth_state, AuthState::Done)); assert!( matches!(app.trust_state, TrustState::Pending { .. }), "trust stays pending after auth completes", ); assert!( app.deferred_startup.session.is_some(), "startup must remain deferred while trust is pending", ); assert!( !effects .iter() .any(|e| matches!(e, Effect::LoadSession { .. })), "no session may load before trust is answered", ); let effects = finish_trust(&mut app); assert!(app.deferred_startup.session.is_none()); assert!( effects .iter() .any(|e| matches!(e, Effect::LoadSession { .. })), "trust answer must replay the deferred startup", ); } /// Symmetric to the above: trust answered FIRST while auth is still pending /// exercises `finish_trust`'s `else` (deferred) branch -- it must NOT drain; /// the later `AuthComplete` (the last gate) drains. #[test] fn trust_answered_first_defers_startup_until_auth_completes() { let mut app = test_app(); app.auth_state = AuthState::Authenticating { request_seq: 1, handle: None, auth_url: None, mode: AuthMode::Pending, }; app.trust_state = TrustState::Pending { workspace: PathBuf::from("/x"), }; app.deferred_startup.session = Some(crate::app::session_startup::DeferredSessionStartup::Load { session_id: "deferred-session".into(), session_cwd: None, chat_kind: false, }); let effects = finish_trust(&mut app); assert!(matches!(app.trust_state, TrustState::Done)); assert!( app.deferred_startup.session.is_some(), "startup stays deferred while auth is still pending", ); assert!( !effects .iter() .any(|e| matches!(e, Effect::LoadSession { .. })), "no session may load before auth completes", ); let effects = dispatch( Action::TaskComplete(TaskResult::AuthComplete { request_seq: 1, meta: None, }), &mut app, ); assert!(matches!(app.auth_state, AuthState::Done)); assert!(app.deferred_startup.session.is_none()); assert!( effects .iter() .any(|e| matches!(e, Effect::LoadSession { .. })), "auth completion replays the deferred startup", ); } /// Regression / negative-space: a session-creating dispatch (e.g. the /// global `Ctrl+N` `NewSession`, which bypasses the welcome interceptor) must /// NOT create a session while `TrustState::Pending` -- the dispatch /// chokepoint stashes it, and it replays exactly once when trust resolves. #[test] fn new_session_is_gated_while_trust_pending() { let mut app = test_app(); app.trust_state = TrustState::Pending { workspace: PathBuf::from("/x"), }; assert!(app.agents.is_empty()); let effects = dispatch(Action::NewSession, &mut app); assert!( app.agents.is_empty(), "no session may be created while trust is Pending", ); assert!( app.deferred_startup.new_session, "the new-session intent must be stashed for replay", ); assert!(effects.is_empty(), "a gated NewSession produces no effects"); let _ = finish_trust(&mut app); assert!(matches!(app.trust_state, TrustState::Done)); assert!( !app.deferred_startup.new_session, "the stashed new-session intent is consumed on replay", ); assert_eq!( app.agents.len(), 1, "the deferred new session replays exactly once after trust resolves", ); } /// Sticky `--chat` first create (NewSession) stamps chat_kind. #[test] fn chat_mode_new_session_creates_with_chat_kind() { let mut app = test_app(); app.chat_mode = true; let effects = dispatch(Action::NewSession, &mut app); assert!( effects.iter().any(|e| matches!( e, Effect::CreateSession { chat_kind: true, .. } )), "expected chat CreateSession under --chat, got {effects:?}" ); } /// Atomicity: when several startup intents coexist (e.g. CLI /// `--resume` + an incidental `Ctrl+N` deferred during the trust question), /// `drain_startup_actions` replays the highest-priority one and leaves NO /// `startup_*` field set — no stale intent can fire on a later drain. #[test] fn drain_clears_all_startup_fields_even_when_intents_coexist() { let mut app = test_app(); app.deferred_startup.session = Some(crate::app::session_startup::DeferredSessionStartup::Load { session_id: "deferred-session".into(), session_cwd: None, chat_kind: false, }); app.deferred_startup.new_session = true; app.deferred_startup.worktree_label = Some("stray".into()); let effects = drain_startup_actions(&mut app); assert!( effects .iter() .any(|e| matches!(e, Effect::LoadSession { .. })), "the higher-priority deferred resume replays", ); assert!(app.deferred_startup.session.is_none()); assert!(app.deferred_startup.session.is_none()); assert!(!app.deferred_startup.worktree); assert!(app.deferred_startup.worktree_label.is_none()); assert!( !app.deferred_startup.new_session, "the superseded Ctrl+N intent must be cleared, not left set", ); assert!(app.deferred_startup.prompt.is_none()); assert!(!app.deferred_startup.open_dashboard); } /// The `--worktree ` feature must keep /// working through the startup gate. A `NewWorktreeSession` carrying a /// `git_ref` dispatched while the trust gate is closed is stashed into /// `deferred_startup.worktree_ref` by the chokepoint; once the gate opens the drain /// replays it as the worktree's git ref and clears the field. #[test] fn deferred_worktree_ref_replays_through_gate() { let mut app = test_app(); app.cwd_has_git_ancestor = true; app.trust_state = TrustState::Pending { workspace: PathBuf::from("/x"), }; let effects = dispatch( Action::NewWorktreeSession { load_session_id: None, label: Some("wt".into()), git_ref: Some("feature-branch".into()), }, &mut app, ); assert!( effects.is_empty(), "a gated worktree session produces no effects", ); assert_eq!( app.deferred_startup.worktree_ref.as_deref(), Some("feature-branch"), "the --worktree must be stashed for replay", ); assert!(app.deferred_startup.worktree); let effects = finish_trust(&mut app); assert!( effects .iter() .any(|e| matches!(e, Effect::CreateWorktreeSession { git_ref : Some(r), .. } if r == "feature-branch")), "the deferred --worktree replays with its git ref", ); assert!( app.deferred_startup.worktree_ref.is_none(), "the stashed worktree ref is consumed by the drain", ); assert!(!app.deferred_startup.worktree); } /// Regression: a gated worktree-WITHOUT-load-id must /// not clobber a `--resume`/`LoadSession` id a previous gated action already /// stashed. Otherwise `drain_startup_actions` loses the resume intent and /// opens a blank worktree instead of a worktree-with-resume. #[test] fn gated_worktree_without_load_id_preserves_stashed_resume() { let mut app = test_app(); app.cwd_has_git_ancestor = true; app.trust_state = TrustState::Pending { workspace: PathBuf::from("/x"), }; let effects = dispatch( Action::LoadSession("resume-me".into(), None, false), &mut app, ); assert!(effects.is_empty(), "a gated resume produces no effects"); assert_eq!( app.deferred_startup.session.as_ref().and_then(|d| match d { crate::app::session_startup::DeferredSessionStartup::Load { session_id, .. } => Some(session_id.as_str()), _ => None, }), Some("resume-me"), "the gated resume id must be stashed", ); let effects = dispatch( Action::NewWorktreeSession { load_session_id: None, label: None, git_ref: None, }, &mut app, ); assert!(effects.is_empty(), "a gated worktree produces no effects"); assert_eq!( app.deferred_startup.session.as_ref().and_then(|d| match d { crate::app::session_startup::DeferredSessionStartup::Load { session_id, .. } => Some(session_id.as_str()), _ => None, }), Some("resume-me"), "the worktree-without-load-id must preserve the stashed resume id", ); assert!( app.deferred_startup.worktree, "the worktree intent is stashed too" ); let effects = finish_trust(&mut app); assert!( effects .iter() .any(|e| matches!(e, Effect::CreateWorktreeSession { load_session_id : Some(id), .. } if id == "resume-me")), "the deferred worktree replays with the preserved resume id", ); assert!(app.deferred_startup.session.is_none()); assert!(!app.deferred_startup.worktree); } /// Regression (derisk pass): the same clobber class as the resume-id fix, /// now for the worktree companion fields. A gated worktree dispatch carrying /// `None` label/ref must NOT erase a `--worktree