diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 915e7f5..dff12e6 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -196,12 +196,11 @@ stored per model; the custom system prompt is shared across profiles. Other sections control A2UI, permission defaults, endpoint settings, Git diff display, Dev Brain, extensions, checkpoint storage, and diagnostics. -A session remains model-independent until its first tool call. That call locks -the session to its model so persisted tool syntax is never mixed. Continuing a -locked session while another model is active asks before switching back. Legacy -sessions recover the exact model from their checkpoint when available; a legacy -tool session whose exact model can no longer be identified remains viewable but -cannot be continued. +A new draft uses the currently selected model when its first turn is persisted. +From then on the session keeps that exact model, so opening it immediately selects +the same model and that model's thinking choices. Legacy sessions recover the +exact model from their checkpoint when available; a legacy session whose +exact model can no longer be identified remains viewable but cannot be continued. ### Main views, sidebar, and branches @@ -232,7 +231,9 @@ restarts the local listener. accepts `none`, `high`, or `max`; GLM 5.3 Flash accepts `low`, `high`, or `max`. Omitting it uses the upstream model default: `low` for DeepSeek and `max` for both GLM models. Unsupported values and conflicting thinking controls return a -400 error instead of being converted to another effort. +400 error instead of being converted to another effort. This per-model thinking +selection is intentionally part of the DS4Server agent harness, not a DS4 parity +surface; do not replace it with mapped, hidden, or shared choices. ### Data and recovery diff --git a/src/app.rs b/src/app.rs index 5ffe349..771ce2b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -105,8 +105,6 @@ pub(crate) struct App { session_menu: Option, /// Session waiting for explicit confirmation before deletion. pending_session_delete: Option, - /// Locked session model waiting to replace the current global selection. - pending_session_model_switch: Option, /// Session being renamed, with the in-progress title. session_rename: Option<(i32, String)>, /// Projects whose archived sessions are expanded in the sidebar. @@ -502,8 +500,6 @@ pub(crate) enum Message { AllowToolOnce, DenyTool, SubmitPrompt, - ConfirmSessionModelSwitch, - CancelSessionModelSwitch, StopGeneration, GenerationTick, ChatScrolled(scrollable::Viewport), @@ -649,7 +645,6 @@ impl App { background_chats: HashMap::new(), session_menu: None, pending_session_delete: None, - pending_session_model_switch: None, session_rename: None, expanded_archives: HashSet::new(), sidebar_drag: false, @@ -815,7 +810,6 @@ impl App { background_chats: HashMap::new(), session_menu: None, pending_session_delete: None, - pending_session_model_switch: None, session_rename: None, expanded_archives: HashSet::new(), sidebar_drag: false, @@ -1247,8 +1241,6 @@ impl App { Message::DismissPanel => { if self.quit_confirmation { self.quit_confirmation = false; - } else if self.pending_session_model_switch.is_some() { - self.pending_session_model_switch = None; } else if self.git_diff.is_some() { self.git_diff = None; } else if self.git_commit_all_confirmation { @@ -1568,20 +1560,21 @@ impl App { } Message::SubmitPrompt => { if !self.generating && self.selected_session.is_some() { - match required_session_model_switch( - self.config.model, + match required_session_model( self.selected_session_model(), - self.chat_tool_protocol_model(), + !self.conversation.is_empty(), ) { Ok(Some(model)) => { - self.pending_session_model_switch = Some(model); - return Task::none(); + if let Err(error) = self.activate_model(model) { + self.error = Some(error); + return Task::none(); + } } - Err(protocol) => { - self.error = Some(format!( - "This legacy session contains {} tool calls, but its exact model was not recorded and no compatible checkpoint remains. Start a new session to continue safely.", - if protocol.is_glm() { "GLM" } else { "DeepSeek" } - )); + Err(()) => { + self.error = Some( + "This legacy session has chat history, but its exact model was not recorded and no compatible checkpoint remains. Start a new session to continue safely." + .into(), + ); return Task::none(); } Ok(None) => {} @@ -1591,33 +1584,6 @@ impl App { self.start_generation(); return scroll_chat_to_end(); } - Message::ConfirmSessionModelSwitch => { - let Some(model) = self.pending_session_model_switch.take() else { - return Task::none(); - }; - let mut config = self.config.clone(); - config.model = model; - if let Err(error) = config.save(&config_path()) { - self.error = Some(error); - return Task::none(); - } - self.config = config; - self.preference_draft = PreferenceDraft::from_saved(&self.config); - self.context_limit = self.config.active_generation().context_tokens.max(0) as u32; - if model != ModelChoice::Glm53Flash { - self.pending_vision_image = None; - } - #[cfg(target_os = "macos")] - { - self.agent_tools = None; - preferences::update_runtime_config(&self.runtime_config, &self.config); - } - self.error = None; - self.chat_follow_tail = true; - self.start_generation(); - return scroll_chat_to_end(); - } - Message::CancelSessionModelSwitch => self.pending_session_model_switch = None, Message::StopGeneration => { self.stop_requested = true; self.activity = Some("Stopping…".into()); @@ -1993,7 +1959,18 @@ impl App { if self.selected_session == Some(session_id) { return Task::none(); } - self.pending_session_model_switch = None; + let stored_model = self + .projects + .iter() + .flat_map(|project| &project.sessions) + .find(|session| session.id == session_id) + .and_then(|session| session.model()); + if let Some(model) = stored_model + && let Err(error) = self.activate_model(model) + { + self.error = Some(error); + return Task::none(); + } #[cfg(target_os = "macos")] { self.leave_current_chat(); @@ -2037,26 +2014,28 @@ impl App { self.error = Some(format!("Could not update the session: {error}")); return Task::none(); } - let stored_model = saved_context - .as_ref() - .and_then(|(_, _, _, _, model)| *model); let protocol_model = messages.iter().find_map(|message| { crate::agent::tool_protocol_model(&message.content) }); if stored_model.is_none() - && let (Some(protocol_model), Some(checkpoint_model)) = ( - protocol_model, - crate::engine::checkpoint_model(&session_checkpoint_path( - session_id, - )), + && let Some(checkpoint_model) = crate::engine::checkpoint_model( + &session_checkpoint_path(session_id), ) - && protocol_model.is_glm() == checkpoint_model.is_glm() - && let Err(error) = - database.set_session_model(session_id, checkpoint_model) + && protocol_model.is_none_or(|protocol| { + protocol.is_glm() == checkpoint_model.is_glm() + }) { - self.error = - Some(format!("Could not restore the session model: {error}")); - return Task::none(); + if let Err(error) = + database.set_session_model(session_id, checkpoint_model) + { + self.error = + Some(format!("Could not restore the session model: {error}")); + return Task::none(); + } + if let Err(error) = self.activate_model(checkpoint_model) { + self.error = Some(error); + return Task::none(); + } } self.conversation = messages.into_iter().map(ChatMessage::from).collect(); self.chat_follow_tail = true; @@ -3232,15 +3211,14 @@ fn session_checkpoint_path(session_id: i32) -> PathBuf { kv_cache_path().join(format!("{session_id}.bin")) } -fn required_session_model_switch( - current: ModelChoice, +fn required_session_model( locked: Option, - legacy_protocol: Option, -) -> Result, ModelChoice> { + has_history: bool, +) -> Result, ()> { match locked { - Some(model) if model != current => Ok(Some(model)), - Some(_) => Ok(None), - None => legacy_protocol.map_or(Ok(None), Err), + Some(model) => Ok(Some(model)), + None if has_history => Err(()), + None => Ok(None), } } @@ -3295,22 +3273,11 @@ mod tests { use super::*; #[test] - fn tool_calls_lock_continuation_to_the_session_model() { + fn stored_session_always_selects_its_model() { let flash = ModelChoice::DeepSeekV4Flash0731; - let glm = ModelChoice::Glm53Flash; - assert_eq!( - required_session_model_switch(flash, Some(flash), None), - Ok(None) - ); - assert_eq!( - required_session_model_switch(glm, Some(flash), None), - Ok(Some(flash)) - ); - assert_eq!( - required_session_model_switch(glm, None, Some(flash)), - Err(flash) - ); - assert_eq!(required_session_model_switch(glm, None, None), Ok(None)); + assert_eq!(required_session_model(Some(flash), true), Ok(Some(flash))); + assert_eq!(required_session_model(None, true), Err(())); + assert_eq!(required_session_model(None, false), Ok(None)); } #[test] diff --git a/src/app/projects.rs b/src/app/projects.rs index 7599e81..7f3a761 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -1,6 +1,27 @@ use super::*; impl App { + pub(super) fn activate_model(&mut self, model: ModelChoice) -> Result<(), String> { + if self.config.model == model { + return Ok(()); + } + let mut config = self.config.clone(); + config.model = model; + config.save(&config_path())?; + self.config = config; + self.preference_draft = PreferenceDraft::from_saved(&self.config); + self.context_limit = self.config.active_generation().context_tokens.max(0) as u32; + if model != ModelChoice::Glm53Flash { + self.pending_vision_image = None; + } + #[cfg(target_os = "macos")] + { + self.agent_tools = None; + preferences::update_runtime_config(&self.runtime_config, &self.config); + } + Ok(()) + } + pub(super) fn selected_session_model(&self) -> Option { let session_id = self.selected_session?; self.projects @@ -192,7 +213,6 @@ impl App { self.drafts.entry(project_id).or_insert(title); self.remember_project(project_id); self.selected_session = None; - self.pending_session_model_switch = None; self.permission_mode = self.config.default_permission_mode; self.conversation.clear(); self.chat_follow_tail = true; @@ -234,7 +254,8 @@ impl App { .database .as_mut() .ok_or_else(|| "The project database is unavailable.".to_owned())?; - let session = database.create_session(project_id, &title, self.permission_mode)?; + let session = + database.create_session(project_id, &title, self.permission_mode, self.config.model)?; self.drafts.remove(&project_id); self.remember_project(project_id); self.selected_session = Some(session.id); diff --git a/src/app/view.rs b/src/app/view.rs index fe4b0bd..96df342 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -110,7 +110,6 @@ impl App { /// tree, so the layers below have to stay out of the dialog's field order. pub(super) fn modal_open(&self) -> bool { self.quit_confirmation - || self.pending_session_model_switch.is_some() || self.git_diff.is_some() || self.git_commit_all_confirmation || self.pending_project_path.is_some() @@ -175,8 +174,6 @@ impl App { #[cfg(target_os = "macos")] if self.quit_confirmation { layers.push(self.quit_confirmation_panel()); - } else if let Some(model) = self.pending_session_model_switch { - layers.push(self.session_model_switch_panel(model)); } else if let Some((prompt, _)) = &self.pending_tool_approval { layers.push(self.tool_approval_panel(prompt)); } else if let Some(path) = &self.pending_project_path { @@ -199,8 +196,6 @@ impl App { #[cfg(not(target_os = "macos"))] if self.quit_confirmation { layers.push(self.quit_confirmation_panel()); - } else if let Some(model) = self.pending_session_model_switch { - layers.push(self.session_model_switch_panel(model)); } else if let Some(path) = &self.pending_project_path { layers.push(self.project_dialog(path)); } else if let Some((_, title)) = &self.session_rename { @@ -257,39 +252,6 @@ impl App { ) } - fn session_model_switch_panel(&self, model: ModelChoice) -> Element<'_, Message> { - let title = self.active_chat_title().unwrap_or("This session"); - let dialog = container( - column![ - text("Switch model to continue?").size(22), - text(format!( - "“{title}” is locked to {model} because it contains that model's tool calls. Continuing will switch the active model from {} to {model}.", - self.config.model - )) - .size(13), - row![ - Space::new().width(Length::Fill), - action_button("Cancel").on_press(Message::CancelSessionModelSwitch), - action_button("Switch and continue") - .on_press(Message::ConfirmSessionModelSwitch), - ] - .spacing(8), - ] - .spacing(12), - ) - .padding(22) - .width(520) - .style(overview_style); - opaque( - container(dialog) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(|_| { - container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) - }), - ) - } - #[cfg(target_os = "macos")] fn tool_approval_panel<'a>( &'a self, diff --git a/src/database.rs b/src/database.rs index f86b7fb..d5564d5 100644 --- a/src/database.rs +++ b/src/database.rs @@ -45,7 +45,7 @@ pub struct Session { last_used: i64, /// Raw column value; read it through [`Session::permission_mode`]. permission_mode: String, - /// Locked after the first tool call; read it through [`Session::model`]. + /// Locked when the first turn is persisted; read it through [`Session::model`]. model: Option, } @@ -127,6 +127,7 @@ struct NewSession<'a> { title: &'a str, last_used: i64, permission_mode: &'a str, + model: &'a str, } #[derive(Clone, Debug, Identifiable, Queryable, Selectable)] @@ -395,6 +396,7 @@ impl Database { project_id: i32, title: &str, permission_mode: PermissionMode, + model: crate::model::ModelChoice, ) -> Result { diesel::insert_into(sessions::table) .values(NewSession { @@ -402,6 +404,7 @@ impl Database { title, last_used: OffsetDateTime::now_utc().unix_timestamp(), permission_mode: permission_mode.as_id(), + model: model.id(), }) .returning(Session::as_returning()) .get_result(&mut self.connection) @@ -838,29 +841,39 @@ mod tests { let project = database.create_project("DS4", "/tmp/ds4").unwrap(); let first = database - .create_session(project.id, "First session", PermissionMode::Heuristic) + .create_session( + project.id, + "First session", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); let second = database - .create_session(project.id, "Second session", PermissionMode::Ai) + .create_session( + project.id, + "Second session", + PermissionMode::Ai, + crate::model::ModelChoice::Glm53Flash, + ) .unwrap(); database.delete_session(first.id).unwrap(); let loaded = database.load_projects().unwrap(); assert_eq!(loaded[0].sessions[0].title, "Second session"); assert_eq!(loaded[0].sessions[0].state(), SessionState::Normal); assert_eq!(loaded[0].sessions[0].permission_mode(), PermissionMode::Ai); - assert_eq!(loaded[0].sessions[0].model(), None); + assert_eq!( + loaded[0].sessions[0].model(), + Some(crate::model::ModelChoice::Glm53Flash) + ); database .set_session_permission_mode(second.id, PermissionMode::Heuristic) .unwrap(); database - .set_session_model(second.id, crate::model::ModelChoice::DeepSeekV4Flash0731) - .unwrap(); - database - .set_session_model(second.id, crate::model::ModelChoice::DeepSeekV4Flash0731) + .set_session_model(second.id, crate::model::ModelChoice::Glm53Flash) .unwrap(); assert!( database - .set_session_model(second.id, crate::model::ModelChoice::Glm53Flash) + .set_session_model(second.id, crate::model::ModelChoice::DeepSeekV4Flash0731) .is_err() ); assert_eq!( @@ -869,15 +882,25 @@ mod tests { ); assert_eq!( database.load_projects().unwrap()[0].sessions[0].model(), - Some(crate::model::ModelChoice::DeepSeekV4Flash0731) + Some(crate::model::ModelChoice::Glm53Flash) ); let ordinary = loaded[0].sessions[0].id; let pinned = database - .create_session(project.id, "Pinned", PermissionMode::Heuristic) + .create_session( + project.id, + "Pinned", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); let archived = database - .create_session(project.id, "Archived", PermissionMode::Heuristic) + .create_session( + project.id, + "Archived", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); database .set_session_state(pinned.id, SessionState::Pinned) @@ -934,13 +957,28 @@ mod tests { .create_project("DS4", "/tmp/ds4-session-order") .unwrap(); let older = database - .create_session(project.id, "Older", PermissionMode::Heuristic) + .create_session( + project.id, + "Older", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); let newer = database - .create_session(project.id, "Newer", PermissionMode::Heuristic) + .create_session( + project.id, + "Newer", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); let pinned = database - .create_session(project.id, "Pinned", PermissionMode::Heuristic) + .create_session( + project.id, + "Pinned", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); database .set_session_state(pinned.id, SessionState::Pinned) @@ -980,7 +1018,12 @@ mod tests { .create_project("DS4", "/tmp/ds4-reactivate") .unwrap(); let session = database - .create_session(project.id, "Archived", PermissionMode::Heuristic) + .create_session( + project.id, + "Archived", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); database .set_session_state(session.id, SessionState::Archived) @@ -1020,7 +1063,12 @@ mod tests { .create_project("DS4", "/tmp/ds4-a2ui-dismiss") .unwrap(); let session = database - .create_session(project.id, "A2UI", PermissionMode::Heuristic) + .create_session( + project.id, + "A2UI", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); let first = database .start_chat_turn(session.id, "First", None, &[], false) @@ -1100,7 +1148,12 @@ mod tests { let mut database = Database::open(&path).unwrap(); let project = database.create_project("DS4", "/tmp/ds4-chat").unwrap(); let session = database - .create_session(project.id, "Chat", PermissionMode::Heuristic) + .create_session( + project.id, + "Chat", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); let mut opening = database .start_chat_turn( @@ -1309,10 +1362,20 @@ mod tests { let mut database = Database::open(&path).unwrap(); let project = database.create_project("DS4", "/tmp/ds4-svg-chat").unwrap(); let session = database - .create_session(project.id, "SVG", PermissionMode::Heuristic) + .create_session( + project.id, + "SVG", + PermissionMode::Heuristic, + crate::model::ModelChoice::DeepSeekV4Flash0731, + ) .unwrap(); let other = database - .create_session(project.id, "Other", PermissionMode::Heuristic) + .create_session( + project.id, + "Other", + PermissionMode::Heuristic, + crate::model::ModelChoice::Glm53Flash, + ) .unwrap(); let assistant = database .start_chat_turn(session.id, "Draw it", None, &[], false)