4 files changed
+449
-49
src/chat.rs
+81
-7
@@ -21,7 +21,7 @@ use onde::inference::{
21
ChatEngine, GgufModelConfig, SamplingConfig, StreamChunk, ToolDefinition, ToolResult,
22
};
23
24
-use crate::setup::DiscoveredModel;
24
+use crate::setup::{DiscoveredModel, ModelCacheHealth};
25
use ratatui::{
26
Frame,
27
layout::{Constraint, Layout, Position},
@@ -262,17 +262,31 @@ impl App {
262
}
263
264
fn open_model_picker(&mut self, engine: &ChatEngine) {
265
- let current = crate::setup::load_selected_model_name().unwrap_or_else(|| {
265
+ let current = crate::setup::load_selected_model();
266
+ let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| {
267
futures::executor::block_on(engine.info())
268
.model_name
269
.unwrap_or_else(|| self.current_model_name.clone())
270
});
271
272
self.model_picker_items = build_model_picker_items();
272
- self.model_picker_index = self
273
- .model_picker_items
274
- .iter()
275
- .position(|item| item.display_name == current)
273
+ self.model_picker_index = current
274
+ .as_ref()
275
+ .and_then(|selected| {
276
+ self.model_picker_items.iter().position(|item| {
277
+ item.config.model_id == selected.model_id
278
+ && item
279
+ .config
280
+ .files
281
+ .iter()
282
+ .any(|file| file == &selected.gguf_file)
283
+ })
284
+ })
285
+ .or_else(|| {
286
+ self.model_picker_items
287
+ .iter()
288
+ .position(|item| item.display_name == current_name)
289
+ })
290
.unwrap_or(0);
291
self.show_model_picker = true;
292
}
@@ -374,6 +388,7 @@ pub(crate) struct ModelPickerItem {
388
local_path: Option<String>,
389
brand_mark: &'static str,
390
source: ModelSource,
391
+ cache_health: ModelCacheHealth,
392
}
393
394
pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
@@ -400,6 +415,7 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
415
local_path: None,
416
brand_mark: "◎",
417
source: ModelSource::Fallback,
418
+ cache_health: ModelCacheHealth::Complete,
419
});
420
}
421
@@ -420,6 +436,8 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
436
437
let config = match model.model_id.as_str() {
438
"bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
439
+ "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
440
+ "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
441
"bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
442
"bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
443
_ => return None,
@@ -442,6 +460,7 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
460
} else {
461
ModelSource::HuggingFace
462
},
463
+ cache_health: model.cache_health,
464
})
465
}
466
@@ -503,7 +522,15 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
522
} else {
523
""
524
};
525
+ let health_badge = match item.cache_health {
526
+ ModelCacheHealth::Complete => "",
527
+ ModelCacheHealth::Incomplete => " ! incomplete cache",
528
+ };
529
let current_badge = if current { " ← current" } else { "" };
530
+ let disabled_badge = match item.cache_health {
531
+ ModelCacheHealth::Complete => "",
532
+ ModelCacheHealth::Incomplete => " (unselectable)",
533
+ };
534
let source = format!(" [{} {}]", item.brand_mark, item.source_label);
535
536
let base_style = if selected {
@@ -522,6 +549,12 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
549
}
550
};
551
552
+ let health_style = if selected {
553
+ Style::default().fg(Color::Red).bg(Color::White)
554
+ } else {
555
+ Style::default().fg(Color::Red)
556
+ };
557
+
558
lines.push(Line::from(vec![
559
Span::styled(
560
format!("{marker}{} {}", item.display_name, item.description),
@@ -535,6 +568,15 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
568
Style::default().fg(Color::Green)
569
},
570
),
571
+ Span::styled(health_badge.to_string(), health_style),
572
+ Span::styled(
573
+ disabled_badge.to_string(),
574
+ if selected {
575
+ Style::default().fg(Color::DarkGray).bg(Color::White)
576
+ } else {
577
+ Style::default().fg(Color::DarkGray)
578
+ },
579
+ ),
580
Span::styled(
581
current_badge.to_string(),
582
if selected {
@@ -1085,6 +1127,15 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1127
)));
1128
}
1129
Some(model) => {
1130
+ if model.cache_health == ModelCacheHealth::Incomplete {
1131
+ app.close_model_picker();
1132
+ app.messages.push(ChatMessage::system(format!(
1133
+ "error: {} has an incomplete local cache and cannot be selected yet.",
1134
+ model.display_name
1135
+ )));
1136
+ return;
1137
+ }
1138
+
1139
app.close_model_picker();
1140
app.messages.push(ChatMessage::system(format!(
1141
"Loading {}…",
@@ -1286,7 +1337,30 @@ async fn event_loop<B: ratatui::backend::Backend>(
1337
app.switching_model = false;
1338
app.model_load_rx = None;
1339
app.current_model_name = model_name.clone();
1289
- if let Err(error) = crate::setup::save_selected_model_name(&model_name) {
1340
+
1341
+ let save_result = app
1342
+ .model_picker_items
1343
+ .iter()
1344
+ .find(|item| item.display_name == model_name)
1345
+ .map(|item| crate::setup::SelectedModel {
1346
+ model_id: item.config.model_id.clone(),
1347
+ gguf_file: item
1348
+ .config
1349
+ .files
1350
+ .first()
1351
+ .cloned()
1352
+ .unwrap_or_else(String::new),
1353
+ })
1354
+ .filter(|selected| !selected.gguf_file.is_empty())
1355
+ .map(|selected| crate::setup::save_selected_model(&selected))
1356
+ .unwrap_or_else(|| {
1357
+ Err(format!(
1358
+ "could not determine a stable identifier for {}",
1359
+ model_name
1360
+ ))
1361
+ });
1362
+
1363
+ if let Err(error) = save_result {
1364
app.messages.push(ChatMessage::system(format!(
1365
"warning: switched to {} but could not save the selection: {}",
1366
model_name, error
src/main.rs
+79
-4
@@ -56,7 +56,7 @@ use agent_client_protocol::{
56
Agent, AgentCapabilities, AgentSideConnection, AuthMethod, AuthMethodAgent,
57
AuthenticateRequest, AuthenticateResponse, CancelNotification, Client, ContentBlock,
58
ContentChunk, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest,
59
- InitializeResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest,
59
+ InitializeResponse, LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest,
60
NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities,
61
SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate, StopReason,
62
};
@@ -161,6 +161,54 @@ fn agent_tools_as_onde() -> Vec<ToolDefinition> {
161
.collect()
162
}
163
164
+fn initialize_meta() -> Meta {
165
+ let startup_selection = setup::startup_model_selection();
166
+
167
+ let active_model_name = startup_selection
168
+ .as_ref()
169
+ .map(|selection| selection.display_name.clone())
170
+ .unwrap_or_else(|| GgufModelConfig::qwen3_4b().display_name);
171
+
172
+ let active_model_id = startup_selection
173
+ .as_ref()
174
+ .and_then(|selection| selection.selected_model.as_ref())
175
+ .map(|selected| selected.model_id.clone())
176
+ .unwrap_or_else(|| GgufModelConfig::qwen3_4b().model_id);
177
+
178
+ let active_model_file = startup_selection
179
+ .as_ref()
180
+ .and_then(|selection| selection.selected_model.as_ref())
181
+ .map(|selected| selected.gguf_file.clone())
182
+ .unwrap_or_else(|| {
183
+ GgufModelConfig::qwen3_4b()
184
+ .files
185
+ .first()
186
+ .cloned()
187
+ .unwrap_or_default()
188
+ });
189
+
190
+ let mut model = serde_json::Map::new();
191
+ model.insert(
192
+ "display_name".to_string(),
193
+ serde_json::Value::String(active_model_name),
194
+ );
195
+ model.insert(
196
+ "model_id".to_string(),
197
+ serde_json::Value::String(active_model_id),
198
+ );
199
+ model.insert(
200
+ "gguf_file".to_string(),
201
+ serde_json::Value::String(active_model_file),
202
+ );
203
+
204
+ let mut sigit = serde_json::Map::new();
205
+ sigit.insert("active_model".to_string(), serde_json::Value::Object(model));
206
+
207
+ let mut meta = Meta::new();
208
+ meta.insert("sigit".to_string(), serde_json::Value::Object(sigit));
209
+ meta
210
+}
211
+
212
// Agent
213
214
struct SiGitAgent {
@@ -199,7 +247,8 @@ impl Agent for SiGitAgent {
247
.session_capabilities(
248
SessionCapabilities::new().fork(SessionForkCapabilities::new()),
249
),
202
- ))
250
+ )
251
+ .meta(initialize_meta()))
252
}
253
254
async fn authenticate(
@@ -458,7 +507,20 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
507
.and_then(|selection| {
508
chat::build_model_picker_items()
509
.into_iter()
461
- .find(|item| item.display_name == selection.display_name)
510
+ .find(|item| {
511
+ selection
512
+ .selected_model
513
+ .as_ref()
514
+ .map(|selected| {
515
+ item.config.model_id == selected.model_id
516
+ && item
517
+ .config
518
+ .files
519
+ .iter()
520
+ .any(|file| file == &selected.gguf_file)
521
+ })
522
+ .unwrap_or(false)
523
+ })
524
.map(|item| item.config)
525
})
526
.unwrap_or_else(GgufModelConfig::platform_default);
@@ -522,7 +584,20 @@ async fn run_acp_server() -> anyhow::Result<()> {
584
.and_then(|selection| {
585
chat::build_model_picker_items()
586
.into_iter()
525
- .find(|item| item.display_name == selection.display_name)
587
+ .find(|item| {
588
+ selection
589
+ .selected_model
590
+ .as_ref()
591
+ .map(|selected| {
592
+ item.config.model_id == selected.model_id
593
+ && item
594
+ .config
595
+ .files
596
+ .iter()
597
+ .any(|file| file == &selected.gguf_file)
598
+ })
599
+ .unwrap_or(false)
600
+ })
601
.map(|item| item.config)
602
})
603
.unwrap_or_else(GgufModelConfig::qwen3_4b);
src/setup.rs
+289
-32
@@ -69,13 +69,35 @@ pub fn setup_shared_model_cache() {
69
/// Preference key used to remember the last selected model.
70
const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt";
71
72
+/// Stable persisted identifier for a selected local model.
73
+#[derive(Debug, Clone, PartialEq, Eq)]
74
+pub struct SelectedModel {
75
+ /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`.
76
+ pub model_id: String,
77
+ /// GGUF filename inside the snapshot.
78
+ pub gguf_file: String,
79
+}
80
+
81
+impl SelectedModel {
82
+ fn from_discovered(model: &DiscoveredModel) -> Self {
83
+ Self {
84
+ model_id: model.model_id.clone(),
85
+ gguf_file: model.gguf_file.clone(),
86
+ }
87
+ }
88
+
89
+ fn matches(&self, model: &DiscoveredModel) -> bool {
90
+ self.model_id == model.model_id && self.gguf_file == model.gguf_file
91
+ }
92
+}
93
+
94
/// Minimal startup model selection info used before the full UI is running.
95
#[derive(Debug, Clone, PartialEq, Eq)]
96
pub struct StartupModelSelection {
97
/// Human-friendly model name shown in the loading UI.
98
pub display_name: String,
77
- /// The saved model name if one was found.
78
- pub selected_name: Option<String>,
99
+ /// The saved model identifier if one was found.
100
+ pub selected_model: Option<SelectedModel>,
101
}
102
103
/// A locally discovered GGUF model candidate.
@@ -93,6 +115,14 @@ pub struct DiscoveredModel {
115
pub gguf_path: PathBuf,
116
/// True when the model came from the Onde app group cache.
117
pub from_app_group: bool,
118
+ /// Whether the snapshot looks complete enough to load.
119
+ pub cache_health: ModelCacheHealth,
120
+}
121
+
122
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
123
+pub enum ModelCacheHealth {
124
+ Complete,
125
+ Incomplete,
126
}
127
128
/// Return all locally discovered GGUF models.
@@ -112,9 +142,13 @@ pub fn discover_local_models() -> Vec<DiscoveredModel> {
142
}
143
144
models.sort_by(|left, right| {
115
- left.display_name
116
- .to_lowercase()
117
- .cmp(&right.display_name.to_lowercase())
145
+ left.cache_health
146
+ .cmp(&right.cache_health)
147
+ .then_with(|| {
148
+ left.display_name
149
+ .to_lowercase()
150
+ .cmp(&right.display_name.to_lowercase())
151
+ })
152
.then_with(|| left.model_id.cmp(&right.model_id))
153
.then_with(|| left.gguf_file.cmp(&right.gguf_file))
154
});
@@ -172,26 +206,49 @@ fn collect_models_from_cache_root(
206
Err(_) => continue,
207
};
208
209
+ let mut has_config_json = false;
210
+ let mut has_tokenizer = false;
211
+ let mut gguf_files = Vec::new();
212
+
213
for file in files.flatten() {
214
let file_path = file.path();
215
if !file_path.is_file() {
216
continue;
217
}
218
219
+ let file_name = match file.file_name().to_str() {
220
+ Some(name) => name.to_string(),
221
+ None => continue,
222
+ };
223
+
224
+ if file_name == "config.json" {
225
+ has_config_json = true;
226
+ }
227
+
228
+ if file_name == "tokenizer.json"
229
+ || file_name == "tokenizer.model"
230
+ || file_name == "tokenizer_config.json"
231
+ {
232
+ has_tokenizer = true;
233
+ }
234
+
235
let extension = file_path
236
.extension()
237
.and_then(|ext| ext.to_str())
238
.unwrap_or_default();
239
186
- if !extension.eq_ignore_ascii_case("gguf") {
187
- continue;
240
+ if extension.eq_ignore_ascii_case("gguf") {
241
+ gguf_files.push((file_name, file_path));
242
}
243
+ }
244
190
- let gguf_file = match file.file_name().to_str() {
191
- Some(name) => name.to_string(),
192
- None => continue,
193
- };
245
+ let cache_health = if has_config_json && has_tokenizer {
246
+ ModelCacheHealth::Complete
247
+ } else {
248
+ ModelCacheHealth::Incomplete
249
+ };
250
251
+ for (gguf_file, file_path) in gguf_files {
252
models.push(DiscoveredModel {
253
display_name: display_name_for_model(&model_id, &gguf_file),
254
model_id: model_id.clone(),
@@ -199,6 +256,7 @@ fn collect_models_from_cache_root(
256
snapshot_path: snapshot_path.clone(),
257
gguf_path: file_path,
258
from_app_group,
259
+ cache_health,
260
});
261
}
262
}
@@ -249,11 +307,34 @@ fn hf_cache_root() -> Option<PathBuf> {
307
path.is_dir().then_some(path)
308
}
309
252
-pub fn load_selected_model_name() -> Option<String> {
310
+pub fn load_selected_model() -> Option<SelectedModel> {
311
let path = selected_model_file_path()?;
312
let contents = std::fs::read_to_string(path).ok()?;
313
let trimmed = contents.trim();
256
- (!trimmed.is_empty()).then(|| trimmed.to_string())
314
+ if trimmed.is_empty() {
315
+ return None;
316
+ }
317
+
318
+ let mut parts = trimmed.splitn(2, '\n');
319
+ let model_id = parts.next()?.trim();
320
+ let gguf_file = parts.next()?.trim();
321
+
322
+ if model_id.is_empty() || gguf_file.is_empty() {
323
+ return None;
324
+ }
325
+
326
+ Some(SelectedModel {
327
+ model_id: model_id.to_string(),
328
+ gguf_file: gguf_file.to_string(),
329
+ })
330
+}
331
+
332
+pub fn load_selected_model_name() -> Option<String> {
333
+ let selected = load_selected_model()?;
334
+ discover_local_models()
335
+ .into_iter()
336
+ .find(|model| selected.matches(model))
337
+ .map(|model| model.display_name)
338
}
339
340
/// Pick the model name siGit should try to load at startup.
@@ -269,28 +350,31 @@ pub fn load_selected_model_name() -> Option<String> {
350
pub fn startup_model_selection() -> Option<StartupModelSelection> {
351
let discovered = discover_local_models();
352
272
- if let Some(saved_name) = load_selected_model_name() {
273
- if discovered
274
- .iter()
275
- .any(|model| model.display_name == saved_name)
276
- {
277
- return Some(StartupModelSelection {
278
- display_name: saved_name.clone(),
279
- selected_name: Some(saved_name),
280
- });
281
- }
353
+ if let Some(saved_model) = load_selected_model()
354
+ && let Some(model) = discovered.iter().find(|model| {
355
+ saved_model.matches(model) && model.cache_health == ModelCacheHealth::Complete
356
+ })
357
+ {
358
+ return Some(StartupModelSelection {
359
+ display_name: model.display_name.clone(),
360
+ selected_model: Some(saved_model),
361
+ });
362
}
363
284
- discovered.into_iter().next().map(|model| {
285
- let _ = save_selected_model_name(&model.display_name);
286
- StartupModelSelection {
287
- display_name: model.display_name.clone(),
288
- selected_name: Some(model.display_name),
289
- }
290
- })
364
+ discovered
365
+ .into_iter()
366
+ .find(|model| model.cache_health == ModelCacheHealth::Complete)
367
+ .map(|model| {
368
+ let selected_model = SelectedModel::from_discovered(&model);
369
+ let _ = save_selected_model(&selected_model);
370
+ StartupModelSelection {
371
+ display_name: model.display_name.clone(),
372
+ selected_model: Some(selected_model),
373
+ }
374
+ })
375
}
376
293
-pub fn save_selected_model_name(model_name: &str) -> Result<(), String> {
377
+pub fn save_selected_model(selected_model: &SelectedModel) -> Result<(), String> {
378
let path = selected_model_file_path()
379
.ok_or_else(|| "Could not determine where to store the selected model.".to_string())?;
380
@@ -301,7 +385,12 @@ pub fn save_selected_model_name(model_name: &str) -> Result<(), String> {
385
.map_err(|error| format!("Could not create preferences directory: {error}"))?;
386
}
387
304
- std::fs::write(&path, model_name)
388
+ let contents = format!(
389
+ "{}\n{}\n",
390
+ selected_model.model_id, selected_model.gguf_file
391
+ );
392
+
393
+ std::fs::write(&path, contents)
394
.map_err(|error| format!("Could not save selected model: {error}"))
395
}
396
@@ -355,3 +444,171 @@ fn resolve_shared_container() -> Option<PathBuf> {
444
fn resolve_shared_container() -> Option<PathBuf> {
445
None
446
}
447
+
448
+#[cfg(test)]
449
+mod tests {
450
+ use super::*;
451
+ use std::sync::{Mutex, OnceLock};
452
+
453
+ fn env_lock() -> &'static Mutex<()> {
454
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
455
+ LOCK.get_or_init(|| Mutex::new(()))
456
+ }
457
+
458
+ fn unique_temp_dir(name: &str) -> PathBuf {
459
+ let nanos = std::time::SystemTime::now()
460
+ .duration_since(std::time::UNIX_EPOCH)
461
+ .expect("system time before unix epoch")
462
+ .as_nanos();
463
+
464
+ std::env::temp_dir().join(format!("sigit-setup-tests-{name}-{nanos}"))
465
+ }
466
+
467
+ fn create_snapshot(
468
+ cache_root: &Path,
469
+ model_id: &str,
470
+ snapshot_name: &str,
471
+ gguf_file: &str,
472
+ complete: bool,
473
+ ) -> PathBuf {
474
+ let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--")));
475
+ let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name);
476
+ std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir");
477
+ std::fs::write(snapshot_dir.join(gguf_file), b"gguf").expect("write gguf");
478
+
479
+ if complete {
480
+ std::fs::write(snapshot_dir.join("config.json"), b"{}").expect("write config");
481
+ std::fs::write(snapshot_dir.join("tokenizer.json"), b"{}").expect("write tokenizer");
482
+ }
483
+
484
+ snapshot_dir
485
+ }
486
+
487
+ fn with_test_env<T>(hf_hub_cache: &Path, hf_home: &Path, f: impl FnOnce() -> T) -> T {
488
+ let _guard = env_lock().lock().expect("lock env");
489
+
490
+ let old_hf_hub_cache = std::env::var_os("HF_HUB_CACHE");
491
+ let old_hf_home = std::env::var_os("HF_HOME");
492
+ let old_home = std::env::var_os("HOME");
493
+
494
+ // SAFETY: tests serialize environment mutation with a process-wide mutex.
495
+ unsafe {
496
+ std::env::set_var("HF_HUB_CACHE", hf_hub_cache);
497
+ std::env::set_var("HF_HOME", hf_home);
498
+ std::env::set_var("HOME", hf_home);
499
+ }
500
+
501
+ let result = f();
502
+
503
+ // SAFETY: tests serialize environment mutation with a process-wide mutex.
504
+ unsafe {
505
+ match old_hf_hub_cache {
506
+ Some(value) => std::env::set_var("HF_HUB_CACHE", value),
507
+ None => std::env::remove_var("HF_HUB_CACHE"),
508
+ }
509
+ match old_hf_home {
510
+ Some(value) => std::env::set_var("HF_HOME", value),
511
+ None => std::env::remove_var("HF_HOME"),
512
+ }
513
+ match old_home {
514
+ Some(value) => std::env::set_var("HOME", value),
515
+ None => std::env::remove_var("HOME"),
516
+ }
517
+ }
518
+
519
+ result
520
+ }
521
+
522
+ #[test]
523
+ fn discover_local_models_marks_complete_and_incomplete_snapshots() {
524
+ let root = unique_temp_dir("discover-health");
525
+ let cache_root = root.join("hf-cache");
526
+ let hf_home = root.join("hf-home");
527
+ std::fs::create_dir_all(&cache_root).expect("create cache root");
528
+ std::fs::create_dir_all(&hf_home).expect("create hf home");
529
+
530
+ create_snapshot(
531
+ &cache_root,
532
+ "bartowski/Qwen_Qwen3-4B-GGUF",
533
+ "complete",
534
+ "Qwen_Qwen3-4B-Q4_K_M.gguf",
535
+ true,
536
+ );
537
+ create_snapshot(
538
+ &cache_root,
539
+ "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
540
+ "incomplete",
541
+ "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
542
+ false,
543
+ );
544
+
545
+ let models = with_test_env(&cache_root, &hf_home, discover_local_models);
546
+
547
+ assert_eq!(models.len(), 2);
548
+
549
+ let complete = models
550
+ .iter()
551
+ .find(|model| model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF")
552
+ .expect("complete model discovered");
553
+ assert_eq!(complete.cache_health, ModelCacheHealth::Complete);
554
+ assert!(!complete.from_app_group);
555
+
556
+ let incomplete = models
557
+ .iter()
558
+ .find(|model| model.model_id == "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF")
559
+ .expect("incomplete model discovered");
560
+ assert_eq!(incomplete.cache_health, ModelCacheHealth::Incomplete);
561
+ assert!(!incomplete.from_app_group);
562
+
563
+ std::fs::remove_dir_all(root).expect("remove temp dir");
564
+ }
565
+
566
+ #[test]
567
+ fn startup_model_selection_skips_saved_incomplete_model_and_picks_complete_one() {
568
+ let root = unique_temp_dir("startup-selection");
569
+ let cache_root = root.join("hf-cache");
570
+ let hf_home = root.join("hf-home");
571
+ std::fs::create_dir_all(&cache_root).expect("create cache root");
572
+ std::fs::create_dir_all(&hf_home).expect("create hf home");
573
+
574
+ create_snapshot(
575
+ &cache_root,
576
+ "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
577
+ "broken",
578
+ "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
579
+ false,
580
+ );
581
+ create_snapshot(
582
+ &cache_root,
583
+ "bartowski/Qwen_Qwen3-4B-GGUF",
584
+ "ready",
585
+ "Qwen_Qwen3-4B-Q4_K_M.gguf",
586
+ true,
587
+ );
588
+
589
+ let selection = with_test_env(&cache_root, &hf_home, || {
590
+ let selected_path = selected_model_file_path().expect("selected model path");
591
+ if let Some(parent) = selected_path.parent() {
592
+ std::fs::create_dir_all(parent).expect("create selected model parent");
593
+ }
594
+
595
+ std::fs::write(
596
+ &selected_path,
597
+ "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n",
598
+ )
599
+ .expect("write selected model");
600
+
601
+ startup_model_selection().expect("startup selection")
602
+ });
603
+
604
+ assert_eq!(
605
+ selection.display_name,
606
+ "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M"
607
+ );
608
+ let selected = selection.selected_model.expect("selected model");
609
+ assert_eq!(selected.model_id, "bartowski/Qwen_Qwen3-4B-GGUF");
610
+ assert_eq!(selected.gguf_file, "Qwen_Qwen3-4B-Q4_K_M.gguf");
611
+
612
+ std::fs::remove_dir_all(root).expect("remove temp dir");
613
+ }
614
+}
src/tools.rs
-6
@@ -514,8 +514,6 @@ fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
514
}
515
}
516
517
-/// ── read_website ─────────────────────────────────────────────────────────────
518
-
517
fn exec_read_website(arguments: &str) -> String {
518
let args: Value = match serde_json::from_str(arguments) {
519
Ok(v) => v,
@@ -630,8 +628,6 @@ fn exec_read_website(arguments: &str) -> String {
628
output
629
}
630
633
-/// ── create_directory ─────────────────────────────────────────────────────────
634
-
631
/// Create a directory and any missing parent directories.
632
fn exec_create_directory(arguments: &str) -> String {
633
let args: Value = match serde_json::from_str(arguments) {
@@ -661,8 +657,6 @@ fn exec_create_directory(arguments: &str) -> String {
657
}
658
}
659
664
-/// ── create_file ──────────────────────────────────────────────────────────────
665
-
660
/// Create a new file with the provided content.
661
///
662
/// Parent directories are created automatically. Fails if the file already