Compare commits

...
Author SHA1 Message Date
github-actions[bot] 465dba4b3f chore: update clone traffic data [skip ci] 2026-08-13 07:22:13 +00:00
github-actions[bot] 4f857b0abb chore: update clone traffic data [skip ci] 2026-08-12 07:20:09 +00:00
Jon Saad-FalconandClaude Opus 5 20aa08ef04 ci(desktop): preflight Apple notarization credentials before build (#724)
Notarization is the last thing tauri-action does, so any credential or
account-state fault surfaced ~10 minutes into the macOS job -- after the
Rust toolchain, npm install, two Ollama sidecar downloads and a universal
cargo build -- as one opaque line:

  failed to bundle project: failed codesign application: failed to
  notarize app: Error: HTTP status code: 403. ...

That message conflates three unrelated causes, and the signing step
succeeds in all of them, so the log actively misleads: the certificate is
clearly valid right up until the failure.

Add a read-only `notarytool history` call immediately after checkout. It
submits nothing and exercises the identical auth path, so all three
failures reach us in ~2s with the specific cause and fix named:

  401 invalid credentials  -> APPLE_PASSWORD is not an app-specific
                              password, or was minted under a different
                              Apple ID than APPLE_ID
  403 inaccessible team    -> APPLE_ID is not a member of APPLE_TEAM_ID
  403 required agreement   -> the Program License Agreement lapsed; only
                              the Account Holder can accept it

xcrun is preinstalled on macOS runners, hence placement before the
toolchain steps rather than beside "Configure Apple signing".

Skips cleanly when APPLE_CERTIFICATE is unset (unsigned builds never
notarize), mirroring the existing signing step, and errors when a
certificate is present but notarization secrets are missing -- previously
that combination signed successfully and then failed at the very end.
Transient network faults retry 3x; credential errors are deterministic
and exit on the first definitive answer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:49:52 -07:00
github-actions[bot] 9a63561db8 chore: update clone traffic data [skip ci] 2026-08-11 07:02:06 +00:00
Elliot Slusky 7959285ad8 fix(cli): preserve extras during self-update (#718)
* fix(cli): preserve extras during self-update

* style(cli): format install detection
2026-08-10 17:22:29 -07:00
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
Haotian Zheng 4efdb07dae fix(cli): use UTF-8 for config files 2026-08-10 16:44:17 -07:00
Elliot Slusky 7feda3dad3 fix(cloud): isolate Gemini stream metadata 2026-08-10 16:32:14 -07:00
Elliot Slusky f08ad574d3 fix(cloud): handle parallel Gemini tool calls 2026-08-10 16:32:14 -07:00
Dustin Zander 1bfc25a860 fix: preserve Gemini tool calls while streaming 2026-08-10 16:32:14 -07:00
32 changed files with 1225 additions and 67 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "185,599",
"message": "189,482",
"color": "green",
"namedLogo": "git"
}
+6 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 185599,
"last_updated": "2026-08-10T07:26:44Z",
"total_clones": 189482,
"last_updated": "2026-08-13T07:22:13Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -137,6 +137,9 @@
"2026-08-06": 604,
"2026-08-07": 624,
"2026-08-08": 706,
"2026-08-09": 1076
"2026-08-09": 1076,
"2026-08-10": 1060,
"2026-08-11": 2182,
"2026-08-12": 641
}
}
+98
View File
@@ -121,6 +121,104 @@ jobs:
# latest release tag (#526).
fetch-depth: 0
# Validate Apple credentials BEFORE the expensive work. Notarization is
# the very last thing `tauri-action` does, so a bad credential or a
# lapsed account agreement previously surfaced ~10 minutes in — after the
# Rust toolchain, npm install, two Ollama sidecar downloads and a
# universal cargo build — as a single opaque line:
#
# failed to bundle project: failed codesign application: failed to
# notarize app: Error: HTTP status code: 403. ...
#
# `notarytool history` is a read-only call (it submits nothing) that
# exercises the identical auth path, so every credential/account failure
# mode reaches us here first, in seconds, with the specific cause named.
# `xcrun` is preinstalled on macOS runners, hence placement before the
# toolchain steps rather than next to "Configure Apple signing".
- name: Preflight Apple notarization credentials
if: matrix.platform == 'macos-14'
env:
CERT: ${{ secrets.APPLE_CERTIFICATE }}
A_ID: ${{ secrets.APPLE_ID }}
A_PASS: ${{ secrets.APPLE_PASSWORD }}
A_TEAM: ${{ secrets.APPLE_TEAM_ID }}
shell: bash
run: |
set -uo pipefail
# Mirror the skip logic in "Configure Apple signing": without a
# certificate the build is unsigned and never notarizes, so there is
# nothing to preflight. Tag builds still hard-fail there.
if [ -z "$CERT" ]; then
echo "No Apple certificate configured; skipping notarization preflight."
exit 0
fi
missing=""
[ -z "$A_ID" ] && missing="$missing APPLE_ID"
[ -z "$A_PASS" ] && missing="$missing APPLE_PASSWORD"
[ -z "$A_TEAM" ] && missing="$missing APPLE_TEAM_ID"
if [ -n "$missing" ]; then
echo "::error::APPLE_CERTIFICATE is set but notarization secrets are missing:$missing"
echo "::error::Signing would succeed and notarization would then fail. Set them or clear APPLE_CERTIFICATE."
exit 1
fi
# Retry only to absorb transient network faults. Credential and
# account errors are deterministic, so we classify and exit on the
# first definitive answer rather than retrying into the same wall.
attempt=1
while [ "$attempt" -le 3 ]; do
out=$(xcrun notarytool history \
--apple-id "$A_ID" \
--team-id "$A_TEAM" \
--password "$A_PASS" \
--output-format json 2>&1)
rc=$?
if [ $rc -eq 0 ]; then
echo "Apple notarization preflight OK — credentials valid, team reachable, agreements in effect."
exit 0
fi
case "$out" in
*"Invalid credentials"*|*"401"*)
echo "::error::Apple notarization preflight failed: invalid credentials (HTTP 401)."
echo "::error::APPLE_PASSWORD must be an app-specific password from appleid.apple.com,"
echo "::error::generated while signed in as the SAME Apple ID as APPLE_ID. A regular"
echo "::error::Apple ID password will not work, and a password minted under a different"
echo "::error::Apple ID authenticates as that other account."
exit 1
;;
*"Invalid or inaccessible developer team ID"*)
echo "::error::Apple notarization preflight failed: APPLE_ID is not a member of team APPLE_TEAM_ID (HTTP 403)."
echo "::error::The Team ID must match the signing certificate. Read it from the cert's"
echo "::error::subject, where it appears as: Developer ID Application: NAME (TEAMID)."
echo "::error::If you belong to several teams, confirm APPLE_ID is a member of this one."
exit 1
;;
*"required agreement"*|*"agreement"*)
echo "::error::Apple notarization preflight failed: the team has no in-effect agreement (HTTP 403)."
echo "::error::Apple reissues the Developer Program License Agreement periodically and"
echo "::error::notarization is refused until it is accepted. ONLY THE ACCOUNT HOLDER can"
echo "::error::accept it — team Admins cannot. Sign in to the account that owns this team:"
echo "::error:: 1. https://developer.apple.com/account -> review any pending agreement"
echo "::error:: 2. App Store Connect -> Business -> accept anything pending there too"
echo "::error::Certificates stay valid while this is outstanding, so signing still works."
exit 1
;;
esac
echo "Preflight attempt ${attempt}/3 failed with a non-credential error."
echo "$out" | tail -5
attempt=$((attempt + 1))
[ "$attempt" -le 3 ] && sleep 10
done
echo "::error::Apple notarization preflight failed after 3 attempts. Last output:"
echo "$out" | tail -20
exit 1
- name: Install system dependencies (Linux)
if: matrix.platform == 'ubuntu-22.04'
run: |
-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;
+4 -2
View File
@@ -11,7 +11,9 @@ Three install paths are supported today:
- **Editable git checkout** (``uv sync`` / ``pip install -e .`` from a
cloned repo). The package's ``__file__`` is inside a working tree
with a ``.git`` directory at the repo root. Upgrade with
``git pull && uv sync`` from the checkout.
``git pull && uv sync --inexact`` from the checkout. ``--inexact`` is
important here: a bare ``uv sync`` removes packages installed by extras or
dependency groups that are not part of the base project.
We detect by inspecting ``openjarvis.__file__``. If we can't tell with
confidence we fall back to the PyPI command — that's the most common
@@ -68,7 +70,7 @@ def detect_install() -> InstallInfo:
if (candidate / ".git").exists() and (candidate / "pyproject.toml").exists():
return InstallInfo(
kind="editable-git",
upgrade_command=f"cd {candidate} && git pull && uv sync",
upgrade_command=(f"cd {candidate} && git pull && uv sync --inexact"),
repo_root=candidate,
)
if candidate.parent == candidate:
+4 -4
View File
@@ -158,7 +158,7 @@ def _show_toml_config(console: Console, config_path: Path) -> None:
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config_content = config_path.read_text()
config_content = config_path.read_text(encoding="utf-8")
syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True)
console.print(Panel(syntax, title="Config File", border_style="cyan"))
else:
@@ -170,7 +170,7 @@ def _show_json_config(console: Console, config_path: Path) -> None:
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config_content = config_path.read_text()
config_content = config_path.read_text(encoding="utf-8")
try:
import tomllib # Python 3.11+
@@ -375,7 +375,7 @@ def set_config(key: str, value: str) -> None:
os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml")
)
if config_path.exists():
doc = tomlkit.parse(config_path.read_text())
doc = tomlkit.parse(config_path.read_text(encoding="utf-8"))
else:
doc = tomlkit.document()
config_path.parent.mkdir(parents=True, exist_ok=True)
@@ -390,7 +390,7 @@ def set_config(key: str, value: str) -> None:
current[parts[-1]] = typed_value
# Write back
config_path.write_text(tomlkit.dumps(doc))
config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
console.print(f"[green]Set[/green] {key} = {value!r}")
+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:
+3 -1
View File
@@ -344,7 +344,9 @@ def init(
console.print(f" Looked in: {examples_dir}")
raise SystemExit(1)
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_CONFIG_PATH.write_text(preset_path.read_text())
DEFAULT_CONFIG_PATH.write_text(
preset_path.read_text(encoding="utf-8"), encoding="utf-8"
)
console.print(
f"[green]Preset '{preset}' installed to {DEFAULT_CONFIG_PATH}[/green]"
)
+3 -1
View File
@@ -4,7 +4,9 @@ Runs the right upgrade command for how the user installed OpenJarvis:
- PyPI installs get ``pip install --upgrade openjarvis``.
- uv-tool installs get ``uv tool upgrade openjarvis``.
- Editable git checkouts get ``git pull && uv sync`` in the checkout.
- Editable git checkouts get ``git pull && uv sync --inexact`` in the checkout.
The inexact sync preserves packages previously installed through extras and
dependency groups.
The detection logic is shared with the post-command "new version
available" hint in ``_version_check.py`` so both surfaces stay in sync.
+156 -1
View File
@@ -9,6 +9,7 @@ import json
import logging
import os
import time
import uuid
from collections.abc import AsyncIterator, Sequence
from typing import Any, Dict, List, Tuple
@@ -1305,6 +1306,160 @@ class CloudEngine(InferenceEngine):
if chunk.text:
yield chunk.text
async def _stream_full_google(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""Stream Google text and function-call parts as full chunks."""
if self._google_client is None:
raise EngineConnectionError("Google client not available")
system_text = ""
contents: List[Dict[str, Any]] = []
for message in messages:
if message.role.value == "system":
system_text = message.content
elif message.role.value == "tool":
function_response = {
"function_response": {
"name": message.name or "unknown",
"response": {"result": message.content},
}
}
if (
contents
and contents[-1]["role"] == "user"
and contents[-1]["parts"]
and "function_response" in contents[-1]["parts"][-1]
):
contents[-1]["parts"].append(function_response)
else:
contents.append({"role": "user", "parts": [function_response]})
elif message.role.value == "assistant" and message.tool_calls:
parts: List[Dict[str, Any]] = []
if message.content:
parts.append({"text": message.content})
for tool_call in message.tool_calls:
args = tool_call.arguments
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {"input": args}
function_call_part: Dict[str, Any] = {
"function_call": {
"name": tool_call.name,
"args": args if isinstance(args, dict) else {},
}
}
signature = self._thought_sigs.get(tool_call.id)
if signature is not None:
function_call_part["thought_signature"] = signature
parts.append(function_call_part)
contents.append({"role": "model", "parts": parts})
elif message.role.value == "assistant":
contents.append({"role": "model", "parts": [{"text": message.content}]})
else:
contents.append({"role": "user", "parts": [{"text": message.content}]})
from google.genai import types as genai_types
config = genai_types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)
if system_text:
config.system_instruction = system_text
tools = kwargs.pop("tools", None)
if tools:
config.tools = [{"function_declarations": _convert_tools_to_google(tools)}]
tool_call_count = 0
stream_id = uuid.uuid4().hex
final_usage: Dict[str, Any] | None = None
for chunk in self._google_client.models.generate_content_stream(
model=model,
contents=contents,
config=config,
):
usage_metadata = getattr(chunk, "usage_metadata", None)
if usage_metadata is not None:
prompt_tokens = getattr(usage_metadata, "prompt_token_count", 0) or 0
completion_tokens = (
getattr(usage_metadata, "candidates_token_count", 0) or 0
)
final_usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
candidates = getattr(chunk, "candidates", None)
parts = []
if candidates:
parts = getattr(candidates[0].content, "parts", []) or []
if parts:
text_found = False
calls: List[Dict[str, Any]] = []
for part in parts:
text = getattr(part, "text", None)
if text:
text_found = True
yield StreamChunk(content=text)
function_call = getattr(part, "function_call", None)
if function_call:
name = getattr(function_call, "name", "")
raw_args = getattr(function_call, "args", {})
args = dict(raw_args) if hasattr(raw_args, "items") else {}
# Gemini emits complete function-call parts, so each part is
# a distinct invocation. The same function may legitimately
# be called more than once in a parallel response.
tool_index = tool_call_count
# The engine is shared across server requests, and saved
# thought signatures are keyed by tool-call ID. Include a
# per-stream nonce so concurrent conversations cannot
# overwrite each other's signatures.
tool_id = f"google_{stream_id}_{tool_index}"
tool_call_count += 1
tool_call = {
"index": tool_index,
"id": tool_id,
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(args),
},
}
calls.append(tool_call)
signature = getattr(part, "thought_signature", None)
if signature is not None:
tool_call["thought_signature"] = signature
self._thought_sigs[tool_id] = signature
if calls:
yield StreamChunk(tool_calls=calls)
if text_found:
continue
try:
text = chunk.text
except (AttributeError, ValueError):
text = None
if text:
yield StreamChunk(content=text)
yield StreamChunk(
finish_reason="tool_calls" if tool_call_count else "stop",
usage=final_usage,
)
async def _stream_openrouter(
self,
messages: Sequence[Message],
@@ -1600,7 +1755,7 @@ class CloudEngine(InferenceEngine):
async for chunk in self._stream_full_anthropic(messages, **kw):
yield chunk
elif _is_google_model(model):
async for chunk in super().stream_full(messages, **kw):
async for chunk in self._stream_full_google(messages, **kw):
yield chunk
else:
async for chunk in self._stream_full_openai(messages, **kw):
+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(
+32
View File
@@ -138,6 +138,38 @@ class TestCLI:
content = config_path.read_text()
assert "[engine]" in content
def test_init_preset_uses_utf8_for_config_copy(self, tmp_path: Path) -> None:
"""Preset installation reads and writes shipped TOML as UTF-8."""
config_dir = tmp_path / ".openjarvis"
config_path = config_dir / "config.toml"
original_read_text = Path.read_text
original_write_text = Path.write_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path.name == "chat-simple.toml":
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
if path == config_path:
assert kwargs.get("encoding") == "utf-8"
return original_write_text(path, data, *args, **kwargs)
with (
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
mock.patch.object(
Path, "write_text", autospec=True, side_effect=write_text
),
):
result = CliRunner().invoke(cli, ["init", "--preset", "chat-simple"])
assert result.exit_code == 0
assert "lightweight conversational AI" in config_path.read_text(
encoding="utf-8"
)
class TestStartupResilience:
"""Importing the CLI must not force heavy/native deps (#404, #309).
+24 -7
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
from pathlib import Path
from unittest import mock
import pytest
from click.testing import CliRunner
@@ -109,18 +110,34 @@ temperature = 0.7
except json.JSONDecodeError:
pytest.fail(f"Output is not valid JSON: {result.output}")
def test_config_show_toml_displays_raw_content(self, tmp_path: Path) -> None:
"""Test that config show toml displays the raw TOML content."""
@pytest.mark.parametrize("output_format", ["toml", "json"])
def test_config_show_uses_utf8_for_config_file(
self, tmp_path: Path, output_format: str
) -> None:
"""Test that config show reads UTF-8 config files explicitly."""
# Create a temporary config file
config_file = tmp_path / "test_config.toml"
config_file.write_text('[engine]\ndefault = "ollama"\n')
result = CliRunner().invoke(
cli, ["config", "show", "toml", "--path", str(config_file)]
config_file.write_text(
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
encoding="utf-8",
)
original_read_text = Path.read_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
with mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text):
result = CliRunner().invoke(
cli, ["config", "show", output_format, "--path", str(config_file)]
)
assert result.exit_code == 0
assert "[engine]" in result.output
if output_format == "toml":
assert "[engine]" in result.output
else:
assert '"engine"' in result.output
assert "ollama" in result.output
def test_config_show_json_displays_parsed_content(self, tmp_path: Path) -> None:
+36
View File
@@ -60,6 +60,42 @@ class TestConfigSet:
assert "vllm" in content
assert "qwen2.5:3b" in content
def test_set_uses_utf8_for_existing_config(self, tmp_path: Path) -> None:
"""config set preserves a UTF-8 config regardless of the system locale."""
config_file = tmp_path / "config.toml"
config_file.write_text(
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
encoding="utf-8",
)
original_read_text = Path.read_text
original_write_text = Path.write_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_write_text(path, data, *args, **kwargs)
with (
mock.patch.dict(os.environ, {"OPENJARVIS_CONFIG": str(config_file)}),
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
mock.patch.object(
Path, "write_text", autospec=True, side_effect=write_text
),
):
result = CliRunner().invoke(
cli, ["config", "set", "engine.default", "vllm"]
)
assert result.exit_code == 0
content = config_file.read_text(encoding="utf-8")
assert "Preset comment — stored as UTF-8" in content
assert "vllm" in content
def test_set_invalid_key_rejected(self, tmp_path: Path) -> None:
"""config set rejects unknown keys."""
config_file = tmp_path / "config.toml"
+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.
+1 -1
View File
@@ -35,7 +35,7 @@ def test_editable_git_install_detected(tmp_path, monkeypatch):
info = detect_install()
assert info.kind == "editable-git"
assert "git pull" in info.upgrade_command
assert "uv sync" in info.upgrade_command
assert info.upgrade_command.endswith("uv sync --inexact")
assert info.repo_root == repo
+21 -1
View File
@@ -21,7 +21,7 @@ def _mock_info(kind: str = "pypi") -> InstallInfo:
upgrade_command={
"pypi": "pip install --upgrade openjarvis",
"uv-tool": "uv tool upgrade openjarvis",
"editable-git": "cd /tmp/repo && git pull && uv sync",
"editable-git": "cd /tmp/repo && git pull && uv sync --inexact",
"unknown": "pip install --upgrade openjarvis",
}[kind],
)
@@ -90,6 +90,26 @@ def test_editable_git_uses_shell_true():
assert kwargs.get("shell") is True
def test_editable_git_preserves_extra_dependencies():
"""The update sync must not remove packages from prior extras/groups."""
mock_proc = MagicMock(returncode=0)
with (
patch(
"openjarvis.cli.self_update_cmd.detect_install",
return_value=_mock_info("editable-git"),
),
patch(
"openjarvis.cli.self_update_cmd.subprocess.run",
return_value=mock_proc,
) as mock_run,
):
result = CliRunner().invoke(self_update, ["-y"])
assert result.exit_code == 0
assert "uv sync --inexact" in result.output
assert "uv sync --inexact" in mock_run.call_args.args[0]
def test_failed_upgrade_propagates_exit_code():
mock_proc = MagicMock(returncode=3)
with (
+342
View File
@@ -3,6 +3,8 @@ and _prepare_anthropic_messages."""
from __future__ import annotations
import sys
from types import ModuleType, SimpleNamespace
from typing import Any, List
from unittest.mock import MagicMock
@@ -63,6 +65,36 @@ def _openai_tool_call_delta(
return tc
class _GoogleConfig:
def __init__(self, **kwargs: Any) -> None:
self.__dict__.update(kwargs)
def _google_stream_chunk(
*parts: Any,
text: str | None = None,
usage_metadata: Any = None,
) -> Any:
candidates = []
if parts:
candidates = [SimpleNamespace(content=SimpleNamespace(parts=list(parts)))]
return SimpleNamespace(
text=text,
candidates=candidates,
usage_metadata=usage_metadata,
)
def _google_types_modules() -> dict[str, ModuleType]:
types = ModuleType("google.genai.types")
types.GenerateContentConfig = _GoogleConfig
genai = ModuleType("google.genai")
genai.types = types
google = ModuleType("google")
google.genai = genai
return {"google": google, "google.genai": genai, "google.genai.types": types}
# ---------------------------------------------------------------------------
# _stream_full_openai tests
# ---------------------------------------------------------------------------
@@ -413,6 +445,316 @@ def test_prepare_anthropic_messages_tool_calls():
assert blocks[1]["input"] == {"city": "Berlin"}
# ---------------------------------------------------------------------------
# _stream_full_google tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_full_google_text_only(monkeypatch: pytest.MonkeyPatch):
"""Google text chunks retain their content and finish normally."""
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(text="Hello"), _google_stream_chunk(text=" world")]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
messages = [Message(role=Role.USER, content="hi")]
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(messages, model="gemini-2.5-flash")
]
assert [chunk.content for chunk in result[:-1]] == ["Hello", " world"]
assert result[-1].finish_reason == "stop"
@pytest.mark.asyncio
async def test_stream_full_google_preserves_tool_calls(monkeypatch: pytest.MonkeyPatch):
"""Google function_call parts become OpenAI-compatible tool call chunks."""
function_call = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
part = SimpleNamespace(
function_call=function_call, text=None, thought_signature=b"sig"
)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(part)]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
messages = [Message(role=Role.USER, content="weather")]
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
messages,
model="gemini-2.5-flash",
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
]
tool_call = result[0].tool_calls[0]
assert tool_call["index"] == 0
assert tool_call["id"].startswith("google_")
assert tool_call["type"] == "function"
assert tool_call["function"] == {
"name": "get_weather",
"arguments": '{"city": "Berlin"}',
}
assert tool_call["thought_signature"] == b"sig"
assert engine._thought_sigs[tool_call["id"]] == b"sig"
assert result[-1].finish_reason == "tool_calls"
config = client.models.generate_content_stream.call_args.kwargs["config"]
assert config.tools == [
{
"function_declarations": [
{
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
}
]
}
]
@pytest.mark.asyncio
async def test_stream_full_google_preserves_mixed_and_multiple_calls(
monkeypatch: pytest.MonkeyPatch,
):
"""Google streams retain mixed text and multiple tool calls."""
weather = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
calendar = SimpleNamespace(name="get_calendar", args={"day": "Monday"})
text_part = SimpleNamespace(text="I'll check.", function_call=None)
weather_part = SimpleNamespace(
function_call=weather, text=None, thought_signature=None
)
calendar_part = SimpleNamespace(
function_call=calendar, text=None, thought_signature=None
)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[
_google_stream_chunk(text_part, weather_part),
_google_stream_chunk(calendar_part),
]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="plan")], model="gemini-2.5-flash"
)
]
assert result[0].content == "I'll check."
weather_call = result[1].tool_calls[0]
calendar_call = result[2].tool_calls[0]
assert weather_call["index"] == 0
assert weather_call["function"] == {
"name": "get_weather",
"arguments": '{"city": "Berlin"}',
}
assert calendar_call["index"] == 1
assert calendar_call["function"] == {
"name": "get_calendar",
"arguments": '{"day": "Monday"}',
}
assert weather_call["id"] != calendar_call["id"]
assert result[-1].finish_reason == "tool_calls"
@pytest.mark.asyncio
async def test_stream_full_google_keeps_parallel_same_name_calls_distinct(
monkeypatch: pytest.MonkeyPatch,
):
"""Parallel invocations of one function receive unique indexes and IDs."""
paris = SimpleNamespace(name="get_weather", args={"city": "Paris"})
london = SimpleNamespace(name="get_weather", args={"city": "London"})
parts = [
SimpleNamespace(function_call=paris, text=None, thought_signature=b"sig"),
SimpleNamespace(function_call=london, text=None, thought_signature=None),
]
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(*parts)]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in Paris and London")],
model="gemini-3-flash-preview",
)
]
calls = result[0].tool_calls
assert [call["index"] for call in calls] == [0, 1]
assert calls[0]["id"] != calls[1]["id"]
assert [call["function"]["arguments"] for call in calls] == [
'{"city": "Paris"}',
'{"city": "London"}',
]
@pytest.mark.asyncio
async def test_stream_full_google_ids_are_unique_across_requests(
monkeypatch: pytest.MonkeyPatch,
):
"""Shared engines keep signatures isolated between conversations."""
first_part = SimpleNamespace(
function_call=SimpleNamespace(name="get_weather", args={"city": "Paris"}),
text=None,
thought_signature=b"paris-sig",
)
second_part = SimpleNamespace(
function_call=SimpleNamespace(name="get_weather", args={"city": "London"}),
text=None,
thought_signature=b"london-sig",
)
client = MagicMock()
client.models.generate_content_stream.side_effect = [
iter([_google_stream_chunk(first_part)]),
iter([_google_stream_chunk(second_part)]),
]
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
first = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in Paris")],
model="gemini-3-flash-preview",
)
]
second = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in London")],
model="gemini-3-flash-preview",
)
]
first_id = first[0].tool_calls[0]["id"]
second_id = second[0].tool_calls[0]["id"]
assert first_id != second_id
assert engine._thought_sigs[first_id] == b"paris-sig"
assert engine._thought_sigs[second_id] == b"london-sig"
@pytest.mark.asyncio
async def test_stream_full_google_emits_final_usage(monkeypatch: pytest.MonkeyPatch):
"""Google's final usage metadata is normalized onto the terminal chunk."""
usage = SimpleNamespace(prompt_token_count=12, candidates_token_count=5)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[
_google_stream_chunk(text="Hello"),
_google_stream_chunk(usage_metadata=usage),
]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="hi")],
model="gemini-2.5-flash",
)
]
assert result[-1].finish_reason == "stop"
assert result[-1].usage == {
"prompt_tokens": 12,
"completion_tokens": 5,
"total_tokens": 17,
}
@pytest.mark.asyncio
async def test_stream_full_google_replays_signature_on_part(
monkeypatch: pytest.MonkeyPatch,
):
"""A saved Gemini signature is replayed beside, not inside, function_call."""
client = MagicMock()
client.models.generate_content_stream.return_value = iter([])
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {"google_get_weather_0": b"sig"}
messages = [
Message(role=Role.USER, content="weather"),
Message(
role=Role.ASSISTANT,
content=None,
tool_calls=[
ToolCall(
id="google_get_weather_0",
name="get_weather",
arguments='{"city": "Berlin"}',
)
],
),
Message(role=Role.TOOL, name="get_weather", content='{"temp": 20}'),
]
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
messages, model="gemini-3-flash-preview"
)
]
contents = client.models.generate_content_stream.call_args.kwargs["contents"]
assert contents[1]["parts"] == [
{
"function_call": {
"name": "get_weather",
"args": {"city": "Berlin"},
},
"thought_signature": b"sig",
}
]
assert result[-1].finish_reason == "stop"
# ---------------------------------------------------------------------------
# stream_full routing tests
# ---------------------------------------------------------------------------
+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")