#![cfg_attr(rustfmt, rustfmt::skip)] use super::*; /// Regression: a machine-wide `kigi/models/update` broadcast /// carries each model's static catalog-default effort (`high`), not the /// session's chosen `xhigh`, and must not clobber the per-session choice. #[test] fn models_update_preserves_user_reasoning_effort() { use kigi_shell::sampling::types::ReasoningEffort; let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); let id = acp::ModelId::new(std::sync::Arc::from("reason-model")); let mut info = make_model_info("reason-model"); info.meta = serde_json::json!({ "supportsReasoningEffort": true, "reasoningEffort": "high", }) .as_object() .cloned(); agent.session.models.available.insert(id.clone(), info); agent .session .models .set_current(id, Some(ReasoningEffort::Xhigh)); assert_eq!( agent.session.models.reasoning_effort, Some(ReasoningEffort::Xhigh) ); let notif = make_reasoning_models_update_notif("reason-model", "high"); assert!(handle_models_update(¬if, &mut app)); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent.session.models.reasoning_effort, Some(ReasoningEffort::Xhigh), "models/update broadcast must not clobber a user-set per-session effort" ); } #[test] fn models_update_preserves_active_agent_model() { let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); let id_3 = acp::ModelId::new(std::sync::Arc::from("kigi-3")); agent .session .models .available .insert(id_3.clone(), make_model_info("kigi-3")); agent.session.models.current = Some(id_3); let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]); handle_models_update(¬if, &mut app); assert_eq!( app.models.current.as_ref().map(|id| id.0.as_ref()), Some("kigi-3"), "app.models.current must preserve active agent's model, not remote settings default" ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-3"), "agent's per-session model must be preserved" ); } #[test] fn models_update_uses_shell_default_when_agent_model_removed() { let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); let id_3 = acp::ModelId::new(std::sync::Arc::from("kigi-3")); agent .session .models .available .insert(id_3.clone(), make_model_info("kigi-3")); agent.session.models.current = Some(id_3); // kigi-3 removed from catalog. let notif = make_models_update_notif("kigi-4.3", &["kigi-4.3", "kigi-4.5"]); handle_models_update(¬if, &mut app); assert_eq!( app.models.current.as_ref().map(|id| id.0.as_ref()), Some("kigi-4.3"), "app.models.current must use shell default when agent model removed" ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-4.3"), "agent must fall back to shell default when its model is removed" ); } fn seed_windowed_models(agent: &mut AgentView) { for (slug, window) in [("wide", 1_000_000u64), ("narrow", 262_144)] { let mut info = make_model_info(slug); info.meta = serde_json::json!({ "totalContextTokens": window }) .as_object() .cloned(); agent .session .models .available .insert(acp::ModelId::new(std::sync::Arc::from(slug)), info); } agent .session .models .set_current(acp::ModelId::new(std::sync::Arc::from("wide")), None); agent.apply_context_used(15_000, 1_000_000); } /// A follower mirrors a remote switch and installs the effective window /// the broadcast carries, like the invoking client does. #[test] fn model_changed_broadcast_installs_the_reported_context_window() { let mut app = make_app_with_agent("sess-1"); seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap()); let notif = model_changed_ext("sess-1", "narrow", None, Some(262_144)); assert!(handle_ext_notification(¬if, &mut app)); let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap(); assert_eq!((snap.used, snap.total, snap.usage_pct), (15_000, 262_144, 6)); } /// Same model, same effort, new effective window (the catalog was /// reloaded and re-applied): the follower must still request a redraw. #[test] fn model_changed_same_model_new_window_requests_redraw() { let mut app = make_app_with_agent("sess-1"); seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap()); let notif = model_changed_ext("sess-1", "wide", None, Some(2_000_000)); assert!(handle_ext_notification(¬if, &mut app)); let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap(); assert_eq!((snap.used, snap.total), (15_000, 2_000_000)); let again = model_changed_ext("sess-1", "wide", None, Some(2_000_000)); assert!(!handle_ext_notification(&again, &mut app)); } /// A broadcast from a leader that predates the field carries no window; /// the snapshot is left for the next streaming update to refresh. #[test] fn model_changed_broadcast_without_window_keeps_the_snapshot() { let mut app = make_app_with_agent("sess-1"); seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap()); let notif = model_changed_ext("sess-1", "narrow", None, None); assert!(handle_ext_notification(¬if, &mut app)); let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap(); assert_eq!((snap.used, snap.total), (15_000, 1_000_000)); } #[test] fn models_update_without_active_agent_uses_shell_default() { let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = AppView::new(tx, ModelState::default(), Vec::new()); let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]); handle_models_update(¬if, &mut app); assert_eq!( app.models.current.as_ref().map(|id| id.0.as_ref()), Some("kigi-4"), "without an active agent, shell default must be used" ); } #[test] fn models_update_noop_when_agent_matches_shell_default() { let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); let id_4 = acp::ModelId::new(std::sync::Arc::from("kigi-4")); agent .session .models .available .insert(id_4.clone(), make_model_info("kigi-4")); agent.session.models.current = Some(id_4); let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]); handle_models_update(¬if, &mut app); assert_eq!( app.models.current.as_ref().map(|id| id.0.as_ref()), Some("kigi-4"), "app.models.current must be kigi-4 when agent and shell agree" ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-4"), "agent model must remain kigi-4" ); } #[test] fn models_update_non_active_agent_uses_shell_fallback_not_active_model() { let mut app = make_app_with_agent("sess-A"); insert_agent(&mut app, AgentId(1), Some("sess-B")); { let agent_a = app.agents.get_mut(&AgentId(0)).unwrap(); let id_3 = acp::ModelId::new(std::sync::Arc::from("kigi-3")); agent_a .session .models .available .insert(id_3.clone(), make_model_info("kigi-3")); agent_a.session.models.current = Some(id_3); } { let agent_b = app.agents.get_mut(&AgentId(1)).unwrap(); let id_5 = acp::ModelId::new(std::sync::Arc::from("kigi-4.5")); agent_b .session .models .available .insert(id_5.clone(), make_model_info("kigi-4.5")); agent_b.session.models.current = Some(id_5); } // kigi-4.5 removed from catalog. let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]); handle_models_update(¬if, &mut app); assert_eq!( app.models.current.as_ref().map(|id| id.0.as_ref()), Some("kigi-3"), ); let agent_a = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent_a .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-3"), "agent A's model must be preserved" ); // B's kigi-4.5 was removed — must fall back to shell's kigi-4, not A's kigi-3. let agent_b = app.agents.get(&AgentId(1)).unwrap(); assert_eq!( agent_b .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-4"), "inactive agent must fall back to shell default, not active agent's model" ); } /// A follower client (no in-flight switch of its own) receives the /// leader's `ModelChanged` broadcast and silently mirrors the new model /// into its local state — no scrollback entry, no toast, just enough /// state for the status bar / `/model` dropdown to render correctly. #[test] fn model_changed_updates_state_silently_on_follower() { let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]); let scrollback_before = agent.scrollback.len(); assert!(!agent.session.model_switch_pending); let notif = model_changed_ext("sess-1", "kigi-4", None, None); let changed = handle_ext_notification(¬if, &mut app); assert!( changed, "follower's state changed → handler must request a redraw" ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-4"), "follower must mirror the remote switch into its local model state", ); assert_eq!( agent.scrollback.len(), scrollback_before, "follower must NOT push a 'Switched to' scrollback entry — that is \ the invoking client's job (SwitchModelComplete owns the system message)" ); assert!( !agent.session.model_switch_pending, "follower's pending flag must stay false (no local switch was issued)" ); } /// A live remote `ModelChanged` (leader-mode fan-out from another client) /// must apply even when this client already has a local /// `user_model_preference` — otherwise the status bar desyncs from the /// gateway session. Preference is updated to track the new live model. /// (History-replay silent-revert is suppressed on the shell side via /// `ReconnectState::user_selected_model`, not by permanently blocking /// remote ModelChanged here.) #[test] fn model_changed_applies_and_updates_user_model_preference() { let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); seed_models(agent, "heavy", &["auto", "heavy"]); agent.session.user_model_preference = Some(acp::ModelId::new(std::sync::Arc::from("heavy"))); assert!(!agent.session.model_switch_pending); let notif = model_changed_ext("sess-1", "auto", None, None); let changed = handle_ext_notification(¬if, &mut app); assert!( changed, "remote live ModelChanged must apply despite prior local preference" ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("auto"), "selector must mirror the remote switch" ); assert_eq!( agent .session .user_model_preference .as_ref() .map(|id| id.0.as_ref()), Some("auto"), "preference must track the applied remote switch" ); } /// The invoking client is also a subscriber to its own session and so /// receives the broadcast it triggered. Its in-flight /// `SetSessionModelResponse` is the authority for its local state + /// the single "Switched to X" scrollback entry, so the broadcast handler /// must be a no-op here — gated on `model_switch_pending == true`. /// /// Concretely we verify the broadcast does NOT touch /// `models.current` (preserving the pre-response snapshot) — that /// snapshot is what `SwitchModelComplete`'s `unchanged` check compares /// against to decide whether to render the "Switched to X" message. If /// the broadcast optimistically updated state here, the response /// handler would see `prev == new`, mark it unchanged, and suppress the /// user-facing message entirely. #[test] fn model_changed_skipped_when_local_switch_in_flight() { let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]); agent.session.model_switch_pending = true; let scrollback_before = agent.scrollback.len(); let notif = model_changed_ext("sess-1", "kigi-4", None, None); let changed = handle_ext_notification(¬if, &mut app); assert!( !changed, "broadcast must be a no-op while local switch is pending" ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-3"), "models.current must stay at the pre-response snapshot — \ SwitchModelComplete owns the final apply + system message" ); assert_eq!( agent.scrollback.len(), scrollback_before, "broadcast must not push any scrollback entry on the invoker" ); assert!( agent.session.model_switch_pending, "pending flag must remain set until SwitchModelComplete arrives" ); } /// A `ModelChanged` broadcast carrying a model id the local catalog /// doesn't know about must be dropped — applying it would render an /// unresolvable id in the status bar and desync the `/model` dropdown. /// This can happen when leader and a follower client briefly disagree /// on the model catalog (etag drift, custom-model config skew). #[test] fn model_changed_dropped_when_model_unknown_to_catalog() { let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]); let notif = model_changed_ext("sess-1", "kigi-99-unknown", None, None); let changed = handle_ext_notification(¬if, &mut app); assert!( !changed, "unknown model must NOT trigger a redraw — no state changed" ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-3"), "models.current must stay on the previously-known model" ); } /// `reasoning_effort` round-trips through the broadcast: the follower /// applies it alongside the model id so the prompt header / status bar /// show the right effort without waiting for a subsequent /// `kigi/models/update`. #[test] fn model_changed_applies_reasoning_effort_on_follower() { use kigi_shell::sampling::types::ReasoningEffort; let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]); let notif = model_changed_ext("sess-1", "kigi-4", Some("high"), None); assert!(handle_ext_notification(¬if, &mut app)); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent.session.models.reasoning_effort, Some(ReasoningEffort::High), "follower must mirror the broadcast's reasoning_effort" ); } /// `ModelChanged` for a session this client doesn't own / hasn't loaded /// must be dropped — `find_session_match` returns `None`. The bug-flavored /// version of this would be: leader-mode A switches model on session X /// (which this client never opened) and we accidentally apply the change /// to the active agent. #[test] fn model_changed_dropped_for_unknown_session_id() { let mut app = make_app_with_agent("sess-1"); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]); let notif = model_changed_ext("sess-OTHER", "kigi-4", None, None); let changed = handle_ext_notification(¬if, &mut app); assert!(!changed); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( agent .session .models .current .as_ref() .map(|id| id.0.as_ref()), Some("kigi-3"), "unrelated-session broadcast must not touch this agent's model" ); }