From 318950187e4e1f57b4bce409563d22a79b67c58f Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Fri, 8 May 2026 00:55:14 +0530 Subject: [PATCH] fix(local-ai): fix false-negative Ollama diagnostics (binary + model detection) (#1327) --- .../local-model/ModelStatusSection.test.tsx | 207 ++++++++++++++++++ .../panels/local-model/ModelStatusSection.tsx | 50 ++++- app/src/utils/tauriCommands/localAi.ts | 7 + src/openhuman/local_ai/install.rs | 69 +++++- src/openhuman/local_ai/ollama_api.rs | 111 +++++++++- .../local_ai/service/ollama_admin.rs | 83 ++++++- .../local_ai/service/ollama_admin_tests.rs | 152 ++++++++++++- 7 files changed, 659 insertions(+), 20 deletions(-) create mode 100644 app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx diff --git a/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx b/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx new file mode 100644 index 000000000..48e40ebbb --- /dev/null +++ b/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx @@ -0,0 +1,207 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { LocalAiDiagnostics, RepairAction } from '../../../../utils/tauriCommands'; +import ModelStatusSection from './ModelStatusSection'; + +const defaultProps = { + status: null, + downloads: null, + diagnostics: null, + isDiagnosticsLoading: false, + diagnosticsError: '', + statusError: '', + isTriggeringDownload: false, + bootstrapMessage: '', + progress: 0, + isIndeterminateDownload: false, + isInstalling: false, + isInstallError: false, + showErrorDetail: false, + ollamaPathInput: '', + isSettingPath: false, + downloadedText: '', + speedText: '', + etaText: '', + statusTone: (_state: string) => '', + onRefreshStatus: vi.fn(), + onTriggerDownload: vi.fn(), + onSetOllamaPath: vi.fn(), + onClearOllamaPath: vi.fn(), + onSetOllamaPathInput: vi.fn(), + onToggleErrorDetail: vi.fn(), + onRunDiagnostics: vi.fn(), + onRepairAction: vi.fn(), +}; + +const makeDiagnostics = (overrides: Partial = {}): LocalAiDiagnostics => ({ + ollama_running: true, + ollama_base_url: 'http://localhost:11434', + ollama_binary_path: '/usr/local/bin/ollama', + installed_models: [], + expected: { + chat_model: 'gemma3:1b-it-qat', + chat_found: true, + embedding_model: 'nomic-embed-text', + embedding_found: true, + vision_model: 'llava', + vision_found: false, + }, + issues: [], + repair_actions: [], + ok: true, + ...overrides, +}); + +describe('ModelStatusSection diagnostics', () => { + it('shows the base URL being checked', () => { + render( + + ); + expect(screen.getByTitle('http://192.168.1.5:11434')).toBeTruthy(); + }); + + it('shows Running when server is up', () => { + render( + + ); + expect(screen.getByText('Running')).toBeTruthy(); + }); + + it('shows Not running when server is down', () => { + render( + + ); + expect(screen.getByText('Not running')).toBeTruthy(); + }); + + it('shows Running via external process when binary is null but server is running', () => { + render( + + ); + expect(screen.getByText('Running via external process')).toBeTruthy(); + }); + + it('shows Not found when binary is null and server is not running', () => { + render( + + ); + expect(screen.getByText('Not found')).toBeTruthy(); + }); + + it('shows the binary path when set', () => { + render( + + ); + expect(screen.getByText('/opt/homebrew/bin/ollama')).toBeTruthy(); + }); + + it('renders repair action buttons', () => { + const repairActions: RepairAction[] = [ + { action: 'install_ollama' }, + { action: 'start_server', binary_path: '/usr/local/bin/ollama' }, + { action: 'pull_model', model: 'gemma3:1b-it-qat' }, + ]; + render( + + ); + expect(screen.getByText('Install Ollama')).toBeTruthy(); + expect(screen.getByText('Start Server')).toBeTruthy(); + expect(screen.getByText('Pull gemma3:1b-it-qat')).toBeTruthy(); + }); + + it('calls onRepairAction with the correct action when button is clicked', () => { + const onRepairAction = vi.fn(); + const repairActions: RepairAction[] = [{ action: 'install_ollama' }]; + render( + + ); + fireEvent.click(screen.getByText('Install Ollama')); + expect(onRepairAction).toHaveBeenCalledWith({ action: 'install_ollama' }); + }); + + it('calls onRepairAction with pull_model action', () => { + const onRepairAction = vi.fn(); + const repairActions: RepairAction[] = [{ action: 'pull_model', model: 'gemma3:1b-it-qat' }]; + render( + + ); + fireEvent.click(screen.getByText('Pull gemma3:1b-it-qat')); + expect(onRepairAction).toHaveBeenCalledWith({ + action: 'pull_model', + model: 'gemma3:1b-it-qat', + }); + }); + + it('does not render repair actions section when repair_actions is empty', () => { + render( + + ); + expect(screen.queryByText('Suggested Fixes')).toBeNull(); + }); + + it('shows all checks passed when ok is true', () => { + render(); + expect(screen.getByText('All checks passed')).toBeTruthy(); + }); + + it('shows issue count when ok is false', () => { + render( + + ); + expect(screen.getByText('2 issue(s) found')).toBeTruthy(); + }); + + it('renders prompt text when diagnostics is null', () => { + render(); + expect(screen.getByText(/Click.*Run Diagnostics/)).toBeTruthy(); + }); +}); diff --git a/app/src/components/settings/panels/local-model/ModelStatusSection.tsx b/app/src/components/settings/panels/local-model/ModelStatusSection.tsx index 1481c1c75..38d0018ba 100644 --- a/app/src/components/settings/panels/local-model/ModelStatusSection.tsx +++ b/app/src/components/settings/panels/local-model/ModelStatusSection.tsx @@ -3,6 +3,7 @@ import type { LocalAiDiagnostics, LocalAiDownloadsProgress, LocalAiStatus, + RepairAction, } from '../../../../utils/tauriCommands'; interface ModelStatusSectionProps { @@ -32,8 +33,20 @@ interface ModelStatusSectionProps { onSetOllamaPathInput: (value: string) => void; onToggleErrorDetail: () => void; onRunDiagnostics: () => void; + onRepairAction?: (action: RepairAction) => void; } +const repairActionLabel = (action: RepairAction): string => { + switch (action.action) { + case 'install_ollama': + return 'Install Ollama'; + case 'start_server': + return 'Start Server'; + case 'pull_model': + return `Pull ${action.model}`; + } +}; + const ModelStatusSection = ({ status, downloads, @@ -61,6 +74,7 @@ const ModelStatusSection = ({ onSetOllamaPathInput, onToggleErrorDetail, onRunDiagnostics, + onRepairAction, }: ModelStatusSectionProps) => { return ( <> @@ -288,13 +302,27 @@ const ModelStatusSection = ({ className={`mt-1 font-medium ${diagnostics.ollama_running ? 'text-green-600' : 'text-red-600'}`}> {diagnostics.ollama_running ? 'Running' : 'Not running'} + {diagnostics.ollama_base_url && ( +
+ {diagnostics.ollama_base_url} +
+ )}
Binary
- {diagnostics.ollama_binary_path ?? 'Not found'} + title={ + diagnostics.ollama_binary_path ?? + (diagnostics.ollama_running ? 'External process' : 'Not found') + }> + {diagnostics.ollama_binary_path === null + ? diagnostics.ollama_running + ? 'Running via external process' + : 'Not found' + : diagnostics.ollama_binary_path}
@@ -373,6 +401,24 @@ const ModelStatusSection = ({ )} + + {diagnostics.repair_actions && diagnostics.repair_actions.length > 0 && ( +
+
+ Suggested Fixes +
+
+ {diagnostics.repair_actions.map((action, i) => ( + + ))} +
+
+ )} )} diff --git a/app/src/utils/tauriCommands/localAi.ts b/app/src/utils/tauriCommands/localAi.ts index c8427980d..65cd151fe 100644 --- a/app/src/utils/tauriCommands/localAi.ts +++ b/app/src/utils/tauriCommands/localAi.ts @@ -195,8 +195,14 @@ export interface ApplyPresetResult { local_ai_enabled?: boolean; } +export type RepairAction = + | { action: 'install_ollama' } + | { action: 'start_server'; binary_path: string | null } + | { action: 'pull_model'; model: string }; + export interface LocalAiDiagnostics { ollama_running: boolean; + ollama_base_url: string; ollama_binary_path: string | null; vision_mode?: string; installed_models: Array<{ name: string; size?: number | null; modified_at?: string | null }>; @@ -209,6 +215,7 @@ export interface LocalAiDiagnostics { vision_found: boolean; }; issues: string[]; + repair_actions: RepairAction[]; ok: boolean; } diff --git a/src/openhuman/local_ai/install.rs b/src/openhuman/local_ai/install.rs index 778c13a0a..53fad43fe 100644 --- a/src/openhuman/local_ai/install.rs +++ b/src/openhuman/local_ai/install.rs @@ -191,12 +191,27 @@ pub(crate) fn find_system_ollama_binary() -> Option { } if cfg!(target_os = "macos") { - let common = [ + let mut candidates = vec![ PathBuf::from("/usr/local/bin/ollama"), PathBuf::from("/opt/homebrew/bin/ollama"), ]; - for candidate in common { + // Ollama.app installed in /Applications or ~/Applications ships its + // CLI binary inside the app bundle resources directory. + let bundle_rel = std::path::Path::new("Applications") + .join("Ollama.app") + .join("Contents") + .join("Resources") + .join("ollama"); + candidates.push(PathBuf::from("/").join(&bundle_rel)); + if let Some(home) = std::env::var_os("HOME") { + candidates.push(PathBuf::from(home).join(&bundle_rel)); + } + for candidate in candidates { if candidate.is_file() { + log::debug!( + "[local_ai] found system Ollama at macOS path: {}", + candidate.display() + ); return Some(candidate); } } @@ -325,7 +340,6 @@ mod tests { #[test] fn find_system_ollama_binary_finds_binary_via_path() { let _lock = env_lock(); - // Build a fake binary and inject its directory as the first PATH entry. let tmp = tempfile::tempdir().unwrap(); let binary_name = if cfg!(windows) { "ollama.exe" @@ -351,4 +365,53 @@ mod tests { "PATH-based lookup should succeed with a valid stub" ); } + + #[cfg(target_os = "macos")] + #[test] + fn find_system_ollama_binary_detects_macos_app_bundle_in_applications() { + let _lock = env_lock(); + let tmp = tempfile::tempdir().unwrap(); + // Build a fake /Applications/Ollama.app/Contents/Resources/ollama tree. + let bundle_bin = tmp + .path() + .join("Applications") + .join("Ollama.app") + .join("Contents") + .join("Resources") + .join("ollama"); + std::fs::create_dir_all(bundle_bin.parent().unwrap()).unwrap(); + std::fs::write(&bundle_bin, b"stub").unwrap(); + + // Clear OLLAMA_BIN, clear PATH so the normal PATH lookup won't find it, + // and point HOME to tmp so the ~/Applications branch is exercised via a + // separate sub-test below. Here we exercise /Applications by building + // the file at root and verifying the function returns it when the static + // /Applications path exists — we skip direct-path injection since the + // function hard-codes "/" as root and we cannot mock the filesystem. + // Instead verify the ~/Applications path via the HOME trick. + let _home_guard = EnvGuard::set("HOME", tmp.path()); + let _bin_guard = EnvGuard::unset("OLLAMA_BIN"); + let prev_path = std::env::var_os("PATH").unwrap_or_default(); + let _path_guard = EnvGuard::set("PATH", ""); + + // ~/Applications bundle path is under HOME. + let home_bundle = tmp + .path() + .join("Applications") + .join("Ollama.app") + .join("Contents") + .join("Resources") + .join("ollama"); + std::fs::create_dir_all(home_bundle.parent().unwrap()).unwrap(); + std::fs::write(&home_bundle, b"stub").unwrap(); + + let found = find_system_ollama_binary(); + assert_eq!( + found.as_deref(), + Some(home_bundle.as_path()), + "should find Ollama in ~/Applications bundle" + ); + drop(_path_guard); + let _ = prev_path; + } } diff --git a/src/openhuman/local_ai/ollama_api.rs b/src/openhuman/local_ai/ollama_api.rs index 062c2d408..d44fd4a14 100644 --- a/src/openhuman/local_ai/ollama_api.rs +++ b/src/openhuman/local_ai/ollama_api.rs @@ -4,16 +4,35 @@ use serde::{Deserialize, Serialize}; pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434"; -/// Returns the effective Ollama base URL, honouring the -/// `OPENHUMAN_OLLAMA_BASE_URL` env override so tests can point it at -/// a local mock server. In production builds this is always -/// [`DEFAULT_OLLAMA_BASE_URL`] unless an operator has deliberately -/// pointed the sidecar at a remote Ollama. +/// Returns the effective Ollama base URL. +/// +/// Priority (highest to lowest): +/// 1. `OPENHUMAN_OLLAMA_BASE_URL` — app-specific override, used in tests. +/// 2. `OLLAMA_HOST` — Ollama's own env var; normalized to a full URL by +/// prepending `http://` when no scheme is present. +/// 3. [`DEFAULT_OLLAMA_BASE_URL`] — `http://localhost:11434`. pub(crate) fn ollama_base_url() -> String { - match std::env::var("OPENHUMAN_OLLAMA_BASE_URL") { - Ok(url) if !url.trim().is_empty() => url.trim().trim_end_matches('/').to_string(), - _ => DEFAULT_OLLAMA_BASE_URL.to_string(), + if let Ok(url) = std::env::var("OPENHUMAN_OLLAMA_BASE_URL") { + let trimmed = url.trim(); + if !trimmed.is_empty() { + return trimmed.trim_end_matches('/').to_string(); + } } + + if let Ok(host) = std::env::var("OLLAMA_HOST") { + let trimmed = host.trim().trim_end_matches('/'); + if !trimmed.is_empty() { + let url = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + log::debug!("[local_ai] ollama_base_url: using OLLAMA_HOST -> {url}"); + return url; + } + } + + DEFAULT_OLLAMA_BASE_URL.to_string() } /// Back-compat constant kept at its original value for callers that @@ -271,8 +290,10 @@ mod tests { // calls from other tests in the same binary. const ENV_VAR: &str = "OPENHUMAN_OLLAMA_BASE_URL"; + const OLLAMA_HOST_VAR: &str = "OLLAMA_HOST"; struct OllamaEnvGuard { + var: &'static str, prior: Option, } @@ -280,13 +301,31 @@ mod tests { fn clear() -> Self { let prior = std::env::var(ENV_VAR).ok(); unsafe { std::env::remove_var(ENV_VAR) }; - Self { prior } + Self { + var: ENV_VAR, + prior, + } } fn set(value: &str) -> Self { let prior = std::env::var(ENV_VAR).ok(); unsafe { std::env::set_var(ENV_VAR, value) }; - Self { prior } + Self { + var: ENV_VAR, + prior, + } + } + + fn clear_var(var: &'static str) -> Self { + let prior = std::env::var(var).ok(); + unsafe { std::env::remove_var(var) }; + Self { var, prior } + } + + fn set_var(var: &'static str, value: &str) -> Self { + let prior = std::env::var(var).ok(); + unsafe { std::env::set_var(var, value) }; + Self { var, prior } } } @@ -294,8 +333,8 @@ mod tests { fn drop(&mut self) { unsafe { match self.prior.take() { - Some(v) => std::env::set_var(ENV_VAR, v), - None => std::env::remove_var(ENV_VAR), + Some(v) => std::env::set_var(self.var, v), + None => std::env::remove_var(self.var), } } } @@ -347,4 +386,52 @@ mod tests { assert_eq!(ollama_base_url(), DEFAULT_OLLAMA_BASE_URL); } } + + #[test] + fn ollama_base_url_uses_ollama_host_when_openhuman_var_unset() { + let _lock = test_lock(); + let _g1 = OllamaEnvGuard::clear(); + let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "192.168.1.5:11434"); + assert_eq!(ollama_base_url(), "http://192.168.1.5:11434"); + } + + #[test] + fn ollama_base_url_prepends_http_for_host_without_scheme() { + let _lock = test_lock(); + let _g1 = OllamaEnvGuard::clear(); + let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "myhost:11434"); + assert_eq!(ollama_base_url(), "http://myhost:11434"); + } + + #[test] + fn ollama_base_url_preserves_existing_scheme_in_ollama_host() { + let _lock = test_lock(); + let _g1 = OllamaEnvGuard::clear(); + let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "https://remote-ollama.example.com"); + assert_eq!(ollama_base_url(), "https://remote-ollama.example.com"); + } + + #[test] + fn ollama_base_url_openhuman_var_takes_priority_over_ollama_host() { + let _lock = test_lock(); + let _g1 = OllamaEnvGuard::set("http://127.0.0.1:55555"); + let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "192.168.1.5:11434"); + assert_eq!(ollama_base_url(), "http://127.0.0.1:55555"); + } + + #[test] + fn ollama_base_url_ignores_empty_ollama_host() { + let _lock = test_lock(); + let _g1 = OllamaEnvGuard::clear(); + let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, " "); + assert_eq!(ollama_base_url(), DEFAULT_OLLAMA_BASE_URL); + } + + #[test] + fn ollama_base_url_strips_trailing_slash_from_ollama_host() { + let _lock = test_lock(); + let _g1 = OllamaEnvGuard::clear(); + let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "myhost:11434/"); + assert_eq!(ollama_base_url(), "http://myhost:11434"); + } } diff --git a/src/openhuman/local_ai/service/ollama_admin.rs b/src/openhuman/local_ai/service/ollama_admin.rs index 5af7237dc..6767b2e3c 100644 --- a/src/openhuman/local_ai/service/ollama_admin.rs +++ b/src/openhuman/local_ai/service/ollama_admin.rs @@ -579,8 +579,15 @@ impl LocalAiService { /// Run full diagnostics: check Ollama server health, list installed models, /// and verify expected models are present. Returns a JSON-serializable report. pub async fn diagnostics(&self, config: &Config) -> Result { + let base_url = ollama_base_url(); let healthy = self.ollama_healthy().await; + log::debug!( + "[local_ai] diagnostics: entry base_url={} healthy={}", + base_url, + healthy + ); + let (models, tags_error) = if healthy { match self.list_models().await { Ok(models) => (models, None), @@ -609,20 +616,38 @@ impl LocalAiService { let binary_path = self.resolve_binary_path(config); let mut issues: Vec = Vec::new(); + let mut repair_actions: Vec = Vec::new(); + if !healthy { issues.push(format!( "Ollama server is not running or not reachable at {}", - ollama_base_url() + base_url )); + if binary_path.is_none() { + repair_actions.push(serde_json::json!({"action": "install_ollama"})); + } else { + repair_actions.push(serde_json::json!({ + "action": "start_server", + "binary_path": binary_path, + })); + } } if healthy && !chat_found { issues.push(format!("Chat model `{}` is not installed", expected_chat)); + repair_actions.push(serde_json::json!({ + "action": "pull_model", + "model": expected_chat, + })); } if healthy && config.local_ai.preload_embedding_model && !embedding_found { issues.push(format!( "Embedding model `{}` is not installed", expected_embedding )); + repair_actions.push(serde_json::json!({ + "action": "pull_model", + "model": expected_embedding, + })); } if healthy && matches!( @@ -635,20 +660,26 @@ impl LocalAiService { "Vision model `{}` is not installed", expected_vision )); + repair_actions.push(serde_json::json!({ + "action": "pull_model", + "model": expected_vision, + })); } if let Some(ref e) = tags_error { issues.push(format!("Failed to list models: {e}")); } log::debug!( - "[local_ai] diagnostics: healthy={} models={} issues={}", + "[local_ai] diagnostics: healthy={} models={} issues={} repair_actions={}", healthy, models.len(), issues.len(), + repair_actions.len(), ); Ok(serde_json::json!({ "ollama_running": healthy, + "ollama_base_url": base_url, "ollama_binary_path": binary_path, "installed_models": models, "vision_mode": presets::vision_mode_for_config(&config.local_ai), @@ -661,6 +692,7 @@ impl LocalAiService { "vision_found": vision_found, }, "issues": issues, + "repair_actions": repair_actions, "ok": issues.is_empty(), })) } @@ -748,16 +780,63 @@ impl LocalAiService { } fn resolve_binary_path(&self, config: &Config) -> Option { + // 1. Explicit user-configured path in Settings. if let Some(ref custom) = config.local_ai.ollama_binary_path { let p = PathBuf::from(custom); if p.is_file() { + log::debug!( + "[local_ai] resolve_binary_path: using configured path {}", + p.display() + ); return Some(custom.clone()); } } + + // 2. OLLAMA_BIN env var (mirrors bootstrap detection). + if let Some(from_env) = std::env::var("OLLAMA_BIN") + .ok() + .filter(|v| !v.trim().is_empty()) + { + let p = PathBuf::from(&from_env); + if p.is_file() { + log::debug!( + "[local_ai] resolve_binary_path: using OLLAMA_BIN {}", + p.display() + ); + return Some(from_env); + } + } + + // 3. Workspace-managed binary installed by the app. let workspace_bin = workspace_ollama_binary(config); if workspace_bin.is_file() { + log::debug!( + "[local_ai] resolve_binary_path: using workspace binary {}", + workspace_bin.display() + ); return Some(workspace_bin.display().to_string()); } + + // 4. Bare `ollama` on PATH — same as bootstrap's `which ollama` step. + let binary_name = if cfg!(windows) { + "ollama.exe" + } else { + "ollama" + }; + if let Some(path_var) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(binary_name); + if candidate.is_file() { + log::debug!( + "[local_ai] resolve_binary_path: found on PATH at {}", + candidate.display() + ); + return Some(candidate.display().to_string()); + } + } + } + + // 5. Platform-specific well-known locations (macOS bundles, Windows, Linux). crate::openhuman::local_ai::install::find_system_ollama_binary() .map(|p| p.display().to_string()) } diff --git a/src/openhuman/local_ai/service/ollama_admin_tests.rs b/src/openhuman/local_ai/service/ollama_admin_tests.rs index 17c35a71a..b55d2f64c 100644 --- a/src/openhuman/local_ai/service/ollama_admin_tests.rs +++ b/src/openhuman/local_ai/service/ollama_admin_tests.rs @@ -133,6 +133,10 @@ async fn diagnostics_reports_server_unreachable_when_url_unbound() { let service = LocalAiService::new(&config); let diag = service.diagnostics(&config).await.expect("diagnostics"); assert_eq!(diag["ollama_running"], false); + assert!( + diag["ollama_base_url"].as_str().is_some(), + "diagnostics must include ollama_base_url" + ); let issues = diag["issues"].as_array().cloned().unwrap_or_default(); assert!( !issues.is_empty(), @@ -141,6 +145,14 @@ async fn diagnostics_reports_server_unreachable_when_url_unbound() { assert!(issues .iter() .any(|v| v.as_str().unwrap_or("").contains("not running"))); + let repair_actions = diag["repair_actions"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + !repair_actions.is_empty(), + "unreachable server must produce at least one repair action" + ); unsafe { std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); } @@ -162,9 +174,25 @@ async fn diagnostics_with_running_server_but_missing_models_flags_issues() { let service = LocalAiService::new(&config); let diag = service.diagnostics(&config).await.expect("diagnostics"); assert_eq!(diag["ollama_running"], true); + assert_eq!( + diag["ollama_base_url"].as_str(), + Some(base.as_str()), + "diagnostics must echo back the base url being checked" + ); // No models are installed → expected chat model issue surfaces. let issues = diag["issues"].as_array().cloned().unwrap_or_default(); assert!(!issues.is_empty()); + // Missing chat model should produce a pull_model repair action. + let repair_actions = diag["repair_actions"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + repair_actions + .iter() + .any(|a| a["action"].as_str() == Some("pull_model")), + "missing models must produce pull_model repair action" + ); unsafe { std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); } @@ -178,15 +206,19 @@ async fn diagnostics_ok_when_expected_models_are_present() { let config = Config::default(); let chat = crate::openhuman::local_ai::model_ids::effective_chat_model_id(&config); + let embedding = crate::openhuman::local_ai::model_ids::effective_embedding_model_id(&config); let chat_tag = format!("{}:latest", chat); + let embed_tag = format!("{}:latest", embedding); let app = Router::new().route( "/api/tags", get(move || { let chat_tag = chat_tag.clone(); + let embed_tag = embed_tag.clone(); async move { Json(json!({ "models": [ - { "name": chat_tag, "modified_at": "", "size": 1u64, "digest": "d" } + { "name": chat_tag, "modified_at": "", "size": 1u64, "digest": "d" }, + { "name": embed_tag, "modified_at": "", "size": 2u64, "digest": "e" }, ] })) } @@ -201,6 +233,124 @@ async fn diagnostics_ok_when_expected_models_are_present() { let diag = service.diagnostics(&config).await.expect("diagnostics"); assert_eq!(diag["ollama_running"], true); assert_eq!(diag["expected"]["chat_found"], true); + assert_eq!(diag["expected"]["embedding_found"], true); + assert!(diag["ollama_base_url"].as_str().is_some()); + // All required models present → no issues and no repair actions. + let issues = diag["issues"].as_array().cloned().unwrap_or_default(); + assert!( + issues.is_empty(), + "all models present should produce no issues, got: {:?}", + issues + ); + let repair_actions = diag["repair_actions"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + repair_actions.is_empty(), + "no issues should produce no repair actions" + ); + unsafe { + std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); + } +} + +#[tokio::test] +async fn resolve_binary_path_finds_binary_via_ollama_bin_env() { + let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX + .lock() + .expect("local ai mutex"); + + let tmp = tempfile::tempdir().unwrap(); + let fake_bin = tmp.path().join(if cfg!(windows) { + "ollama.exe" + } else { + "ollama" + }); + std::fs::write(&fake_bin, b"stub").unwrap(); + + unsafe { + std::env::set_var("OLLAMA_BIN", fake_bin.to_str().unwrap()); + // Point the base URL at a dead port so we don't depend on a real server. + std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", "http://127.0.0.1:1"); + } + + let config = Config::default(); + let service = LocalAiService::new(&config); + let diag = service.diagnostics(&config).await.expect("diagnostics"); + assert_eq!( + diag["ollama_binary_path"].as_str(), + Some(fake_bin.to_str().unwrap()), + "diagnostics should resolve binary via OLLAMA_BIN" + ); + + unsafe { + std::env::remove_var("OLLAMA_BIN"); + std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); + } +} + +#[tokio::test] +async fn diagnostics_repair_actions_include_start_server_when_binary_known() { + let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX + .lock() + .expect("local ai mutex"); + + let tmp = tempfile::tempdir().unwrap(); + let fake_bin = tmp.path().join(if cfg!(windows) { + "ollama.exe" + } else { + "ollama" + }); + std::fs::write(&fake_bin, b"stub").unwrap(); + + unsafe { + std::env::set_var("OLLAMA_BIN", fake_bin.to_str().unwrap()); + std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", "http://127.0.0.1:1"); + } + + let config = Config::default(); + let service = LocalAiService::new(&config); + let diag = service.diagnostics(&config).await.expect("diagnostics"); + + assert_eq!(diag["ollama_running"], false); + let repair_actions = diag["repair_actions"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + repair_actions + .iter() + .any(|a| a["action"].as_str() == Some("start_server")), + "when binary is known but server is down, repair action should be start_server" + ); + + unsafe { + std::env::remove_var("OLLAMA_BIN"); + std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); + } +} + +#[tokio::test] +async fn diagnostics_repair_actions_field_always_present() { + // Verifies that the "repair_actions" key is always present in the diagnostics + // JSON, regardless of the server state, so the UI can always iterate over it. + let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX + .lock() + .expect("local ai mutex"); + + unsafe { + std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", "http://127.0.0.1:1"); + } + let config = Config::default(); + let service = LocalAiService::new(&config); + let diag = service.diagnostics(&config).await.expect("diagnostics"); + + assert!( + diag["repair_actions"].is_array(), + "repair_actions must always be a JSON array" + ); + unsafe { std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); }