fix(local-ai): fix false-negative Ollama diagnostics (binary + model detection) (#1327)

This commit is contained in:
Mega Mind
2026-05-07 12:25:14 -07:00
committed by GitHub
parent d303219d7c
commit 318950187e
7 changed files with 659 additions and 20 deletions
@@ -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> = {}): 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(
<ModelStatusSection
{...defaultProps}
diagnostics={makeDiagnostics({ ollama_base_url: 'http://192.168.1.5:11434' })}
/>
);
expect(screen.getByTitle('http://192.168.1.5:11434')).toBeTruthy();
});
it('shows Running when server is up', () => {
render(
<ModelStatusSection
{...defaultProps}
diagnostics={makeDiagnostics({ ollama_running: true })}
/>
);
expect(screen.getByText('Running')).toBeTruthy();
});
it('shows Not running when server is down', () => {
render(
<ModelStatusSection
{...defaultProps}
diagnostics={makeDiagnostics({ ollama_running: false })}
/>
);
expect(screen.getByText('Not running')).toBeTruthy();
});
it('shows Running via external process when binary is null but server is running', () => {
render(
<ModelStatusSection
{...defaultProps}
diagnostics={makeDiagnostics({ ollama_binary_path: null, ollama_running: true })}
/>
);
expect(screen.getByText('Running via external process')).toBeTruthy();
});
it('shows Not found when binary is null and server is not running', () => {
render(
<ModelStatusSection
{...defaultProps}
diagnostics={makeDiagnostics({ ollama_binary_path: null, ollama_running: false })}
/>
);
expect(screen.getByText('Not found')).toBeTruthy();
});
it('shows the binary path when set', () => {
render(
<ModelStatusSection
{...defaultProps}
diagnostics={makeDiagnostics({ ollama_binary_path: '/opt/homebrew/bin/ollama' })}
/>
);
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(
<ModelStatusSection
{...defaultProps}
diagnostics={makeDiagnostics({
ok: false,
issues: ['Ollama server is not running'],
repair_actions: repairActions,
})}
/>
);
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(
<ModelStatusSection
{...defaultProps}
onRepairAction={onRepairAction}
diagnostics={makeDiagnostics({
ok: false,
issues: ['Ollama server is not running'],
repair_actions: repairActions,
})}
/>
);
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(
<ModelStatusSection
{...defaultProps}
onRepairAction={onRepairAction}
diagnostics={makeDiagnostics({
ok: false,
issues: ['Chat model is not installed'],
repair_actions: repairActions,
})}
/>
);
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(
<ModelStatusSection {...defaultProps} diagnostics={makeDiagnostics({ repair_actions: [] })} />
);
expect(screen.queryByText('Suggested Fixes')).toBeNull();
});
it('shows all checks passed when ok is true', () => {
render(<ModelStatusSection {...defaultProps} diagnostics={makeDiagnostics({ ok: true })} />);
expect(screen.getByText('All checks passed')).toBeTruthy();
});
it('shows issue count when ok is false', () => {
render(
<ModelStatusSection
{...defaultProps}
diagnostics={makeDiagnostics({
ok: false,
issues: ['issue one', 'issue two'],
repair_actions: [],
})}
/>
);
expect(screen.getByText('2 issue(s) found')).toBeTruthy();
});
it('renders prompt text when diagnostics is null', () => {
render(<ModelStatusSection {...defaultProps} diagnostics={null} />);
expect(screen.getByText(/Click.*Run Diagnostics/)).toBeTruthy();
});
});
@@ -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'}
</div>
{diagnostics.ollama_base_url && (
<div
className="mt-0.5 text-stone-400 truncate text-[10px]"
title={diagnostics.ollama_base_url}>
{diagnostics.ollama_base_url}
</div>
)}
</div>
<div className="rounded-md border border-stone-200 p-2">
<div className="text-stone-400 uppercase tracking-wide text-[10px]">Binary</div>
<div
className="mt-1 text-stone-600 truncate"
title={diagnostics.ollama_binary_path ?? 'Not found'}>
{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}
</div>
</div>
</div>
@@ -373,6 +401,24 @@ const ModelStatusSection = ({
</ul>
</div>
)}
{diagnostics.repair_actions && diagnostics.repair_actions.length > 0 && (
<div>
<div className="text-amber-700 uppercase tracking-wide text-[10px] mb-1">
Suggested Fixes
</div>
<div className="flex flex-wrap gap-2">
{diagnostics.repair_actions.map((action, i) => (
<button
key={i}
onClick={() => onRepairAction?.(action)}
className="px-2.5 py-1 text-xs rounded-md bg-amber-50 border border-amber-300 text-amber-800 hover:bg-amber-100 transition-colors">
{repairActionLabel(action)}
</button>
))}
</div>
</div>
)}
</>
)}
</div>
+7
View File
@@ -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;
}
+66 -3
View File
@@ -191,12 +191,27 @@ pub(crate) fn find_system_ollama_binary() -> Option<PathBuf> {
}
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;
}
}
+99 -12
View File
@@ -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<String>,
}
@@ -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");
}
}
+81 -2
View File
@@ -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<serde_json::Value, String> {
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<String> = Vec::new();
let mut repair_actions: Vec<serde_json::Value> = 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<String> {
// 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())
}
@@ -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");
}