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

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

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)