Lock tool sessions to their model
This commit is contained in:
@@ -196,6 +196,13 @@ stored per model; the custom system prompt is shared across profiles. Other
|
|||||||
sections control A2UI, permission defaults, endpoint settings, Git diff display,
|
sections control A2UI, permission defaults, endpoint settings, Git diff display,
|
||||||
Dev Brain, extensions, checkpoint storage, and diagnostics.
|
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.
|
||||||
|
|
||||||
### Main views, sidebar, and branches
|
### Main views, sidebar, and branches
|
||||||
|
|
||||||
**Chat** shows the conversation, **A2UI** shows interactive surfaces, **Git**
|
**Chat** shows the conversation, **A2UI** shows interactive surfaces, **Git**
|
||||||
|
|||||||
1
migrations/20260901223000_add_session_model/down.sql
Normal file
1
migrations/20260901223000_add_session_model/down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE sessions DROP COLUMN model;
|
||||||
7
migrations/20260901223000_add_session_model/up.sql
Normal file
7
migrations/20260901223000_add_session_model/up.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE sessions ADD COLUMN model TEXT
|
||||||
|
CHECK (model IS NULL OR model IN (
|
||||||
|
'deepseek-v4-flash-0731',
|
||||||
|
'deepseek-v4-pro',
|
||||||
|
'glm-5.2',
|
||||||
|
'glm-5.3-flash'
|
||||||
|
));
|
||||||
@@ -3627,6 +3627,12 @@ pub(crate) fn parse_tool_calls(
|
|||||||
.map(|calls| (content, calls))
|
.map(|calls| (content, calls))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tool_protocol_model(text: &str) -> Option<ModelChoice> {
|
||||||
|
[ModelChoice::DeepSeekV4Flash0731, ModelChoice::Glm52]
|
||||||
|
.into_iter()
|
||||||
|
.find(|model| parse_tool_calls(*model, text).is_ok_and(|(_, calls)| !calls.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) -> String {
|
pub(crate) fn system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) -> String {
|
||||||
system_prompt_with_tools(model, extra, dev_brain, false)
|
system_prompt_with_tools(model, extra, dev_brain, false)
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/app.rs
115
src/app.rs
@@ -105,6 +105,8 @@ pub(crate) struct App {
|
|||||||
session_menu: Option<i32>,
|
session_menu: Option<i32>,
|
||||||
/// Session waiting for explicit confirmation before deletion.
|
/// Session waiting for explicit confirmation before deletion.
|
||||||
pending_session_delete: Option<i32>,
|
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 being renamed, with the in-progress title.
|
||||||
session_rename: Option<(i32, String)>,
|
session_rename: Option<(i32, String)>,
|
||||||
/// Projects whose archived sessions are expanded in the sidebar.
|
/// Projects whose archived sessions are expanded in the sidebar.
|
||||||
@@ -500,6 +502,8 @@ pub(crate) enum Message {
|
|||||||
AllowToolOnce,
|
AllowToolOnce,
|
||||||
DenyTool,
|
DenyTool,
|
||||||
SubmitPrompt,
|
SubmitPrompt,
|
||||||
|
ConfirmSessionModelSwitch,
|
||||||
|
CancelSessionModelSwitch,
|
||||||
StopGeneration,
|
StopGeneration,
|
||||||
GenerationTick,
|
GenerationTick,
|
||||||
ChatScrolled(scrollable::Viewport),
|
ChatScrolled(scrollable::Viewport),
|
||||||
@@ -645,6 +649,7 @@ impl App {
|
|||||||
background_chats: HashMap::new(),
|
background_chats: HashMap::new(),
|
||||||
session_menu: None,
|
session_menu: None,
|
||||||
pending_session_delete: None,
|
pending_session_delete: None,
|
||||||
|
pending_session_model_switch: None,
|
||||||
session_rename: None,
|
session_rename: None,
|
||||||
expanded_archives: HashSet::new(),
|
expanded_archives: HashSet::new(),
|
||||||
sidebar_drag: false,
|
sidebar_drag: false,
|
||||||
@@ -810,6 +815,7 @@ impl App {
|
|||||||
background_chats: HashMap::new(),
|
background_chats: HashMap::new(),
|
||||||
session_menu: None,
|
session_menu: None,
|
||||||
pending_session_delete: None,
|
pending_session_delete: None,
|
||||||
|
pending_session_model_switch: None,
|
||||||
session_rename: None,
|
session_rename: None,
|
||||||
expanded_archives: HashSet::new(),
|
expanded_archives: HashSet::new(),
|
||||||
sidebar_drag: false,
|
sidebar_drag: false,
|
||||||
@@ -1134,7 +1140,7 @@ impl App {
|
|||||||
let content = export_chat(
|
let content = export_chat(
|
||||||
format,
|
format,
|
||||||
&title,
|
&title,
|
||||||
self.config.model,
|
self.chat_parser_model(),
|
||||||
&self.conversation,
|
&self.conversation,
|
||||||
&surfaces,
|
&surfaces,
|
||||||
&images,
|
&images,
|
||||||
@@ -1241,6 +1247,8 @@ impl App {
|
|||||||
Message::DismissPanel => {
|
Message::DismissPanel => {
|
||||||
if self.quit_confirmation {
|
if self.quit_confirmation {
|
||||||
self.quit_confirmation = false;
|
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() {
|
} else if self.git_diff.is_some() {
|
||||||
self.git_diff = None;
|
self.git_diff = None;
|
||||||
} else if self.git_commit_all_confirmation {
|
} else if self.git_commit_all_confirmation {
|
||||||
@@ -1559,10 +1567,57 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::SubmitPrompt => {
|
Message::SubmitPrompt => {
|
||||||
|
if !self.generating && self.selected_session.is_some() {
|
||||||
|
match required_session_model_switch(
|
||||||
|
self.config.model,
|
||||||
|
self.selected_session_model(),
|
||||||
|
self.chat_tool_protocol_model(),
|
||||||
|
) {
|
||||||
|
Ok(Some(model)) => {
|
||||||
|
self.pending_session_model_switch = Some(model);
|
||||||
|
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" }
|
||||||
|
));
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
self.chat_follow_tail = true;
|
self.chat_follow_tail = true;
|
||||||
self.start_generation();
|
self.start_generation();
|
||||||
return scroll_chat_to_end();
|
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 => {
|
Message::StopGeneration => {
|
||||||
self.stop_requested = true;
|
self.stop_requested = true;
|
||||||
self.activity = Some("Stopping…".into());
|
self.activity = Some("Stopping…".into());
|
||||||
@@ -1938,6 +1993,7 @@ impl App {
|
|||||||
if self.selected_session == Some(session_id) {
|
if self.selected_session == Some(session_id) {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
|
self.pending_session_model_switch = None;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
self.leave_current_chat();
|
self.leave_current_chat();
|
||||||
@@ -1964,6 +2020,7 @@ impl App {
|
|||||||
session.context_limit,
|
session.context_limit,
|
||||||
session.last_tokens_per_second,
|
session.last_tokens_per_second,
|
||||||
session.permission_mode(),
|
session.permission_mode(),
|
||||||
|
session.model(),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let Some(database) = &mut self.database else {
|
let Some(database) = &mut self.database else {
|
||||||
@@ -1980,6 +2037,27 @@ impl App {
|
|||||||
self.error = Some(format!("Could not update the session: {error}"));
|
self.error = Some(format!("Could not update the session: {error}"));
|
||||||
return Task::none();
|
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,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
&& protocol_model.is_glm() == checkpoint_model.is_glm()
|
||||||
|
&& 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();
|
||||||
|
}
|
||||||
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
|
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
|
||||||
self.chat_follow_tail = true;
|
self.chat_follow_tail = true;
|
||||||
generation::promote_legacy_turn_summaries(&mut self.conversation);
|
generation::promote_legacy_turn_summaries(&mut self.conversation);
|
||||||
@@ -2005,8 +2083,8 @@ impl App {
|
|||||||
self.selected_session = Some(session_id);
|
self.selected_session = Some(session_id);
|
||||||
self.system_prompt_seen_at = 0;
|
self.system_prompt_seen_at = 0;
|
||||||
self.queued_inputs.clear();
|
self.queued_inputs.clear();
|
||||||
let (used, limit, tokens_per_second, permission_mode) =
|
let (used, limit, tokens_per_second, permission_mode, _) =
|
||||||
saved_context.unwrap_or((0, 0, None, PermissionMode::default()));
|
saved_context.unwrap_or((0, 0, None, PermissionMode::default(), None));
|
||||||
self.context_used = used.max(0) as u32;
|
self.context_used = used.max(0) as u32;
|
||||||
self.context_limit = if limit > 0 {
|
self.context_limit = if limit > 0 {
|
||||||
limit as u32
|
limit as u32
|
||||||
@@ -3154,6 +3232,18 @@ fn session_checkpoint_path(session_id: i32) -> PathBuf {
|
|||||||
kv_cache_path().join(format!("{session_id}.bin"))
|
kv_cache_path().join(format!("{session_id}.bin"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn required_session_model_switch(
|
||||||
|
current: ModelChoice,
|
||||||
|
locked: Option<ModelChoice>,
|
||||||
|
legacy_protocol: Option<ModelChoice>,
|
||||||
|
) -> Result<Option<ModelChoice>, ModelChoice> {
|
||||||
|
match locked {
|
||||||
|
Some(model) if model != current => Ok(Some(model)),
|
||||||
|
Some(_) => Ok(None),
|
||||||
|
None => legacy_protocol.map_or(Ok(None), Err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn discard_session_checkpoint_files(directory: &Path, session_id: i32) -> Result<bool, String> {
|
fn discard_session_checkpoint_files(directory: &Path, session_id: i32) -> Result<bool, String> {
|
||||||
let mut removed = false;
|
let mut removed = false;
|
||||||
for extension in ["bin", "tmp", "compacting"] {
|
for extension in ["bin", "tmp", "compacting"] {
|
||||||
@@ -3204,6 +3294,25 @@ pub(crate) fn app_icon() -> window::Icon {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_calls_lock_continuation_to_the_session_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));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preferences_shortcut_and_dspark_support_are_explicit() {
|
fn preferences_shortcut_and_dspark_support_are_explicit() {
|
||||||
let message = shortcut(
|
let message = shortcut(
|
||||||
|
|||||||
@@ -1584,6 +1584,11 @@ impl App {
|
|||||||
let session_id = self
|
let session_id = self
|
||||||
.selected_session
|
.selected_session
|
||||||
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
|
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
|
||||||
|
self.database
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| "The project database is unavailable.".to_owned())?
|
||||||
|
.set_session_model(session_id, self.config.model)?;
|
||||||
|
self.reload_projects();
|
||||||
if self.agent_tools.as_ref().map(|(id, _)| *id) != Some(session_id) {
|
if self.agent_tools.as_ref().map(|(id, _)| *id) != Some(session_id) {
|
||||||
let project_id = self
|
let project_id = self
|
||||||
.selected_project
|
.selected_project
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
pub(super) fn selected_session_model(&self) -> Option<ModelChoice> {
|
||||||
|
let session_id = self.selected_session?;
|
||||||
|
self.projects
|
||||||
|
.iter()
|
||||||
|
.flat_map(|project| &project.sessions)
|
||||||
|
.find(|session| session.id == session_id)
|
||||||
|
.and_then(|session| session.model())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn chat_tool_protocol_model(&self) -> Option<ModelChoice> {
|
||||||
|
self.conversation
|
||||||
|
.iter()
|
||||||
|
.find_map(|message| crate::agent::tool_protocol_model(&message.content))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn chat_parser_model(&self) -> ModelChoice {
|
||||||
|
self.selected_session_model()
|
||||||
|
.or_else(|| self.chat_tool_protocol_model())
|
||||||
|
.unwrap_or(self.config.model)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn clear_a2ui(&mut self) {
|
pub(super) fn clear_a2ui(&mut self) {
|
||||||
self.a2ui.clear();
|
self.a2ui.clear();
|
||||||
self.a2ui_history.clear();
|
self.a2ui_history.clear();
|
||||||
@@ -171,6 +192,7 @@ impl App {
|
|||||||
self.drafts.entry(project_id).or_insert(title);
|
self.drafts.entry(project_id).or_insert(title);
|
||||||
self.remember_project(project_id);
|
self.remember_project(project_id);
|
||||||
self.selected_session = None;
|
self.selected_session = None;
|
||||||
|
self.pending_session_model_switch = None;
|
||||||
self.permission_mode = self.config.default_permission_mode;
|
self.permission_mode = self.config.default_permission_mode;
|
||||||
self.conversation.clear();
|
self.conversation.clear();
|
||||||
self.chat_follow_tail = true;
|
self.chat_follow_tail = true;
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ impl App {
|
|||||||
/// tree, so the layers below have to stay out of the dialog's field order.
|
/// tree, so the layers below have to stay out of the dialog's field order.
|
||||||
pub(super) fn modal_open(&self) -> bool {
|
pub(super) fn modal_open(&self) -> bool {
|
||||||
self.quit_confirmation
|
self.quit_confirmation
|
||||||
|
|| self.pending_session_model_switch.is_some()
|
||||||
|| self.git_diff.is_some()
|
|| self.git_diff.is_some()
|
||||||
|| self.git_commit_all_confirmation
|
|| self.git_commit_all_confirmation
|
||||||
|| self.pending_project_path.is_some()
|
|| self.pending_project_path.is_some()
|
||||||
@@ -174,6 +175,8 @@ impl App {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if self.quit_confirmation {
|
if self.quit_confirmation {
|
||||||
layers.push(self.quit_confirmation_panel());
|
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 {
|
} else if let Some((prompt, _)) = &self.pending_tool_approval {
|
||||||
layers.push(self.tool_approval_panel(prompt));
|
layers.push(self.tool_approval_panel(prompt));
|
||||||
} else if let Some(path) = &self.pending_project_path {
|
} else if let Some(path) = &self.pending_project_path {
|
||||||
@@ -196,6 +199,8 @@ impl App {
|
|||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
if self.quit_confirmation {
|
if self.quit_confirmation {
|
||||||
layers.push(self.quit_confirmation_panel());
|
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 {
|
} else if let Some(path) = &self.pending_project_path {
|
||||||
layers.push(self.project_dialog(path));
|
layers.push(self.project_dialog(path));
|
||||||
} else if let Some((_, title)) = &self.session_rename {
|
} else if let Some((_, title)) = &self.session_rename {
|
||||||
@@ -252,6 +257,39 @@ 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")]
|
#[cfg(target_os = "macos")]
|
||||||
fn tool_approval_panel<'a>(
|
fn tool_approval_panel<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ impl App {
|
|||||||
if message.tool
|
if message.tool
|
||||||
&& index > 0
|
&& index > 0
|
||||||
&& !crate::agent::stored_tool_cards(
|
&& !crate::agent::stored_tool_cards(
|
||||||
self.config.model,
|
self.chat_parser_model(),
|
||||||
&self.conversation[index - 1].content,
|
&self.conversation[index - 1].content,
|
||||||
None,
|
None,
|
||||||
&self.conversation[index - 1].tool_approval_reasons,
|
&self.conversation[index - 1].tool_approval_reasons,
|
||||||
@@ -191,7 +191,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::agent::stored_tool_cards(
|
crate::agent::stored_tool_cards(
|
||||||
self.config.model,
|
self.chat_parser_model(),
|
||||||
&message.content,
|
&message.content,
|
||||||
stored_result,
|
stored_result,
|
||||||
&message.tool_approval_reasons,
|
&message.tool_approval_reasons,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use iced::widget::column;
|
|||||||
impl App {
|
impl App {
|
||||||
pub(super) fn stats_dashboard(&self) -> Element<'_, Message> {
|
pub(super) fn stats_dashboard(&self) -> Element<'_, Message> {
|
||||||
let stats = &self.metrics_snapshot;
|
let stats = &self.metrics_snapshot;
|
||||||
let session = SessionStats::from_messages(&self.conversation, self.config.model);
|
let session = SessionStats::from_messages(&self.conversation, self.chat_parser_model());
|
||||||
let context_fraction = if stats.context_limit == 0 {
|
let context_fraction = if stats.context_limit == 0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ pub struct Session {
|
|||||||
last_used: i64,
|
last_used: i64,
|
||||||
/// Raw column value; read it through [`Session::permission_mode`].
|
/// Raw column value; read it through [`Session::permission_mode`].
|
||||||
permission_mode: String,
|
permission_mode: String,
|
||||||
|
/// Locked after the first tool call; read it through [`Session::model`].
|
||||||
|
model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
@@ -56,6 +58,12 @@ impl Session {
|
|||||||
PermissionMode::from_id(&self.permission_mode).unwrap_or_default()
|
PermissionMode::from_id(&self.permission_mode).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn model(&self) -> Option<crate::model::ModelChoice> {
|
||||||
|
self.model
|
||||||
|
.as_deref()
|
||||||
|
.and_then(crate::model::ModelChoice::from_id)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn fixture(id: i32, project_id: i32, title: &str, state: SessionState) -> Self {
|
pub fn fixture(id: i32, project_id: i32, title: &str, state: SessionState) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -69,6 +77,7 @@ impl Session {
|
|||||||
compacted_summary: None,
|
compacted_summary: None,
|
||||||
last_used: 0,
|
last_used: 0,
|
||||||
permission_mode: PermissionMode::default().as_id().to_owned(),
|
permission_mode: PermissionMode::default().as_id().to_owned(),
|
||||||
|
model: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,6 +420,25 @@ impl Database {
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_session_model(
|
||||||
|
&mut self,
|
||||||
|
session_id: i32,
|
||||||
|
model: crate::model::ModelChoice,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let updated = diesel::update(
|
||||||
|
sessions::table
|
||||||
|
.find(session_id)
|
||||||
|
.filter(sessions::model.is_null().or(sessions::model.eq(model.id()))),
|
||||||
|
)
|
||||||
|
.set(sessions::model.eq(model.id()))
|
||||||
|
.execute(&mut self.connection)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if updated == 0 {
|
||||||
|
return Err("The session is locked to a different model.".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn touch_session(&mut self, session_id: i32) -> Result<(), String> {
|
pub fn touch_session(&mut self, session_id: i32) -> Result<(), String> {
|
||||||
touch_session(&mut self.connection, session_id).map_err(|error| error.to_string())
|
touch_session(&mut self.connection, session_id).map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
@@ -820,13 +848,29 @@ mod tests {
|
|||||||
assert_eq!(loaded[0].sessions[0].title, "Second session");
|
assert_eq!(loaded[0].sessions[0].title, "Second session");
|
||||||
assert_eq!(loaded[0].sessions[0].state(), SessionState::Normal);
|
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].permission_mode(), PermissionMode::Ai);
|
||||||
|
assert_eq!(loaded[0].sessions[0].model(), None);
|
||||||
database
|
database
|
||||||
.set_session_permission_mode(second.id, PermissionMode::Heuristic)
|
.set_session_permission_mode(second.id, PermissionMode::Heuristic)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
database
|
||||||
|
.set_session_model(second.id, crate::model::ModelChoice::DeepSeekV4Flash0731)
|
||||||
|
.unwrap();
|
||||||
|
database
|
||||||
|
.set_session_model(second.id, crate::model::ModelChoice::DeepSeekV4Flash0731)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
database
|
||||||
|
.set_session_model(second.id, crate::model::ModelChoice::Glm53Flash)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
database.load_projects().unwrap()[0].sessions[0].permission_mode(),
|
database.load_projects().unwrap()[0].sessions[0].permission_mode(),
|
||||||
PermissionMode::Heuristic
|
PermissionMode::Heuristic
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
database.load_projects().unwrap()[0].sessions[0].model(),
|
||||||
|
Some(crate::model::ModelChoice::DeepSeekV4Flash0731)
|
||||||
|
);
|
||||||
|
|
||||||
let ordinary = loaded[0].sessions[0].id;
|
let ordinary = loaded[0].sessions[0].id;
|
||||||
let pinned = database
|
let pinned = database
|
||||||
@@ -1288,6 +1332,9 @@ mod tests {
|
|||||||
"Tool result 1 (present_svg):\nSVG presented inline: A dark rectangle\n",
|
"Tool result 1 (present_svg):\nSVG presented inline: A dark rectangle\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
database
|
||||||
|
.set_session_model(session.id, crate::model::ModelChoice::DeepSeekV4Flash0731)
|
||||||
|
.unwrap();
|
||||||
database
|
database
|
||||||
.record_compaction(session.id, "Later context summary.", None, None, 100, 1_000)
|
.record_compaction(session.id, "Later context summary.", None, None, 100, 1_000)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1307,8 +1354,14 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|message| message.id == assistant.id)
|
.position(|message| message.id == assistant.id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let session_model = reopened.load_projects().unwrap()[0]
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|stored| stored.id == session.id)
|
||||||
|
.and_then(Session::model)
|
||||||
|
.unwrap();
|
||||||
let cards = crate::agent::stored_tool_cards(
|
let cards = crate::agent::stored_tool_cards(
|
||||||
crate::model::ModelChoice::DeepSeekV4Flash0731,
|
session_model,
|
||||||
&messages[assistant_index].content,
|
&messages[assistant_index].content,
|
||||||
Some(&messages[assistant_index + 1].content),
|
Some(&messages[assistant_index + 1].content),
|
||||||
&[],
|
&[],
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ use kvstore::{KvStore, StoreReason};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -43,6 +45,24 @@ const VISION_START_TOKEN: i32 = 154_830;
|
|||||||
const VISION_END_TOKEN: i32 = 154_831;
|
const VISION_END_TOKEN: i32 = 154_831;
|
||||||
type VisionOverlays = Vec<(u32, metal::VisionEmbedding)>;
|
type VisionOverlays = Vec<(u32, metal::VisionEmbedding)>;
|
||||||
|
|
||||||
|
pub(crate) fn checkpoint_model(path: &Path) -> Option<ModelChoice> {
|
||||||
|
let mut file = File::open(path).ok()?;
|
||||||
|
let mut magic = [0; 8];
|
||||||
|
file.read_exact(&mut magic).ok()?;
|
||||||
|
let model_size_offset = match &magic {
|
||||||
|
b"DS4RKV01" => 40,
|
||||||
|
b"DS4GLM01" => 44,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
file.seek(SeekFrom::Start(model_size_offset)).ok()?;
|
||||||
|
let mut bytes = [0; 8];
|
||||||
|
file.read_exact(&mut bytes).ok()?;
|
||||||
|
let size = u64::from_le_bytes(bytes);
|
||||||
|
crate::model::MODEL_CHOICES
|
||||||
|
.into_iter()
|
||||||
|
.find(|model| model.main_artifact_size() == size)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn vision_data_marker(uri: &str) -> String {
|
pub(crate) fn vision_data_marker(uri: &str) -> String {
|
||||||
format!("{VISION_DATA_START}{uri}{VISION_DATA_END}")
|
format!("{VISION_DATA_START}{uri}{VISION_DATA_END}")
|
||||||
}
|
}
|
||||||
@@ -1917,6 +1937,25 @@ impl Rng {
|
|||||||
mod sampling_tests {
|
mod sampling_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkpoint_headers_identify_the_exact_model() {
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("ds4-checkpoint-model-{}", std::process::id()));
|
||||||
|
for (magic, offset, model) in [
|
||||||
|
(b"DS4RKV01".as_slice(), 40, ModelChoice::DeepSeekV4Flash0731),
|
||||||
|
(b"DS4RKV01".as_slice(), 40, ModelChoice::DeepSeekV4Pro),
|
||||||
|
(b"DS4GLM01".as_slice(), 44, ModelChoice::Glm52),
|
||||||
|
(b"DS4GLM01".as_slice(), 44, ModelChoice::Glm53Flash),
|
||||||
|
] {
|
||||||
|
let mut bytes = magic.to_vec();
|
||||||
|
bytes.resize(offset, 0);
|
||||||
|
bytes.extend_from_slice(&model.main_artifact_size().to_le_bytes());
|
||||||
|
std::fs::write(&path, bytes).unwrap();
|
||||||
|
assert_eq!(checkpoint_model(&path), Some(model));
|
||||||
|
}
|
||||||
|
std::fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zero_temperature_is_greedy() {
|
fn zero_temperature_is_greedy() {
|
||||||
let mut rng = Rng::new(1);
|
let mut rng = Rng::new(1);
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ impl ModelChoice {
|
|||||||
self.is_glm()
|
self.is_glm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn main_artifact_size(self) -> u64 {
|
||||||
|
self.main_artifact().size
|
||||||
|
}
|
||||||
|
|
||||||
fn main_artifact(self) -> &'static Artifact {
|
fn main_artifact(self) -> &'static Artifact {
|
||||||
match self {
|
match self {
|
||||||
Self::DeepSeekV4Flash0731 => &FLASH_0731,
|
Self::DeepSeekV4Flash0731 => &FLASH_0731,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ diesel::table! {
|
|||||||
compacted_summary -> Nullable<Text>,
|
compacted_summary -> Nullable<Text>,
|
||||||
last_used -> BigInt,
|
last_used -> BigInt,
|
||||||
permission_mode -> Text,
|
permission_mode -> Text,
|
||||||
|
model -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user