Bind sessions to their selected model

This commit is contained in:
Georg Bauer
2026-09-01 22:52:01 +02:00
parent 35f306bf2a
commit 9556b26c55
5 changed files with 163 additions and 149 deletions

View File

@@ -105,8 +105,6 @@ pub(crate) struct App {
session_menu: Option<i32>,
/// Session waiting for explicit confirmation before deletion.
pending_session_delete: Option<i32>,
/// Locked session model waiting to replace the current global selection.
pending_session_model_switch: Option<ModelChoice>,
/// 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<ModelChoice>,
legacy_protocol: Option<ModelChoice>,
) -> Result<Option<ModelChoice>, ModelChoice> {
has_history: bool,
) -> Result<Option<ModelChoice>, ()> {
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]

View File

@@ -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<ModelChoice> {
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);

View File

@@ -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,

View File

@@ -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<String>,
}
@@ -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<Session, String> {
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)