Compare commits

...
Author SHA1 Message Date
goatoush 26e1741059 fix(desktop): keep background streams out of active chat (#654) 2026-08-10 17:15:33 -07:00
kelliott-cloudandElliot Slusky 2ed885eb11 fix: never auto-select embed-only models for chat (#659)
* fix: never auto-select embed-only models for chat

Ollama lists nomic-embed-text alongside chat models. Auto-picking
models[0] / recommending the only available id selected the embedder
and every generation failed with HTTP 400 "does not support chat".

- Filter embed-only ids out of GET /v1/models (chat picker)
- Exclude them from /v1/recommended-model; return empty when none left
- Frontend setModels prefers chat models and clears a bad embed selection
- Regression tests for mixed, embed-only, and classifier cases

* fix: harden chat model capability filtering

---------

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-10 17:06:48 -07:00
07fcf35276 fix: use a signal-free liveness probe for the daemon on Windows (#681)
* fix: use a signal-free liveness probe for the daemon on Windows

`_read_pid()` probed the recorded pid with `os.kill(pid, 0)`. That is a
POSIX idiom: on Windows signal 0 is `CTRL_C_EVENT`, so the call routes to
`GenerateConsoleCtrlEvent` rather than testing for existence, and raises
`OSError` (WinError 87, "The parameter is incorrect") for any pid that is
not a live console process-group leader — which includes both dead pids
and the detached server `jarvis start` creates.

That single call produced three symptoms. `jarvis status` propagated the
error and crashed. `_read_pid`'s `except OSError` swallowed it for a
running server, so `status` and `stop` reported "not running" and deleted
a live pid file. And because the probe *sends* a console control event
rather than merely asking, running `status` against the daemon could
terminate it.

Add `_pid_alive()`, which opens a process handle and checks it on Windows
and keeps the signal-0 probe on POSIX, and use it for both liveness
checks. `SIGKILL` in the stop path is now reached on Windows for the
first time, so guard it — it is POSIX-only, and `SIGTERM` already maps to
`TerminateProcess` there.

The existing round-trip test mocked `os.kill` to succeed, which is why
this passed CI on Linux while failing on every Windows run. Point it at
the new seam and add `TestPidLiveness`, which exercises real pids so the
platform behaviour is actually covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: format daemon tests with CI Ruff

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-10 16:56:53 -07:00
18 changed files with 494 additions and 45 deletions
-2
View File
@@ -31,7 +31,6 @@ export default function App() {
const prevModelRef = useRef<string>('');
const setModels = useAppStore((s) => s.setModels);
const setModelsLoading = useAppStore((s) => s.setModelsLoading);
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
const selectedModel = useAppStore((s) => s.selectedModel);
const setServerInfo = useAppStore((s) => s.setServerInfo);
const setSavings = useAppStore((s) => s.setSavings);
@@ -70,7 +69,6 @@ export default function App() {
fetchModels()
.then((m) => {
setModels(m);
if (!selectedModel && m.length > 0) setSelectedModel(m[0].id);
})
.catch(() => setModels([]))
.finally(() => setModelsLoading(false));
+9 -6
View File
@@ -15,6 +15,7 @@ function getGreeting(): string {
}
export function ChatArea() {
const activeId = useAppStore((s) => s.activeId);
const messages = useAppStore((s) => s.messages);
const streamState = useAppStore((s) => s.streamState);
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
@@ -24,6 +25,8 @@ export function ChatArea() {
const shouldAutoScroll = useRef(true);
const wasStreaming = useRef(false);
const lastScrollTop = useRef(0);
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
const currentStreamContent = isCurrentChatStreaming ? streamState.content : '';
// Check if any data sources are connected
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
@@ -38,14 +41,14 @@ export function ChatArea() {
useEffect(() => {
// Sending a message always pins the view to the bottom, even if the
// user had scrolled up to read earlier messages.
if (streamState.isStreaming && !wasStreaming.current) {
if (isCurrentChatStreaming && !wasStreaming.current) {
shouldAutoScroll.current = true;
}
wasStreaming.current = streamState.isStreaming;
wasStreaming.current = isCurrentChatStreaming;
if (shouldAutoScroll.current && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, streamState.content, streamState.isStreaming]);
}, [messages, currentStreamContent, isCurrentChatStreaming]);
const handleScroll = () => {
if (!listRef.current) return;
@@ -66,7 +69,7 @@ export function ChatArea() {
}
};
const isEmpty = messages.length === 0 && !streamState.isStreaming;
const isEmpty = messages.length === 0 && !isCurrentChatStreaming;
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
@@ -174,12 +177,12 @@ export function ChatArea() {
<MessageBubble
key={msg.id}
message={msg}
isLive={isLastAssistant && streamState.isStreaming}
isLive={isLastAssistant && isCurrentChatStreaming}
/>
);
})}
{(() => {
if (!streamState.isStreaming || streamState.content !== '') return null;
if (!isCurrentChatStreaming || streamState.content !== '') return null;
// For research messages the ResearchTimeline handles its own
// pre-content loading state — suppress the generic dots.
const last = messages[messages.length - 1];
+4 -2
View File
@@ -96,6 +96,7 @@ export function InputArea() {
const deepResearch = useAppStore((s) => s.deepResearch);
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
const corpusSync = useResearchCorpusSync(deepResearch);
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
const {
state: speechState,
@@ -226,6 +227,7 @@ export function InputArea() {
let ttftMs: number | undefined;
setStreamState({
conversationId: convId,
isStreaming: true,
phase: deepResearch ? 'Researching...' : 'Generating...',
elapsedMs: 0,
@@ -602,7 +604,7 @@ export function InputArea() {
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
disabled={streamState.isStreaming || modelLoading}
/>
{streamState.isStreaming ? (
{isCurrentChatStreaming ? (
<button
onClick={stopStreaming}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
@@ -621,7 +623,7 @@ export function InputArea() {
/>
<button
onClick={sendMessage}
disabled={!input.trim() || modelLoading || !selectedModel}
disabled={streamState.isStreaming || !input.trim() || modelLoading || !selectedModel}
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
style={{
+6 -3
View File
@@ -7,6 +7,7 @@ import {
type SetupStatus,
} from '../lib/api';
import { useAppStore } from '../lib/store';
import { isEmbedOnlyModel } from '../lib/model-capabilities';
const STEPS = [
{ key: 'ollama_ready', label: 'Inference Engine', icon: Cpu, detail: 'Starting Ollama...' },
@@ -91,12 +92,14 @@ export function SetupScreen({ onReady }: { onReady: () => void }) {
fetchRecommendedModel().catch(() => ({ model: '', reason: '' })),
]);
const store = useAppStore.getState();
const hadSelection = !!store.selectedModel;
store.setModels(models);
store.setModelsLoading(false);
const recommended = rec.model && models.some((m) => m.id === rec.model)
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
const recommended = rec.model && chatModels.some((m) => m.id === rec.model)
? rec.model
: models[0]?.id || '';
if (recommended && !store.selectedModel) {
: chatModels[0]?.id || '';
if (recommended && !hadSelection) {
store.setSelectedModel(recommended);
}
} catch {
@@ -22,6 +22,9 @@ export function ConversationList({ searchQuery }: Props) {
const navigate = useNavigate();
const conversations = useAppStore((s) => s.conversations);
const activeId = useAppStore((s) => s.activeId);
const streamingConversationId = useAppStore((s) =>
s.streamState.isStreaming ? s.streamState.conversationId : null,
);
const selectConversation = useAppStore((s) => s.selectConversation);
const deleteConversation = useAppStore((s) => s.deleteConversation);
@@ -43,6 +46,7 @@ export function ConversationList({ searchQuery }: Props) {
<div className="flex flex-col gap-0.5 py-1">
{filtered.map((conv) => {
const isActive = conv.id === activeId;
const isStreaming = conv.id === streamingConversationId;
return (
<div
key={conv.id}
@@ -82,11 +86,18 @@ export function ConversationList({ searchQuery }: Props) {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
disabled={isStreaming}
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
style={{ color: 'var(--color-text-tertiary)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
onMouseEnter={(e) => {
if (!isStreaming) e.currentTarget.style.color = 'var(--color-error)';
}}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
title="Delete conversation"
title={
isStreaming
? 'Stop generating before deleting this conversation'
: 'Delete conversation'
}
>
<Trash2 size={14} />
</button>
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isEmbedOnlyModel } from './model-capabilities';
describe('isEmbedOnlyModel', () => {
it.each([
'nomic-embed-text',
'mxbai-embed-large',
'text-embedding-3-small',
'all-minilm:latest',
'hf.co/BAAI/bge-m3:latest',
])('classifies %s as embedding-only', (modelId) => {
expect(isEmbedOnlyModel(modelId)).toBe(true);
});
it.each(['qwen3.5:4b', 'codegemma:7b'])('keeps %s available for chat', (modelId) => {
expect(isEmbedOnlyModel(modelId)).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
const EMBEDDING_MODEL_PREFIXES = [
'all-minilm',
'bge-',
'bge_',
'e5-',
'e5_',
'gte-',
'gte_',
'jina-embeddings',
'nomic-bert',
'sentence-transformers',
];
export function isEmbedOnlyModel(modelId: string): boolean {
const name = (modelId || '').trim().toLowerCase();
const leaf = name.slice(name.lastIndexOf('/') + 1).split(':')[0];
return (
leaf.includes('embed') ||
leaf.includes('minilm') ||
EMBEDDING_MODEL_PREFIXES.some((prefix) => leaf.startsWith(prefix))
);
}
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ModelInfo } from '../types';
class MemoryStorage {
private store = new Map<string, string>();
getItem(key: string): string | null {
return this.store.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
}
const model = (id: string): ModelInfo => ({
id,
object: 'model',
created: 0,
owned_by: 'openjarvis',
});
beforeEach(() => {
vi.resetModules();
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
afterEach(() => {
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
undefined;
});
describe('setModels', () => {
it('does not select an embedding-only model', async () => {
const { useAppStore } = await import('./store');
useAppStore.getState().setModels([model('nomic-embed-text')]);
expect(useAppStore.getState().selectedModel).toBe('');
});
it('clears a missing selection when no chat fallback exists', async () => {
const { useAppStore } = await import('./store');
useAppStore.getState().setSelectedModel('deleted-chat-model');
useAppStore.getState().setModels([model('nomic-embed-text')]);
expect(useAppStore.getState().selectedModel).toBe('');
});
it('replaces an embedding selection with an available chat model', async () => {
const { useAppStore } = await import('./store');
useAppStore.getState().setSelectedModel('all-minilm:latest');
useAppStore.getState().setModels([
model('all-minilm:latest'),
model('qwen3.5:4b'),
]);
expect(useAppStore.getState().selectedModel).toBe('qwen3.5:4b');
});
});
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
class MemoryStorage {
private store = new Map<string, string>();
getItem(key: string): string | null {
return this.store.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
removeItem(key: string): void {
this.store.delete(key);
}
}
beforeEach(() => {
vi.resetModules();
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
afterEach(() => {
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
undefined;
});
async function freshStore() {
return (await import('./store')).useAppStore;
}
describe('conversation stream ownership', () => {
it('persists background stream updates without replacing the active messages', async () => {
const store = await freshStore();
const sourceId = store.getState().createConversation('test-model');
store.getState().addMessage(sourceId, {
id: 'assistant',
role: 'assistant',
content: '',
timestamp: 1,
});
const activeId = store.getState().createConversation('test-model');
store.getState().setStreamState({
conversationId: sourceId,
isStreaming: true,
content: 'streamed response',
});
store.getState().updateLastAssistant(sourceId, 'streamed response');
expect(store.getState().activeId).toBe(activeId);
expect(store.getState().messages).toEqual([]);
store.getState().selectConversation(sourceId);
expect(store.getState().messages).toHaveLength(1);
expect(store.getState().messages[0].content).toBe('streamed response');
});
it('keeps the stream-owning conversation until generation stops', async () => {
const store = await freshStore();
const sourceId = store.getState().createConversation('test-model');
const activeId = store.getState().createConversation('test-model');
store.getState().setStreamState({
conversationId: sourceId,
isStreaming: true,
});
store.getState().deleteConversation(sourceId);
expect(
store.getState().conversations.map((conversation) => conversation.id),
).toContain(sourceId);
expect(store.getState().activeId).toBe(activeId);
store.getState().resetStream();
store.getState().deleteConversation(sourceId);
expect(
store.getState().conversations.map((conversation) => conversation.id),
).not.toContain(sourceId);
});
});
+46 -12
View File
@@ -15,6 +15,7 @@ import type {
TokenUsage,
} from '../types';
import type { ManagedAgent } from './api';
import { isEmbedOnlyModel } from './model-capabilities';
export interface CachedConnector {
connector_id: string;
@@ -110,6 +111,7 @@ function saveSettings(settings: Settings): void {
// ── Store ─────────────────────────────────────────────────────────────
const INITIAL_STREAM: StreamState = {
conversationId: null,
isStreaming: false,
phase: '',
elapsedMs: 0,
@@ -351,6 +353,9 @@ export const useAppStore = create<AppState>((set, get) => {
},
deleteConversation: (id: string) => {
const streamState = get().streamState;
if (streamState.isStreaming && streamState.conversationId === id) return;
const store = loadConversations();
delete store.conversations[id];
if (store.activeId === id) {
@@ -393,12 +398,14 @@ export const useAppStore = create<AppState>((set, get) => {
(message.content.length > 50 ? '...' : '');
}
saveConversations(store);
set({
messages: [...conv.messages],
conversations: Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
),
});
const conversations = Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
);
if (get().activeId === conversationId) {
set({ messages: [...conv.messages], conversations });
} else {
set({ conversations });
}
},
updateLastAssistant: (
@@ -425,7 +432,9 @@ export const useAppStore = create<AppState>((set, get) => {
if (researchSources) lastMsg.researchSources = researchSources;
conv.updatedAt = Date.now();
saveConversations(store);
set({ messages: [...conv.messages] });
if (get().activeId === conversationId) {
set({ messages: [...conv.messages] });
}
}
},
@@ -444,11 +453,36 @@ export const useAppStore = create<AppState>((set, get) => {
// ── Models & server ────────────────────────────────────────────
setModels: (models: ModelInfo[]) =>
set((state) =>
!state.selectedModel && models.length > 0
? { models, selectedModel: models[0].id }
: { models },
),
set((state) => {
// Ollama returns embed-only models (e.g. nomic-embed-text) in the
// same list as chat models. Auto-picking models[0] selected the
// embedder and every chat failed with HTTP 400 "does not support
// chat". Prefer a real chat model for selection / fallback.
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
const preferred =
(state.settings.defaultModel &&
chatModels.some((m) => m.id === state.settings.defaultModel) &&
state.settings.defaultModel) ||
chatModels[0]?.id ||
models.find((m) => !isEmbedOnlyModel(m.id))?.id ||
'';
const currentIsBad =
!!state.selectedModel && isEmbedOnlyModel(state.selectedModel);
const currentMissing =
!!state.selectedModel &&
!models.some((m) => m.id === state.selectedModel);
if (!state.selectedModel || currentIsBad || currentMissing) {
// Prefer a real chat model. If none exist, clear a bad/missing
// selection rather than keeping an embed-only id that 400s on chat.
return {
models,
selectedModel: preferred,
};
}
return { models };
}),
setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
setSelectedModel: (model: string) => set({ selectedModel: model }),
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
+1
View File
@@ -147,6 +147,7 @@ export interface ConversationStore {
// --- Stream State ---
export interface StreamState {
conversationId: string | null;
isStreaming: boolean;
phase: string;
elapsedMs: number;
+54 -9
View File
@@ -17,18 +17,64 @@ _PID_FILE = DEFAULT_CONFIG_DIR / "server.pid"
_LOG_FILE = DEFAULT_CONFIG_DIR / "server.log"
def _pid_alive(pid: int) -> bool:
"""Return whether *pid* identifies a running process without signaling it."""
if pid <= 0:
return False
if os.name == "nt":
import ctypes
from ctypes import wintypes
error_invalid_parameter = 87
synchronize = 0x00100000
wait_object_0 = 0x00000000
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
kernel32.WaitForSingleObject.restype = wintypes.DWORD
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
handle = kernel32.OpenProcess(synchronize, False, pid)
if not handle:
# OpenProcess reports ERROR_INVALID_PARAMETER when the PID does not
# exist. For access-denied and other inconclusive failures, retain
# the PID file rather than declaring a potentially live daemon dead.
return ctypes.get_last_error() != error_invalid_parameter
try:
wait_result = kernel32.WaitForSingleObject(handle, 0)
# WAIT_OBJECT_0 proves the process exited. WAIT_TIMEOUT proves it
# is live; unexpected failures are inconclusive, so retain the PID.
return wait_result != wait_object_0
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _read_pid() -> int | None:
"""Read PID from pid file, return None if not found or stale."""
if not _PID_FILE.exists():
return None
try:
pid = int(_PID_FILE.read_text().strip())
# Check if process is still running
os.kill(pid, 0)
return pid
except (ValueError, OSError):
except (OSError, ValueError):
_PID_FILE.unlink(missing_ok=True)
return None
if not _pid_alive(pid):
_PID_FILE.unlink(missing_ok=True)
return None
return pid
def _write_pid(pid: int) -> None:
@@ -127,14 +173,13 @@ def stop() -> None:
# Wait up to 10 seconds for graceful shutdown
for _ in range(20):
time.sleep(0.5)
try:
os.kill(pid, 0)
except OSError:
if not _pid_alive(pid):
break
else:
# Force kill if still running
# SIGKILL is POSIX-only. On Windows SIGTERM already maps to
# TerminateProcess, so repeating it is the available escalation.
try:
os.kill(pid, signal.SIGKILL)
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
except OSError:
pass
except OSError:
+19 -5
View File
@@ -20,6 +20,7 @@ from openjarvis.agents.tool_resolver import (
from openjarvis.agents.tool_resolver import (
ensure_registries_populated as _ensure_registries_populated,
)
from openjarvis.server.model_capabilities import is_embed_only_model
try:
from fastapi import APIRouter, HTTPException, Request
@@ -332,16 +333,29 @@ _CLOUD_PREFIXES = ("gpt-", "claude-", "gemini-", "o1-", "o3-", "o4-")
def _pick_recommended_model(
model_ids: list[str],
) -> dict[str, str]:
"""Pick the second-largest local model from a list."""
local = [m for m in model_ids if not any(m.startswith(p) for p in _CLOUD_PREFIXES)]
"""Pick the second-largest local *chat* model from a list.
Embedding-only models (nomic-embed-text, etc.) are excluded they return
HTTP 400 "does not support chat" when used as the generation model.
"""
local = [
m
for m in model_ids
if not any(m.startswith(p) for p in _CLOUD_PREFIXES)
and not is_embed_only_model(m)
]
if not local:
# Fall back to any non-cloud model, still skipping embedders.
local = [m for m in model_ids if not is_embed_only_model(m)]
if not local:
# Never recommend an embed-only model — chat would 400.
return {
"model": model_ids[0] if model_ids else "",
"reason": "Only model available",
"model": "",
"reason": "No local chat model available",
}
sized = sorted(local, key=_parse_param_count, reverse=True)
if len(sized) == 1:
return {"model": sized[0], "reason": "Only local model available"}
return {"model": sized[0], "reason": "Only local chat model available"}
pick = sized[1] # second-largest
params = _parse_param_count(pick)
return {
@@ -0,0 +1,34 @@
"""Model capability helpers shared by server model-selection routes."""
_EMBEDDING_MODEL_PREFIXES = (
"all-minilm",
"bge-",
"bge_",
"e5-",
"e5_",
"gte-",
"gte_",
"jina-embeddings",
"nomic-bert",
"sentence-transformers",
)
def is_embed_only_model(model_name: str) -> bool:
"""Return whether a model identifier denotes a non-chat embedder.
Ollama does not expose capabilities through its model-list response, so
model selection needs a conservative name-based guard. Most embedding
models contain ``embed``; the explicit prefixes cover common families
such as MiniLM, BGE, E5, and GTE whose names do not.
"""
name = (model_name or "").strip().lower()
leaf = name.rsplit("/", 1)[-1].split(":", 1)[0]
return (
"embed" in leaf
or "minilm" in leaf
or leaf.startswith(_EMBEDDING_MODEL_PREFIXES)
)
__all__ = ["is_embed_only_model"]
+6
View File
@@ -12,6 +12,7 @@ from fastapi.responses import StreamingResponse
from openjarvis.core.paths import get_config_dir
from openjarvis.core.types import Message, Role
from openjarvis.server.model_capabilities import is_embed_only_model
from openjarvis.server.models import (
ChatCompletionChunk,
ChatCompletionRequest,
@@ -892,6 +893,11 @@ async def list_models(request: Request) -> ModelListResponse:
if not model_ids:
model_ids = await list_local_models()
# Keep embed-only models out of the chat model picker. They still work for
# memory/retrieval via the embedder path; putting them in /v1/models made
# the UI auto-select nomic-embed-text and fail every generation with 400.
model_ids = [m for m in model_ids if not is_embed_only_model(m)]
return ModelListResponse(
data=[
ModelObject(
+53 -3
View File
@@ -2,14 +2,17 @@
from __future__ import annotations
import os
import subprocess
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from openjarvis.cli import cli
from openjarvis.cli.daemon_cmd import _read_pid, _write_pid
from openjarvis.cli.daemon_cmd import _pid_alive, _read_pid, _write_pid
class TestDaemonCommands:
@@ -45,12 +48,12 @@ class TestDaemonCommands:
assert _read_pid() is None
def test_write_and_read_pid(self, tmp_path: Path) -> None:
"""Write a PID, then read it back (mock os.kill to succeed)."""
"""Write a PID, then read it back with a successful liveness probe."""
pid_file = tmp_path / "server.pid"
with (
patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file),
patch("openjarvis.cli.daemon_cmd.DEFAULT_CONFIG_DIR", tmp_path),
patch("os.kill", return_value=None),
patch("openjarvis.cli.daemon_cmd._pid_alive", return_value=True),
):
_write_pid(12345)
assert pid_file.exists()
@@ -82,6 +85,53 @@ class TestDaemonCommands:
assert "already running" in result.output
class TestPidLiveness:
"""Regression coverage for Windows-safe PID liveness checks."""
def test_pid_alive_current_process(self) -> None:
assert _pid_alive(os.getpid()) is True
def test_pid_alive_nonpositive(self) -> None:
assert _pid_alive(0) is False
assert _pid_alive(-1) is False
def test_pid_alive_dead_pid(self) -> None:
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
for _ in range(20):
if not _pid_alive(proc.pid):
break
time.sleep(0.1)
assert _pid_alive(proc.pid) is False
def test_read_pid_stale_pid_returns_none(self, tmp_path: Path) -> None:
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
pid_file = tmp_path / "server.pid"
pid_file.write_text(str(proc.pid))
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
assert _read_pid() is None
assert not pid_file.exists()
def test_read_pid_live_pid_returns_it(self, tmp_path: Path) -> None:
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(10)"])
try:
pid_file = tmp_path / "server.pid"
pid_file.write_text(str(proc.pid))
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
assert _read_pid() == proc.pid
assert pid_file.exists()
finally:
proc.terminate()
proc.wait()
class TestDaemonDetachment:
"""The spawned server must outlive the console that started it.
+22
View File
@@ -265,6 +265,28 @@ class TestModelsEndpointExtended:
assert "qwen3.5:9b" in ids
assert "qwen3:0.6b" in ids
def test_models_list_filters_embedding_only_models(self):
engine = _make_engine(
models=["nomic-embed-text", "all-minilm:latest", "qwen3.5:4b"],
)
client = TestClient(create_app(engine, "qwen3.5:4b"))
resp = client.get("/v1/models")
assert resp.status_code == 200
assert [m["id"] for m in resp.json()["data"]] == ["qwen3.5:4b"]
def test_models_list_returns_empty_when_only_embedders_are_installed(self):
engine = _make_engine(
models=["nomic-embed-text", "hf.co/BAAI/bge-m3:latest"],
)
client = TestClient(create_app(engine, "nomic-embed-text"))
resp = client.get("/v1/models")
assert resp.status_code == 200
assert resp.json()["data"] == []
def test_models_empty_engine(self):
"""When engine.list_models() returns empty, endpoint still succeeds."""
engine = _make_engine(models=[])
+39
View File
@@ -50,3 +50,42 @@ def test_parse_param_count():
assert _parse_param_count("qwen3.5:0.8b") == 0.8
assert _parse_param_count("qwen3.5:35b") == 35.0
assert _parse_param_count("gpt-4o") == 0.0
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
def test_recommended_model_skips_embed_only():
"""Embed-only models must never be recommended for chat."""
from openjarvis.server.agent_manager_routes import _pick_recommended_model
models = [
"nomic-embed-text",
"qwen3.5:4b",
"mxbai-embed-large",
"qwen3.5:9b",
]
result = _pick_recommended_model(models)
assert result["model"] == "qwen3.5:4b"
assert "embed" not in result["model"]
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
def test_recommended_model_embed_only_returns_empty():
"""If only embedders are installed, recommend nothing (not nomic-embed)."""
from openjarvis.server.agent_manager_routes import _pick_recommended_model
result = _pick_recommended_model(["nomic-embed-text", "mxbai-embed-large"])
assert result["model"] == ""
assert "No local chat model" in result["reason"]
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
def test_is_embed_only_model():
from openjarvis.server.model_capabilities import is_embed_only_model
assert is_embed_only_model("nomic-embed-text")
assert is_embed_only_model("mxbai-embed-large")
assert is_embed_only_model("text-embedding-3-small")
assert is_embed_only_model("all-minilm:latest")
assert is_embed_only_model("hf.co/BAAI/bge-m3:latest")
assert not is_embed_only_model("qwen3.5:4b")
assert not is_embed_only_model("codegemma:7b")