diff --git a/crates/codegen/kigi-tui/src/app/dispatch/router.rs b/crates/codegen/kigi-tui/src/app/dispatch/router.rs index 39f0497..ea6b294 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/router.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/router.rs @@ -62,6 +62,7 @@ use super::session::lifecycle::{ clear_startup_actions, dispatch_agent_type_mismatch_answered, dispatch_exit_session, dispatch_new_session, dispatch_new_session_inner, dispatch_new_session_with_id, dispatch_new_worktree_session, dispatch_trust_folder, open_new_session_question, + skip_picker_and_create_session, }; use super::session::load::{ dispatch_cycle_session_source_filter, dispatch_load_session, dispatch_pick_content_session, @@ -749,7 +750,12 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { }; let Some(session_id) = agent.session.session_id.clone() else { agent.session.deferred_model_switch = Some((model_id, effort)); - return vec![]; + // No session bound: with a create in flight this is a no-op + // and `SessionCreated` applies the stash; with none in flight + // (project question pending — only a plain prompt opens it) a + // switch would dangle forever, so start the session like the + // QueueCommand arm does for queued slash work. + return skip_picker_and_create_session(app, id); }; agent.session.model_switch_pending = true; vec![Effect::SwitchModel { diff --git a/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs b/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs index 9939a9a..56e129e 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs @@ -1519,12 +1519,20 @@ pub(in crate::app::dispatch) fn set_default_model( effort: None, prev_model_id: prev_id.clone(), }); - } else if let Some(agent) = app.agents.get_mut(&aid) { - // No session id yet — stash for - // `EventLoop::on_session_created` to apply once the session - // id materialises. Mirrors the deferred-switch handling in - // `Action::SwitchModel`. - agent.session.deferred_model_switch = Some((new_id, None)); + } else { + if let Some(agent) = app.agents.get_mut(&aid) { + // No session id yet — stash for + // `EventLoop::on_session_created` to apply once the session + // id materialises. Mirrors the deferred-switch handling in + // `Action::SwitchModel`. + agent.session.deferred_model_switch = Some((new_id, None)); + } + // With no create in flight (project question pending), the stash + // would dangle forever — start the session it drains into. No-op + // when a create is already pending. + effects.extend( + crate::app::dispatch::session::lifecycle::skip_picker_and_create_session(app, aid), + ); } effects } diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/session/lifecycle.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/session/lifecycle.rs index c60d2b1..4dfd590 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/session/lifecycle.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/session/lifecycle.rs @@ -481,7 +481,7 @@ fn session_failed_clears_flag_no_fetches() { assert!(!app.agents[&id].pending_extensions_fetch); } #[test] -fn switch_model_without_session_does_nothing() { +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; @@ -493,7 +493,14 @@ fn switch_model_without_session_does_nothing() { }, &mut app, ); - assert!(effects.is_empty()); + // 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] @@ -668,11 +675,17 @@ fn switch_model_deferred_when_no_session_id() { }, &mut app, ); - assert!(effects.is_empty()); + // 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] diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/task_result.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/task_result.rs index 5160b1d..9af1f46 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/task_result.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/task_result.rs @@ -977,6 +977,77 @@ fn switch_model_pending_lifecycle() { assert!(!app.agents[&id].session.model_switch_pending); } +/// A model switch with no session AND no create in flight (the project-picker +/// question is pending; only a plain prompt can open it) must start the +/// deferred session itself, or the stashed switch dangles forever with zero +/// feedback — `/model X eff` in `~/Downloads` looked like "the model never +/// changes". Mirrors the `QueueCommand` arm: queued slash work bypasses the +/// picker and creates the session so the stash drains. +#[test] +fn switch_model_without_session_creates_the_deferred_session() { + let mut app = test_app_with_agent(); + let id = AgentId(0); + app.agents.get_mut(&id).unwrap().session.session_id = None; + // Harness cwd is `/tmp` (a non-project dir); arm the picker gate the way + // startup leaves it (the harness pre-marks it shown for other tests). + app.project_picker_shown = false; + assert!(app.needs_project_picker()); + + let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5")); + let effects = dispatch( + Action::SwitchModel { + model_id: model_id.clone(), + effort: None, + }, + &mut app, + ); + + assert_eq!( + app.agents[&id].session.deferred_model_switch, + Some((model_id, None)), + "switch must stay stashed for SessionCreated to apply" + ); + assert!( + effects + .iter() + .any(|e| matches!(e, Effect::CreateSession { .. })), + "sessionless switch must start the session the stash drains into" + ); +} + +/// Same stash path while a create IS in flight (`mcp_init_progress` set): +/// no duplicate `CreateSession` — the pending create applies the stash. +#[test] +fn switch_model_with_create_in_flight_does_not_duplicate_create() { + 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.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress { + total: 0, + connected: 0, + started_at: std::time::Instant::now(), + }); + } + + let effects = dispatch( + Action::SwitchModel { + model_id: acp::ModelId::new(std::sync::Arc::from("kigi-4.5")), + effort: None, + }, + &mut app, + ); + + assert!(app.agents[&id].session.deferred_model_switch.is_some()); + assert!( + !effects + .iter() + .any(|e| matches!(e, Effect::CreateSession { .. })), + "an in-flight create must not be duplicated" + ); +} + #[test] fn no_deferred_switch_means_no_extra_effect() { // When there is no deferred model switch, SessionCreated should