use agent_client_protocol as acp; use kigi_sampling_types::{ReasoningEffort, ReasoningEffortOption}; use serde::Serialize; use crate::session::unified_list::SessionKind; pub(crate) const SELECTABLE_REASONING_EFFORTS: [ReasoningEffort; 5] = [ ReasoningEffort::Minimal, ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Xhigh, ]; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionConfigOption { pub id: String, pub category: String, pub label: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub selected: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct KigiSessionDetail { pub session_id: String, pub kind: String, pub cwd: String, pub current_model_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, } impl KigiSessionDetail { pub fn build( session_id: String, cwd: String, current_model_id: String, title: Option, ) -> Self { Self { session_id, kind: SessionKind::Build.as_str().to_string(), cwd, current_model_id, title, } } } fn effort_label(effort: ReasoningEffort) -> String { match effort { ReasoningEffort::None => "None", ReasoningEffort::Minimal => "Minimal", ReasoningEffort::Low => "Low", ReasoningEffort::Medium => "Medium", ReasoningEffort::High => "High", ReasoningEffort::Xhigh => "X-High", } .to_string() } /// The built-in session-picker modes used when the model has no server list. /// Reproduces the historical five rows and their labels. pub(crate) fn legacy_session_effort_options() -> Vec { SELECTABLE_REASONING_EFFORTS .iter() .map(|&effort| ReasoningEffortOption { id: effort.as_str().to_string(), value: effort, label: effort_label(effort), description: None, default: false, }) .collect() } pub(crate) fn build_session_config_options( available_models: &[acp::ModelInfo], current_model_id: &acp::ModelId, effort_options: &[ReasoningEffortOption], current_effort: Option, ) -> Vec { let mut options = Vec::with_capacity(available_models.len() + effort_options.len()); for model in available_models { let label = if model.name.is_empty() { model.model_id.0.to_string() } else { model.name.clone() }; options.push(SessionConfigOption { id: model.model_id.0.to_string(), category: "model".to_string(), label, description: None, selected: model.model_id == *current_model_id, }); } for effort in effort_options { options.push(SessionConfigOption { id: effort.id.clone(), category: "mode".to_string(), label: effort.label.clone(), description: effort.description.clone(), selected: Some(effort.value) == current_effort, }); } options } #[cfg(test)] mod tests { use super::*; fn model(id: &'static str, name: &str) -> acp::ModelInfo { acp::ModelInfo::new(acp::ModelId::new(id), name.to_string()) } #[test] fn options_have_one_selected_model_and_a_mode_per_effort() { let models = [model("kigi", "Kigi"), model("kigi-4.5", "Kigi 4.5")]; let current = acp::ModelId::from("kigi"); let opts = build_session_config_options( &models, ¤t, &legacy_session_effort_options(), Some(ReasoningEffort::High), ); let model_opts: Vec<_> = opts.iter().filter(|o| o.category == "model").collect(); assert_eq!(model_opts.len(), 2); let selected_models: Vec<_> = model_opts.iter().filter(|o| o.selected).collect(); assert_eq!(selected_models.len(), 1); assert_eq!(selected_models[0].id, "kigi"); let mode_opts: Vec<_> = opts.iter().filter(|o| o.category == "mode").collect(); assert_eq!(mode_opts.len(), SELECTABLE_REASONING_EFFORTS.len()); let selected_modes: Vec<_> = mode_opts.iter().filter(|o| o.selected).collect(); assert_eq!(selected_modes.len(), 1); assert_eq!(selected_modes[0].id, "high"); assert_eq!(selected_modes[0].label, "High"); } #[test] fn none_effort_is_not_a_user_selectable_mode() { assert!(!SELECTABLE_REASONING_EFFORTS.contains(&ReasoningEffort::None)); let models = [model("kigi", "Kigi")]; let current = acp::ModelId::from("kigi"); let opts = build_session_config_options( &models, ¤t, &legacy_session_effort_options(), Some(ReasoningEffort::None), ); let modes: Vec<_> = opts.iter().filter(|o| o.category == "mode").collect(); assert!(modes.iter().all(|o| o.id != "none")); assert!(modes.iter().all(|o| !o.selected)); } #[test] fn no_mode_options_when_model_lacks_effort_support() { let models = [model("kigi", "Kigi")]; let current = acp::ModelId::from("kigi"); let opts = build_session_config_options(&models, ¤t, &[], None); assert_eq!(opts.len(), 1); assert!(opts.iter().all(|o| o.category == "model")); } #[test] fn model_label_falls_back_to_id_when_name_empty() { let models = [model("kigi", "")]; let current = acp::ModelId::from("kigi"); let opts = build_session_config_options(&models, ¤t, &[], None); assert_eq!(opts[0].label, "kigi"); } #[test] fn session_config_option_serializes_camel_case() { let opt = SessionConfigOption { id: "kigi".to_string(), category: "model".to_string(), label: "Kigi".to_string(), description: None, selected: true, }; let v = serde_json::to_value(&opt).expect("serialize"); assert_eq!(v["id"], "kigi"); assert_eq!(v["category"], "model"); assert_eq!(v["label"], "Kigi"); assert_eq!(v["selected"], true); assert!(v.get("description").is_none()); } #[test] fn kigi_session_detail_serializes_camel_case() { let detail = KigiSessionDetail::build( "sess-1".to_string(), "/Users/me/xai".to_string(), "kigi".to_string(), None, ); let v = serde_json::to_value(&detail).expect("serialize"); assert_eq!(v["sessionId"], "sess-1"); assert_eq!(v["kind"], "build"); assert_eq!(v["cwd"], "/Users/me/xai"); assert_eq!(v["currentModelId"], "kigi"); assert!(v.get("title").is_none()); } }