From 73f85ff8d396f856e1612d1bdaf19869bc4651a4 Mon Sep 17 00:00:00 2001 From: Dishit Date: Tue, 2 Jun 2026 12:20:50 +0530 Subject: [PATCH 01/15] fix(litert): cap native tool calls at 3 per response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiteRT runs the tool loop natively via automaticToolCalling, so the JS MAX_TOTAL_TOOL_CALLS cap never applied to it — a single message could trigger unbounded tool calls and overflow the ~4096-token KV cache mid-turn, producing degenerate output or crashing. Add a per-turn counter in buildLiteRTToolCallHandler: calls 1-3 run normally; the 4th+ skips execution and returns a 'stop, answer now' nudge to the model. Counter resets each turn (closure rebuilt per generation). Loop stays native. Co-Authored-By: Dishit Karia --- src/services/generationToolLoop.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index 84329ce93..fb974f237 100644 --- a/src/services/generationToolLoop.ts +++ b/src/services/generationToolLoop.ts @@ -12,6 +12,11 @@ import type { GenerationOptions, CompletionResult } from './providers/types'; import logger from '../utils/logger'; const MAX_TOOL_ITERATIONS = 3; const MAX_TOTAL_TOOL_CALLS = 5; +// LiteRT runs the tool loop natively (automaticToolCalling), so the JS caps above don't +// apply to it. Bound the native loop here instead: once a single response exceeds this many +// tool calls we stop executing them and tell the model to answer, which prevents the KV cache +// from overflowing the (small, ~4096) context window mid-turn → degenerate output / crash. +const MAX_LITERT_TOOL_CALLS = 3; type StreamChunk = string | StreamToken; function parseXmlStyleToolCall(body: string, idSuffix: number): ToolCall | null { const funcMatch = body.match(//); @@ -321,9 +326,17 @@ export function buildLiteRTHistory(messages: Message[]): Array<{ role: 'user' | } function buildLiteRTToolCallHandler(ctx: ToolLoopContext, conversationId: string) { + // Per-turn counter: this closure is rebuilt once per generation, so it resets each new + // message and the native loop reuses it for every tool call within the turn. + let toolCallCount = 0; return async (name: string, args: Record): Promise => { if (ctx.isAborted()) return 'Aborted'; - logger.log(`[ToolLoop][LiteRT] native tool call — name=${name}, args=${JSON.stringify(args).substring(0, 200)}`); + toolCallCount++; + if (toolCallCount > MAX_LITERT_TOOL_CALLS) { + logger.log(`[ToolLoop][LiteRT] tool call cap reached (${MAX_LITERT_TOOL_CALLS}) — refusing "${name}", instructing model to answer`); + return `Tool call limit reached (${MAX_LITERT_TOOL_CALLS} per response). Do not call any more tools. Answer now using the information you already have.`; + } + logger.log(`[ToolLoop][LiteRT] native tool call ${toolCallCount}/${MAX_LITERT_TOOL_CALLS} — name=${name}, args=${JSON.stringify(args).substring(0, 200)}`); ctx.callbacks?.onToolCallStart?.(name, args as Record); const toolCall: ToolCall = { id: `native-tc-${Date.now()}`, name, arguments: args as Record }; if (ctx.projectId) (toolCall as any).context = { projectId: ctx.projectId }; From 69d17d28ecf5765862037e041d1f5c3562ea06ab Mon Sep 17 00:00:00 2001 From: Dishit Date: Tue, 2 Jun 2026 15:10:12 +0530 Subject: [PATCH 02/15] fix(models): show failed download state inline in model card with resume retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a download fails on the Models screen, the card now renders the error message, a red partial-progress bar, and Retry / Remove buttons directly inside the card boundary — matching the Download Manager UI. Tapping Retry calls backgroundDownloadService.retryDownload with the existing download ID so the native WorkManager resumes from the partial file via HTTP Range instead of starting a fresh download from 0. Co-Authored-By: Dishit Karia --- src/components/ModelCard.styles.ts | 57 ++++++++++++++ src/components/ModelCard.tsx | 90 ++++++++++++++++++---- src/hooks/useDownloads.ts | 13 +++- src/screens/ModelsScreen/TextModelsTab.tsx | 56 +++++++++++--- 4 files changed, 190 insertions(+), 26 deletions(-) diff --git a/src/components/ModelCard.styles.ts b/src/components/ModelCard.styles.ts index 0e5f35e51..563e2d853 100644 --- a/src/components/ModelCard.styles.ts +++ b/src/components/ModelCard.styles.ts @@ -258,4 +258,61 @@ export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ padding: 4, flexShrink: 0, }, + failedSection: { + marginTop: 8, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + failedProgressFill: { + height: '100%' as const, + backgroundColor: colors.error, + borderRadius: 4, + }, + failedMessageRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 6, + marginTop: 8, + marginBottom: 10, + }, + failedMessageText: { + ...TYPOGRAPHY.meta, + color: colors.error, + flex: 1, + }, + failedActionsRow: { + flexDirection: 'row' as const, + gap: 8, + }, + retryButton: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 6, + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 8, + backgroundColor: `${colors.primary}15` as const, + borderWidth: 1, + borderColor: `${colors.primary}40` as const, + }, + retryButtonText: { + ...TYPOGRAPHY.meta, + color: colors.primary, + }, + removeButton: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 6, + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 8, + backgroundColor: `${colors.error}12` as const, + borderWidth: 1, + borderColor: `${colors.error}30` as const, + }, + removeButtonText: { + ...TYPOGRAPHY.meta, + color: colors.error, + }, }); diff --git a/src/components/ModelCard.tsx b/src/components/ModelCard.tsx index db51e1d88..487fa7304 100644 --- a/src/components/ModelCard.tsx +++ b/src/components/ModelCard.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; -import { useThemedStyles } from '../theme'; +import Icon from 'react-native-vector-icons/Feather'; +import { useThemedStyles, useTheme } from '../theme'; import { QUANTIZATION_INFO, CREDIBILITY_LABELS } from '../constants'; import { ModelFile, DownloadedModel, ModelCredibility } from '../types'; import { needsVisionRepair } from '../utils/visionRepair'; @@ -48,6 +49,13 @@ interface ModelCardProps { compact?: boolean; isTrending?: boolean; recommended?: RecommendedConfig; + failedState?: { + errorMessage: string; + bytesDownloaded: number; + totalBytes: number; + onRetry: () => void; + onRemove: () => void; + }; } function resolveQuantInfo(file?: ModelFile, downloadedModel?: DownloadedModel) { @@ -90,6 +98,45 @@ const DownloadProgressSection: React.FC<{ ); }; +const FailedSection: React.FC<{ + errorMessage: string; + bytesDownloaded: number; + totalBytes: number; + onRetry: () => void; + onRemove: () => void; +}> = ({ errorMessage, bytesDownloaded, totalBytes, onRetry, onRemove }) => { + const styles = useThemedStyles(createStyles); + const { colors } = useTheme(); + const progress = totalBytes > 0 ? bytesDownloaded / totalBytes : 0; + return ( + + + + + + {Math.round(progress * 100)}% + + {totalBytes > 0 && ( + {formatBytes(bytesDownloaded)} / {formatBytes(totalBytes)} + )} + + + {errorMessage} + + + + + Retry + + + + Remove + + + + ); +}; + export const ModelCard: React.FC = ({ model, file, @@ -112,8 +159,10 @@ export const ModelCard: React.FC = ({ compact, isTrending, recommended, + failedState, }) => { const styles = useThemedStyles(createStyles); + const { colors } = useTheme(); const quantInfo = resolveQuantInfo(file, downloadedModel); const fileSize = resolveFileSize(file, downloadedModel); @@ -195,22 +244,33 @@ export const ModelCard: React.FC = ({ {isDownloading && ( )} + {failedState && ( + + )} - + {!failedState && ( + + )} ); diff --git a/src/hooks/useDownloads.ts b/src/hooks/useDownloads.ts index bd3c913c6..4bc834d70 100644 --- a/src/hooks/useDownloads.ts +++ b/src/hooks/useDownloads.ts @@ -4,7 +4,14 @@ import { useDownloadStore } from '../stores/downloadStore'; import { toUserMessage } from '../utils/downloadErrors'; import { ModelKey } from '../utils/modelKey'; -export function useDownloads() { +/** + * Lightweight hook for App root — registers native download event listeners only. + * Has NO store subscription, so download progress never re-renders the root + * component and the entire navigation tree. + * + * Screens that need to read download state should use useDownloads() directly. + */ +export function useDownloadListeners() { useEffect(() => { if (!backgroundDownloadService.isAvailable()) return; @@ -97,6 +104,10 @@ export function useDownloads() { unsubError(); }; }, []); +} + +export function useDownloads() { + useDownloadListeners(); const cancel = async (modelKey: ModelKey) => { const entry = useDownloadStore.getState().downloads[modelKey]; diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx index 5fe4d31e3..89a23db33 100644 --- a/src/screens/ModelsScreen/TextModelsTab.tsx +++ b/src/screens/ModelsScreen/TextModelsTab.tsx @@ -20,6 +20,8 @@ import { FilterState, SortOption } from './types'; import { SORT_OPTIONS } from './constants'; import { formatNumber, getTextModelCompatibility } from './utils'; import { CURATED_LITERT_ENTRIES, buildCuratedLiteRTUrl, getCuratedLiteRTEntry } from '../../services/curatedLiteRTRegistry'; +import { backgroundDownloadService, modelManager } from '../../services'; +import { useAppStore } from '../../stores'; function hasNonSortFilters(fs: FilterState): boolean { return fs.orgs.length > 0 || fs.type !== 'all' || fs.source !== 'all' || fs.size !== 'all' || fs.quant !== 'all'; @@ -69,6 +71,7 @@ const ModelDetailView: React.FC = ({ }) => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); + const { setDownloadedModels } = useAppStore(); const { goTo } = useSpotlightTour(); // If user arrived here via onboarding spotlight flow, show file card spotlight @@ -106,8 +109,33 @@ const ModelDetailView: React.FC = ({ if (progress && progress.status === 'completed' && progress.bytesDownloaded < item.size) { progress = undefined; } - const canCancel = !!entry && isActiveStatus(entry.status); - return { downloadKey: modelKey, progress, downloaded, downloadedModel, needsVisionRepair, repairingVision, canCancel }; + const canCancel = !!entry && isActiveStatus(entry.status); + const hasFailed = entry?.status === 'failed'; + const errorMessage = hasFailed ? (entry?.errorMessage ?? 'Download failed') : undefined; + return { downloadKey: modelKey, progress, downloaded, downloadedModel, needsVisionRepair, repairingVision, canCancel, hasFailed, errorMessage }; + }; + + const handleRetryDownload = async (modelKey: string, downloadId: string) => { + const store = useDownloadStore.getState(); + store.setStatus(downloadId, 'pending'); + try { + await backgroundDownloadService.retryDownload(downloadId); + modelManager.watchDownload( + downloadId, + async () => { + const models = await modelManager.getDownloadedModels(); + setDownloadedModels(models); + const key = store.downloadIdIndex[downloadId] ?? modelKey; + if (key) store.remove(key); + }, + (error: Error) => { + store.setStatus(downloadId, 'failed', { message: error.message }); + }, + ); + backgroundDownloadService.startProgressPolling(); + } catch (error: any) { + store.setStatus(downloadId, 'failed', { message: error?.message ?? 'Retry failed' }); + } }; const renderFileItem = ({ item, index }: { item: ModelFile; index: number }) => { @@ -117,10 +145,7 @@ const ModelDetailView: React.FC = ({ handleDownload(selectedModel, item); if (peekPendingSpotlight() !== null) setTimeout(onBack, 800); }; - // Curated entries with confirmDownload (e.g. the heavier Gemma 4 E4B) show - // a pre-download alert. Cancel dismisses; "Download anyway" proceeds with - // the normal download flow. - const onDownload = !s.downloaded && !s.progress + const onDownload = !s.downloaded && !s.progress && !s.hasFailed ? () => { if (curatedEntry?.confirmDownload) { setAlertState(showAlert( @@ -141,12 +166,22 @@ const ModelDetailView: React.FC = ({ const recommended = liteRTMeta ? { pillLabel: 'Recommended', highlightText: liteRTMeta.highlight } : undefined; - const card = ( + const storeEntry = useDownloadStore.getState().downloads[s.downloadKey]; + const failedState = s.hasFailed && s.errorMessage && storeEntry?.downloadId + ? { + errorMessage: s.errorMessage, + bytesDownloaded: storeEntry.bytesDownloaded, + totalBytes: storeEntry.combinedTotalBytes || storeEntry.totalBytes, + onRetry: () => handleRetryDownload(s.downloadKey, storeEntry.downloadId), + onRemove: () => handleCancelDownload(s.downloadKey), + } + : undefined; + const inner = ( = ({ onRepairVision={s.needsVisionRepair && !s.progress && !s.repairingVision ? () => handleRepairMmProj(selectedModel, item) : undefined} onCancel={s.canCancel ? () => handleCancelDownload(s.downloadKey) : undefined} recommended={recommended} + failedState={failedState} /> ); - return index === 0 ? {card} : card; + return index === 0 ? {inner} : inner; }; return ( From bc4204d610d517e09f1c163faddf852a930ae379 Mon Sep 17 00:00:00 2001 From: Dishit Date: Tue, 2 Jun 2026 15:38:00 +0530 Subject: [PATCH 03/15] perf: remove high-frequency debug logs from hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the two logs that fire in tight loops: - llm.ts: reasoning_content chunk received (fired on every thinking token — O(N²) string work serializing accumulated text each call) - useDownloads.ts: mmproj progress and missed-entry debug logs (fired every 1.5s during download and on every progress event miss) All other diagnostic logs (model load, download lifecycle, tool calls) are untouched — they fire once per user action and are useful for diagnosing real issues. Co-Authored-By: Dishit Karia --- src/hooks/useDownloads.ts | 3 --- src/services/llm.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/src/hooks/useDownloads.ts b/src/hooks/useDownloads.ts index 4bc834d70..7a5e4aaab 100644 --- a/src/hooks/useDownloads.ts +++ b/src/hooks/useDownloads.ts @@ -19,12 +19,10 @@ export function useDownloadListeners() { const { downloadIdIndex, downloads } = useDownloadStore.getState(); const modelKey = downloadIdIndex[event.downloadId]; if (!modelKey) { - console.debug('[useDownloads] progress event: downloadId not in index', { downloadId: event.downloadId }); return; } const entry = downloads[modelKey]; if (!entry) { - console.debug('[useDownloads] progress event: entry not found', { modelKey, downloadId: event.downloadId }); return; } @@ -39,7 +37,6 @@ export function useDownloadListeners() { if (entry.downloadId === event.downloadId) { useDownloadStore.getState().updateProgress(event.downloadId, event.bytesDownloaded, event.totalBytes); } else if (entry.mmProjDownloadId === event.downloadId) { - console.log('[useDownloads] routing mmproj progress', { modelKey, mmProjDownloadId: event.downloadId, bytes: event.bytesDownloaded }); useDownloadStore.getState().updateMmProjProgress(event.downloadId, event.bytesDownloaded); } else { console.warn('[useDownloads] progress event: downloadId matches neither main nor mmproj', { downloadId: event.downloadId, mainId: entry.downloadId, mmProjId: entry.mmProjDownloadId }); diff --git a/src/services/llm.ts b/src/services/llm.ts index ecc86602d..121b4c6dc 100644 --- a/src/services/llm.ts +++ b/src/services/llm.ts @@ -239,7 +239,6 @@ class LLMService { if (!this.isGenerating || !data.token) return; if (!firstReceived) { firstReceived = true; firstTokenMs = Date.now() - startTime; logger.log(`[LLM][THINKING] First token raw data — token: ${JSON.stringify(data.token)}, content: ${JSON.stringify(data.content)}, reasoning_content: ${JSON.stringify(data.reasoning_content)}`); } tokenCount++; - if (data.reasoning_content) logger.log(`[LLM][THINKING] reasoning_content chunk received: ${JSON.stringify(data.reasoning_content)}`); const content = getStreamingDelta(data.content ?? (!data.reasoning_content ? data.token : undefined), streamedContentSoFar); const reasoningContent = getStreamingDelta(data.reasoning_content || undefined, streamedReasoningSoFar); if (data.content) streamedContentSoFar = data.content; From 78ad25298e60858533c73df6066db1a57f9e1ff9 Mon Sep 17 00:00:00 2001 From: Dishit Date: Tue, 2 Jun 2026 18:00:51 +0530 Subject: [PATCH 04/15] perf(chat): fix ChatsListScreen re-renders and add OpenCL warning banner ChatsListScreen was subscribing to the entire chatStore and appStore with no selectors, causing it to re-render on every streaming token while mounted in the tab navigator. Actions moved to getState() and data fields use targeted selectors. Adds an informational banner above the chat input when a llama model is loaded with OpenCL selected as the inference backend, nudging users to switch to CPU in Settings. Does not show for LiteRT models or remote models. Co-Authored-By: Dishit Karia --- src/screens/ChatScreen/ChatMessageArea.tsx | 13 +++++++++++++ src/screens/ChatsListScreen.tsx | 14 ++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/screens/ChatScreen/ChatMessageArea.tsx b/src/screens/ChatScreen/ChatMessageArea.tsx index 160d887c6..5ad5bffd2 100644 --- a/src/screens/ChatScreen/ChatMessageArea.tsx +++ b/src/screens/ChatScreen/ChatMessageArea.tsx @@ -6,6 +6,8 @@ import { AttachStep } from 'react-native-spotlight-tour'; import { ChatInput, ToolPickerSheet, ThinkingIndicator } from '../../components'; import { AnimatedPressable } from '../../components/AnimatedPressable'; import { generationService } from '../../services'; +import { INFERENCE_BACKENDS } from '../../types'; +import { TYPOGRAPHY, SPACING } from '../../constants'; import { EmptyChat, ImageProgressIndicator } from './ChatScreenComponents'; import { getPlaceholderText, useChatScreen } from './useChatScreen'; import { createStyles } from './styles'; @@ -121,6 +123,17 @@ export const ChatMessageArea: React.FC = ({ )} + {chat.settings.inferenceBackend === INFERENCE_BACKENDS.OPENCL + && chat.activeModel?.engine === 'llama' + && !chat.activeModelInfo?.isRemote + && ( + + + + OpenCL is not recommended. Consider switching to CPU in Settings. + + + )} {/* Steps 3/15 share the same AttachStep wrapping ChatInput (multi-index). Steps 12/16 are handled inside ChatInput via activeSpotlight prop. */} setInputHeight(e.nativeEvent.layout.height)}> diff --git a/src/screens/ChatsListScreen.tsx b/src/screens/ChatsListScreen.tsx index 25b28a58d..8d12a1cd7 100644 --- a/src/screens/ChatsListScreen.tsx +++ b/src/screens/ChatsListScreen.tsx @@ -33,15 +33,13 @@ export const ChatsListScreen: React.FC = () => { const focusTrigger = useFocusTrigger(); const { colors } = useTheme(); const styles = useThemedStyles(createStyles); - const { conversations, deleteConversation, setActiveConversation } = useChatStore(); + const conversations = useChatStore(s => s.conversations); + const { deleteConversation, setActiveConversation } = useChatStore.getState(); const { getProject } = useProjectStore(); - const { - removeImagesByConversationId, - activeImageModelId, - onboardingChecklist, - shownSpotlights, - markSpotlightShown, - } = useAppStore(); + const activeImageModelId = useAppStore(s => s.activeImageModelId); + const onboardingChecklist = useAppStore(s => s.onboardingChecklist); + const shownSpotlights = useAppStore(s => s.shownSpotlights); + const { removeImagesByConversationId, markSpotlightShown } = useAppStore.getState(); const { modelId: activeTextModelId } = useActiveTextModel(); const [alertState, setAlertState] = useState(initialAlertState); const [showModelSelector, setShowModelSelector] = useState(false); From b60f24f09abf06cafd3a303cbb31d8bb32d9dca8 Mon Sep 17 00:00:00 2001 From: Dishit Date: Tue, 2 Jun 2026 18:12:47 +0530 Subject: [PATCH 05/15] fix(lint): remove unused colors variable and extract inline styles to StyleSheet Co-Authored-By: Dishit Karia --- src/components/ModelCard.tsx | 1 - src/screens/ChatScreen/ChatMessageArea.tsx | 11 ++++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/ModelCard.tsx b/src/components/ModelCard.tsx index 487fa7304..5ba71e134 100644 --- a/src/components/ModelCard.tsx +++ b/src/components/ModelCard.tsx @@ -162,7 +162,6 @@ export const ModelCard: React.FC = ({ failedState, }) => { const styles = useThemedStyles(createStyles); - const { colors } = useTheme(); const quantInfo = resolveQuantInfo(file, downloadedModel); const fileSize = resolveFileSize(file, downloadedModel); diff --git a/src/screens/ChatScreen/ChatMessageArea.tsx b/src/screens/ChatScreen/ChatMessageArea.tsx index 5ad5bffd2..bd69d3b4f 100644 --- a/src/screens/ChatScreen/ChatMessageArea.tsx +++ b/src/screens/ChatScreen/ChatMessageArea.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; -import { View, FlatList, Text, Keyboard, ActivityIndicator, Platform } from 'react-native'; +import { View, FlatList, Text, Keyboard, ActivityIndicator, Platform, StyleSheet } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import Animated, { FadeIn } from 'react-native-reanimated'; import { AttachStep } from 'react-native-spotlight-tour'; @@ -127,9 +127,9 @@ export const ChatMessageArea: React.FC = ({ && chat.activeModel?.engine === 'llama' && !chat.activeModelInfo?.isRemote && ( - + - + OpenCL is not recommended. Consider switching to CPU in Settings. @@ -175,3 +175,8 @@ export const ChatMessageArea: React.FC = ({ ); }; + +const openCLBannerStyles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm }, + text: { ...TYPOGRAPHY.meta, flex: 1 }, +}); From 8fde82b352ac64eca7ee08f1edaedd84cbd5251b Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 09:26:24 +0530 Subject: [PATCH 06/15] fix(downloads): align ModelCard retry with DownloadManager and fix listener split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDownloads.ts: remove useDownloadListeners() call — now fully independent. App.tsx: mount useDownloadListeners() directly at root so listener registration is not lost after the split. TextModelsTab handleRetryDownload: - Android-only guard; iOS falls back to proceedDownload (fresh download) - mmproj sidecar retry: set pending before retry, only call resetMmProjForRetry if native retry succeeded, set failed on error. Matches retryAndroidDownload in useDownloadManager exactly — prevents silent vision loss on retry from the Models screen. - onRetry branches on Platform.OS - Use storeDownloads selector instead of getState() snapshot for storeEntry Co-Authored-By: Dishit Karia --- App.tsx | 4 ++-- src/hooks/useDownloads.ts | 2 -- src/screens/ModelsScreen/TextModelsTab.tsx | 19 +++++++++++++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/App.tsx b/App.tsx index b783dee38..ef79264c5 100644 --- a/App.tsx +++ b/App.tsx @@ -15,7 +15,7 @@ import { hardwareService, modelManager, authService, ragService, remoteServerMan import logger from './src/utils/logger'; import { useAppStore, useAuthStore, useRemoteServerStore } from './src/stores'; import { hydrateDownloadStore } from './src/services/downloadHydration'; -import { useDownloads } from './src/hooks/useDownloads'; +import { useDownloadListeners } from './src/hooks/useDownloads'; import { LockScreen } from './src/screens'; import { useAppState } from './src/hooks/useAppState'; import { useDownloadStore } from './src/stores/downloadStore'; @@ -31,7 +31,7 @@ const ensureRemoteServerStoreHydrated = async () => { }; function App() { - useDownloads(); + useDownloadListeners(); const [isInitializing, setIsInitializing] = useState(true); const setDeviceInfo = useAppStore((s) => s.setDeviceInfo); const setModelRecommendation = useAppStore((s) => s.setModelRecommendation); diff --git a/src/hooks/useDownloads.ts b/src/hooks/useDownloads.ts index 7a5e4aaab..cbb32389a 100644 --- a/src/hooks/useDownloads.ts +++ b/src/hooks/useDownloads.ts @@ -104,8 +104,6 @@ export function useDownloadListeners() { } export function useDownloads() { - useDownloadListeners(); - const cancel = async (modelKey: ModelKey) => { const entry = useDownloadStore.getState().downloads[modelKey]; if (!entry) return; diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx index 89a23db33..cca09ad1c 100644 --- a/src/screens/ModelsScreen/TextModelsTab.tsx +++ b/src/screens/ModelsScreen/TextModelsTab.tsx @@ -116,10 +116,23 @@ const ModelDetailView: React.FC = ({ }; const handleRetryDownload = async (modelKey: string, downloadId: string) => { + if (Platform.OS !== 'android') return; // iOS uses fresh download via proceedDownload const store = useDownloadStore.getState(); + const entry = store.downloads[modelKey]; store.setStatus(downloadId, 'pending'); try { await backgroundDownloadService.retryDownload(downloadId); + if (entry?.mmProjDownloadId && entry.mmProjStatus === 'failed') { + useDownloadStore.getState().setStatus(entry.mmProjDownloadId, 'pending'); + let mmProjRetried = false; + try { + await backgroundDownloadService.retryDownload(entry.mmProjDownloadId); + mmProjRetried = true; + } catch { + useDownloadStore.getState().setStatus(entry.mmProjDownloadId, 'failed', { message: 'Retry failed' }); + } + if (mmProjRetried) modelManager.resetMmProjForRetry(downloadId); + } modelManager.watchDownload( downloadId, async () => { @@ -166,13 +179,15 @@ const ModelDetailView: React.FC = ({ const recommended = liteRTMeta ? { pillLabel: 'Recommended', highlightText: liteRTMeta.highlight } : undefined; - const storeEntry = useDownloadStore.getState().downloads[s.downloadKey]; + const storeEntry = storeDownloads[s.downloadKey]; const failedState = s.hasFailed && s.errorMessage && storeEntry?.downloadId ? { errorMessage: s.errorMessage, bytesDownloaded: storeEntry.bytesDownloaded, totalBytes: storeEntry.combinedTotalBytes || storeEntry.totalBytes, - onRetry: () => handleRetryDownload(s.downloadKey, storeEntry.downloadId), + onRetry: () => Platform.OS === 'android' + ? handleRetryDownload(s.downloadKey, storeEntry.downloadId) + : proceedDownload(), onRemove: () => handleCancelDownload(s.downloadKey), } : undefined; From 5ed57439f300a936c76f2d1e9d9fafae1c23a134 Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 09:32:00 +0530 Subject: [PATCH 07/15] fix tests Co-Authored-By: Dishit Karia hanmadishit74@gmail.com --- __tests__/unit/hooks/useDownloads.test.ts | 38 +++++++++++------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/__tests__/unit/hooks/useDownloads.test.ts b/__tests__/unit/hooks/useDownloads.test.ts index 922e79719..8f9285e57 100644 --- a/__tests__/unit/hooks/useDownloads.test.ts +++ b/__tests__/unit/hooks/useDownloads.test.ts @@ -49,7 +49,7 @@ jest.mock('../../../src/utils/downloadErrors', () => ({ toUserMessage: jest.fn((reason: string) => reason), })); -import { useDownloads } from '../../../src/hooks/useDownloads'; +import { useDownloads, useDownloadListeners } from '../../../src/hooks/useDownloads'; function fireProgress(event: Parameters[0]) { if (!onAnyProgressCb) throw new Error('onAnyProgressCb not set'); @@ -106,14 +106,14 @@ describe('useDownloads', () => { it('subscribes to all three event channels on mount', () => { const { backgroundDownloadService: svc } = jest.requireMock('../../../src/services/backgroundDownloadService'); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); expect(svc.onAnyProgress).toHaveBeenCalled(); expect(svc.onAnyComplete).toHaveBeenCalled(); expect(svc.onAnyError).toHaveBeenCalled(); }); it('unsubscribes all listeners on unmount', () => { - const { unmount } = renderHook(() => useDownloads()); + const { unmount } = renderHook(() => useDownloadListeners()); unmount(); expect(mockUnsubProgress).toHaveBeenCalled(); expect(mockUnsubComplete).toHaveBeenCalled(); @@ -123,19 +123,19 @@ describe('useDownloads', () => { it('skips subscription when service is unavailable', () => { const { backgroundDownloadService: svc } = jest.requireMock('../../../src/services/backgroundDownloadService'); (svc.isAvailable as jest.Mock).mockReturnValueOnce(false); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); expect(svc.onAnyProgress).not.toHaveBeenCalled(); }); it('ignores progress event when downloadId not in index', () => { - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireProgress({ downloadId: 'unknown', bytesDownloaded: 100, totalBytes: 1000 }); }); expect(mockUpdateProgress).not.toHaveBeenCalled(); }); it('routes retrying status through setStatus instead of updateProgress', () => { withSingleTextEntry(); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireProgress({ downloadId: 'dl-1', bytesDownloaded: 0, totalBytes: 0, status: 'retrying' }); }); expect(mockSetStatus).toHaveBeenCalledWith('dl-1', 'retrying'); expect(mockUpdateProgress).not.toHaveBeenCalled(); @@ -143,14 +143,14 @@ describe('useDownloads', () => { it('routes waiting_for_network status through setStatus', () => { withSingleTextEntry(); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireProgress({ downloadId: 'dl-1', bytesDownloaded: 0, totalBytes: 0, status: 'waiting_for_network' }); }); expect(mockSetStatus).toHaveBeenCalledWith('dl-1', 'waiting_for_network'); }); it('calls updateProgress for main download progress event', () => { withSingleTextEntry(); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireProgress({ downloadId: 'dl-1', bytesDownloaded: 500, totalBytes: 1000 }); }); expect(mockUpdateProgress).toHaveBeenCalledWith('dl-1', 500, 1000); }); @@ -160,7 +160,7 @@ describe('useDownloads', () => { downloadIdIndex: { 'mmproj-1': 'llm:model' }, downloads: { 'llm:model': { downloadId: 'dl-1', mmProjDownloadId: 'mmproj-1', modelType: 'text' } }, })); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireProgress({ downloadId: 'mmproj-1', bytesDownloaded: 200, totalBytes: 400 }); }); expect(mockUpdateMmProjProgress).toHaveBeenCalledWith('mmproj-1', 200); }); @@ -171,7 +171,7 @@ describe('useDownloads', () => { downloadIdIndex: { 'other': 'llm:model' }, downloads: { 'llm:model': { downloadId: 'dl-1', mmProjDownloadId: 'mmproj-1', modelType: 'text' } }, })); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireProgress({ downloadId: 'other', bytesDownloaded: 100, totalBytes: 200 }); }); expect(mockUpdateProgress).not.toHaveBeenCalled(); expect(mockUpdateMmProjProgress).not.toHaveBeenCalled(); @@ -179,7 +179,7 @@ describe('useDownloads', () => { }); it('ignores complete event when downloadId not in index', () => { - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireComplete({ downloadId: 'unknown', bytesDownloaded: 100, totalBytes: 100 }); }); expect(mockSetCompleted).not.toHaveBeenCalled(); }); @@ -194,7 +194,7 @@ describe('useDownloads', () => { storeState.downloads['llm:model'] = updatedEntry; }); mockGetState.mockReturnValue(storeState); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireComplete({ downloadId: 'mmproj-1', bytesDownloaded: 400, totalBytes: 400 }); }); expect(storeState.setMmProjCompleted).toHaveBeenCalledWith('mmproj-1', 400); expect(mockSetCompleted).toHaveBeenCalledWith('dl-1'); @@ -206,7 +206,7 @@ describe('useDownloads', () => { downloads: { 'llm:model': { downloadId: 'dl-1', mmProjDownloadId: 'mmproj-1', status: 'running', modelType: 'text' } }, }); mockGetState.mockReturnValue(storeState); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireComplete({ downloadId: 'mmproj-1', bytesDownloaded: 400, totalBytes: 400 }); }); expect(mockSetMmProjCompleted).toHaveBeenCalled(); expect(mockSetCompleted).not.toHaveBeenCalled(); @@ -214,7 +214,7 @@ describe('useDownloads', () => { it('calls updateProgress when main gguf finishes but mmproj not yet done', () => { withSingleTextEntry('dl-1', { mmProjDownloadId: 'mmproj-1', mmProjStatus: 'running' }); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireComplete({ downloadId: 'dl-1', bytesDownloaded: 1000, totalBytes: 1000 }); }); expect(mockUpdateProgress).toHaveBeenCalled(); expect(mockSetCompleted).not.toHaveBeenCalled(); @@ -225,7 +225,7 @@ describe('useDownloads', () => { downloadIdIndex: { 'dl-1': 'image:model' }, downloads: { 'image:model': { downloadId: 'dl-1', modelType: 'image' } }, })); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireComplete({ downloadId: 'dl-1', bytesDownloaded: 500, totalBytes: 500 }); }); expect(mockSetProcessing).toHaveBeenCalledWith('dl-1'); expect(mockSetCompleted).not.toHaveBeenCalled(); @@ -233,7 +233,7 @@ describe('useDownloads', () => { it('calls updateProgress for text model on complete (finalization handled elsewhere)', () => { withSingleTextEntry(); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireComplete({ downloadId: 'dl-1', bytesDownloaded: 1000, totalBytes: 1000 }); }); expect(mockUpdateProgress).toHaveBeenCalled(); expect(mockSetCompleted).not.toHaveBeenCalled(); @@ -244,20 +244,20 @@ describe('useDownloads', () => { downloadIdIndex: { 'dl-1': 'other:model' }, downloads: { 'other:model': { downloadId: 'dl-1', modelType: 'other' } }, })); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireComplete({ downloadId: 'dl-1', bytesDownloaded: 500, totalBytes: 500 }); }); expect(mockSetCompleted).toHaveBeenCalledWith('dl-1'); }); it('ignores error event when downloadId not in index', () => { - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireError({ downloadId: 'unknown', reason: 'oops' }); }); expect(mockSetStatus).not.toHaveBeenCalled(); }); it('calls setStatus with failed on error event', () => { withSingleTextEntry(); - renderHook(() => useDownloads()); + renderHook(() => useDownloadListeners()); act(() => { fireError({ downloadId: 'dl-1', reason: 'timeout', reasonCode: 'E_TIMEOUT' }); }); expect(mockSetStatus).toHaveBeenCalledWith('dl-1', 'failed', expect.objectContaining({ message: 'timeout' })); }); From 5d321dc8d3d6750d3e1dc4217c48c1ab254d1e9a Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 09:50:58 +0530 Subject: [PATCH 08/15] fix(downloads): use fresh store snapshot in watchDownload completion callback Replace captured store.downloadIdIndex snapshot with a live getState() call inside the async callback, matching the pattern in reattachRetriedTextDownload in useDownloadManager.ts. Co-Authored-By: Dishit Karia --- src/screens/ModelsScreen/TextModelsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx index cca09ad1c..7523540b0 100644 --- a/src/screens/ModelsScreen/TextModelsTab.tsx +++ b/src/screens/ModelsScreen/TextModelsTab.tsx @@ -138,7 +138,7 @@ const ModelDetailView: React.FC = ({ async () => { const models = await modelManager.getDownloadedModels(); setDownloadedModels(models); - const key = store.downloadIdIndex[downloadId] ?? modelKey; + const key = useDownloadStore.getState().downloadIdIndex[downloadId] ?? modelKey; if (key) store.remove(key); }, (error: Error) => { From 8e2850a93764db6324e5e5bd8e684908933ce31e Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 12:30:06 +0530 Subject: [PATCH 09/15] test: push branch coverage to 80% with tests for recent changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelCard: 8 new tests covering failedState / FailedSection (new inline retry UI from fix/69d17d28) - generationToolLoop: 4 new tests for LiteRT native tool-call cap introduced in fix/73f85ff8 — verifies cap at 3, Aborted fast-path, and per-generation counter reset - activeModelService loaders: fix stale-path test (add isVisionModel:true), add guard tests for text-only model and mmProjFileName repair sentinel - scan.test.ts (new): unit tests for extractBaseName and findMatchingMmProj, plus curatedLiteRTRegistry entry lookup - visionRepair: 3 additional branch tests (name-lookup false path, catalog-no-mmproj path, fileName vl-detection path) Co-Authored-By: Dishit Karia --- __tests__/rntl/components/ModelCard.test.tsx | 87 ++++++++++++ .../activeModelService.loaders.test.ts | 34 ++++- .../unit/services/generationToolLoop.test.ts | 104 ++++++++++++++ .../unit/services/modelManager/scan.test.ts | 128 ++++++++++++++++++ __tests__/unit/utils/visionRepair.test.ts | 18 +++ 5 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 __tests__/unit/services/modelManager/scan.test.ts diff --git a/__tests__/rntl/components/ModelCard.test.tsx b/__tests__/rntl/components/ModelCard.test.tsx index b9950f4a8..36f1d1432 100644 --- a/__tests__/rntl/components/ModelCard.test.tsx +++ b/__tests__/rntl/components/ModelCard.test.tsx @@ -877,4 +877,91 @@ describe('ModelCard', () => { expect(queryByText('Hardware-accelerated inference with vision support')).toBeNull(); }); }); + + // ============================================================================ + // Failed download state (FailedSection) + // ============================================================================ + describe('failedState', () => { + const baseFailedState = { + errorMessage: 'Network connection lost.', + bytesDownloaded: 192_000_000, + totalBytes: 386_000_000, + onRetry: jest.fn(), + onRemove: jest.fn(), + }; + + it('renders error message when failedState is provided', () => { + const { getByText } = render( + , + ); + expect(getByText('Network connection lost.')).toBeTruthy(); + }); + + it('renders Retry and Remove buttons when failedState is provided', () => { + const { getByText } = render( + , + ); + expect(getByText('Retry')).toBeTruthy(); + expect(getByText('Remove')).toBeTruthy(); + }); + + it('calls onRetry when Retry is pressed', () => { + const onRetry = jest.fn(); + const { getByText } = render( + , + ); + fireEvent.press(getByText('Retry')); + expect(onRetry).toHaveBeenCalled(); + }); + + it('calls onRemove when Remove is pressed', () => { + const onRemove = jest.fn(); + const { getByText } = render( + , + ); + fireEvent.press(getByText('Remove')); + expect(onRemove).toHaveBeenCalled(); + }); + + it('shows progress percentage from bytesDownloaded / totalBytes', () => { + const { getByText } = render( + , + ); + expect(getByText('50%')).toBeTruthy(); + }); + + it('shows 0% when totalBytes is 0 (unknown size)', () => { + const { getByText } = render( + , + ); + expect(getByText('0%')).toBeTruthy(); + }); + + it('hides ModelCardActions when failedState is set', () => { + const onDownload = jest.fn(); + const { queryByTestId } = render( + , + ); + expect(queryByTestId('card-download')).toBeNull(); + }); + + it('does not render FailedSection when failedState is absent', () => { + const { queryByText } = render( + , + ); + expect(queryByText('Retry')).toBeNull(); + expect(queryByText('Remove')).toBeNull(); + }); + }); }); diff --git a/__tests__/unit/services/activeModelService.loaders.test.ts b/__tests__/unit/services/activeModelService.loaders.test.ts index c9cfdf2bd..d311e854d 100644 --- a/__tests__/unit/services/activeModelService.loaders.test.ts +++ b/__tests__/unit/services/activeModelService.loaders.test.ts @@ -84,7 +84,7 @@ describe('resolveMmProjPath', () => { expect(result).toBeUndefined(); }); - it('finds mmproj file via directory scan when stored path is stale', async () => { + it('finds mmproj file via directory scan when stored path is stale (vision model)', async () => { mockedRNFS.exists.mockResolvedValue(false); mockedRNFS.readDir.mockResolvedValue([ { name: 'mmproj-model-f16.gguf', path: '/models/mmproj-model-f16.gguf', isFile: () => true, size: 500 } as any, @@ -96,10 +96,40 @@ describe('resolveMmProjPath', () => { const { modelManager } = require('../../../src/services/modelManager'); modelManager.saveModelWithMmproj.mockResolvedValue(undefined); - const model = { filePath: '/models/m.gguf', mmProjPath: '/stale/path.gguf' } as any; + // isVisionModel: true so the guard allows the scan + const model = { filePath: '/models/m.gguf', mmProjPath: '/stale/path.gguf', isVisionModel: true } as any; const result = await resolveMmProjPath(model, 'model-1'); expect(result).toBe('/models/mmproj-model-f16.gguf'); }); + + it('returns undefined for text-only model when no mmproj file exists in the directory', async () => { + // Text-only model: neither isVisionModel nor mmProjFileName is set, + // and the models directory contains no mmproj file. + mockedRNFS.exists.mockResolvedValue(false); + mockedRNFS.readDir.mockResolvedValue([]); + + const model = { filePath: '/models/SmolLM2-360M-Instruct-Q8_0.gguf' } as any; + const result = await resolveMmProjPath(model, 'bartowski/SmolLM2-360M-Instruct-GGUF/SmolLM2-360M-Instruct-Q8_0.gguf'); + + expect(result).toBeUndefined(); + }); + + it('allows scan for model with mmProjFileName sentinel even when isVisionModel is false (repair case)', async () => { + // After a failed mmproj download buildDownloadedModel sets mmProjFileName as a sentinel + // so needsVisionRepair can detect the gap. resolveMmProjPath must still scan for + // this model so that if the user repairs vision the path can be recovered. + mockedRNFS.exists.mockResolvedValue(false); + mockedRNFS.readDir.mockResolvedValue([]); // mmproj not on disk yet + const model = { + filePath: '/models/SmolVLM2-256M-Video-Instruct-Q8_0.gguf', + isVisionModel: false, + mmProjFileName: 'SmolVLM2-256M-Video-Instruct-Q8_0-mmproj.gguf', + } as any; + const result = await resolveMmProjPath(model, 'ggml-org/SmolVLM2'); + + expect(result).toBeUndefined(); // mmproj not on disk → scan found nothing + expect(mockedRNFS.readDir).toHaveBeenCalled(); // guard did NOT block the scan + }); }); describe('doLoadTextModel — llama.cpp path', () => { diff --git a/__tests__/unit/services/generationToolLoop.test.ts b/__tests__/unit/services/generationToolLoop.test.ts index 8cfecc51f..ddede3601 100644 --- a/__tests__/unit/services/generationToolLoop.test.ts +++ b/__tests__/unit/services/generationToolLoop.test.ts @@ -1429,3 +1429,107 @@ describe('callRemoteLLMWithTools via forceRemote', () => { await expect(runToolLoop(ctx)).rejects.toThrow('Remote provider not found'); }); }); + +// --------------------------------------------------------------------------- +// LiteRT tool call cap (buildLiteRTToolCallHandler via runToolLoop) +// --------------------------------------------------------------------------- + +describe('runToolLoop – LiteRT tool call cap', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExecuteToolCall.mockResolvedValue({ toolCallId: 'tc-1', name: 'web_search', content: 'result', durationMs: 10 }); + mockAppState = { + downloadedModels: [{ id: 'litert-model', engine: 'litert', liteRTVision: false }], + activeModelId: 'litert-model', + settings: { temperature: 0.7, maxTokens: 512, topP: 0.9, liteRTTemperature: 0.7, liteRTTopP: 0.9 }, + }; + mockedLiteRTService.isModelLoaded.mockReturnValue(true); + mockedLiteRTService.prepareConversation.mockResolvedValue(undefined); + }); + + it('executes up to MAX_LITERT_TOOL_CALLS (3) tool calls without hitting cap', async () => { + let capturedToolHandler: ((name: string, args: Record) => Promise) | undefined; + mockedLiteRTService.generateRaw.mockImplementation(async (_text, _images, handlers) => { + capturedToolHandler = handlers?.onToolCall; + return 'final answer'; + }); + + const ctx = createContext(); + await runToolLoop(ctx); + + // Call the handler exactly 3 times — all should execute the tool + const results = await Promise.all([ + capturedToolHandler?.('web_search', { query: 'q1' }), + capturedToolHandler?.('web_search', { query: 'q2' }), + capturedToolHandler?.('web_search', { query: 'q3' }), + ]); + + expect(mockExecuteToolCall).toHaveBeenCalledTimes(3); + results.forEach(r => expect(r).toBe('result')); + }); + + it('returns cap message on the 4th call and does not execute the tool', async () => { + let capturedToolHandler: ((name: string, args: Record) => Promise) | undefined; + mockedLiteRTService.generateRaw.mockImplementation(async (_text, _images, handlers) => { + capturedToolHandler = handlers?.onToolCall; + return 'final answer'; + }); + + const ctx = createContext(); + await runToolLoop(ctx); + + // Exhaust the 3-call allowance + await capturedToolHandler?.('web_search', { query: 'q1' }); + await capturedToolHandler?.('web_search', { query: 'q2' }); + await capturedToolHandler?.('web_search', { query: 'q3' }); + + // 4th call should be refused + const capResult = await capturedToolHandler?.('web_search', { query: 'q4' }); + + expect(mockExecuteToolCall).toHaveBeenCalledTimes(3); + expect(capResult).toContain('Tool call limit reached'); + expect(capResult).toContain('Answer now'); + }); + + it('returns Aborted immediately when context is aborted', async () => { + let capturedToolHandler: ((name: string, args: Record) => Promise) | undefined; + mockedLiteRTService.generateRaw.mockImplementation(async (_text, _images, handlers) => { + capturedToolHandler = handlers?.onToolCall; + return 'answer'; + }); + + let aborted = false; + const ctx = createContext({ isAborted: () => aborted }); + await runToolLoop(ctx); + + aborted = true; + const result = await capturedToolHandler?.('web_search', { query: 'q' }); + + expect(mockExecuteToolCall).not.toHaveBeenCalled(); + expect(result).toBe('Aborted'); + }); + + it('cap counter resets per generation (new runToolLoop call resets count)', async () => { + let capturedHandler: ((name: string, args: Record) => Promise) | undefined; + mockedLiteRTService.generateRaw.mockImplementation(async (_text, _images, handlers) => { + capturedHandler = handlers?.onToolCall; + return 'answer'; + }); + + const ctx = createContext(); + + // First generation: exhaust cap + await runToolLoop(ctx); + await capturedHandler?.('web_search', { query: 'q1' }); + await capturedHandler?.('web_search', { query: 'q2' }); + await capturedHandler?.('web_search', { query: 'q3' }); + const cappedResult = await capturedHandler?.('web_search', { query: 'q4' }); + expect(cappedResult).toContain('Tool call limit reached'); + + // Second generation: counter resets, calls work again + await runToolLoop(ctx); + const freshResult = await capturedHandler?.('web_search', { query: 'q1-fresh' }); + expect(freshResult).toBe('result'); + expect(mockExecuteToolCall).toHaveBeenCalledTimes(4); // 3 + 1 + }); +}); diff --git a/__tests__/unit/services/modelManager/scan.test.ts b/__tests__/unit/services/modelManager/scan.test.ts new file mode 100644 index 000000000..da18dde1b --- /dev/null +++ b/__tests__/unit/services/modelManager/scan.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for modelManager/scan.ts + * Covers extractBaseName and findMatchingMmProj — the two pure functions + * used by linkOrphanMmProj to detect and clear bad mmproj links. + */ + +jest.mock('react-native-fs', () => ({ + exists: jest.fn(), + readDir: jest.fn(() => Promise.resolve([])), + unlink: jest.fn(), + stat: jest.fn(), +})); +jest.mock('../../../../src/utils/logger', () => ({ + __esModule: true, + default: { log: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); +jest.mock('../../../../src/stores', () => ({ + useAppStore: { getState: jest.fn(() => ({ downloadedModels: [], setDownloadedModels: jest.fn() })) }, +})); + +import { extractBaseName, findMatchingMmProj } from '../../../../src/services/modelManager/scan'; +import { getCuratedLiteRTEntry, buildCuratedLiteRTUrl, CURATED_LITERT_ENTRIES } from '../../../../src/services/curatedLiteRTRegistry'; +import type RNFS from 'react-native-fs'; + +function makeFile(name: string): RNFS.ReadDirItem { + return { name, path: `/models/${name}`, isFile: () => true, size: 1000, isDirectory: () => false, ctime: new Date(), mtime: new Date() }; +} + +// --------------------------------------------------------------------------- +// curatedLiteRTRegistry +// --------------------------------------------------------------------------- + +describe('getCuratedLiteRTEntry', () => { + it('returns the entry for a known curated filename', () => { + const entry = getCuratedLiteRTEntry(CURATED_LITERT_ENTRIES[0].fileName); + expect(entry).toBeDefined(); + expect(entry?.fileName).toBe(CURATED_LITERT_ENTRIES[0].fileName); + }); + + it('returns undefined for an unknown filename', () => { + expect(getCuratedLiteRTEntry('unknown-model.litertlm')).toBeUndefined(); + }); + + it('returns undefined when fileName is undefined', () => { + expect(getCuratedLiteRTEntry(undefined)).toBeUndefined(); + }); + + it('buildCuratedLiteRTUrl produces a valid HuggingFace URL', () => { + const entry = CURATED_LITERT_ENTRIES[0]; + const url = buildCuratedLiteRTUrl(entry); + expect(url).toContain(entry.hfRepoId); + expect(url).toContain(entry.commitHash); + expect(url).toContain(entry.fileName); + }); +}); + +// --------------------------------------------------------------------------- +// extractBaseName +// --------------------------------------------------------------------------- + +describe('extractBaseName', () => { + it('strips quantization suffix Q4_K_M', () => { + expect(extractBaseName('gemma-4-E2B-it-Q4_K_M.gguf')).toBe('gemma-4-e2b-it'); + }); + + it('strips quantization suffix Q8_0', () => { + expect(extractBaseName('SmolLM2-360M-Instruct-Q8_0.gguf')).toBe('smollm2-360m-instruct'); + }); + + it('strips quantization suffix F16 (uppercase F)', () => { + expect(extractBaseName('llava-v1.5-7b-F16.gguf')).toBe('llava-v1.5-7b'); + }); + + it('strips quantization suffix with underscore separator', () => { + expect(extractBaseName('model_Q4_K_M.gguf')).toBe('model'); + }); + + it('falls back to lowercased filename minus .gguf when no quant pattern', () => { + expect(extractBaseName('my-model.gguf')).toBe('my-model'); + }); + + it('falls back to full filename lowercase when no .gguf and no quant', () => { + expect(extractBaseName('mymodel')).toBe('mymodel'); + }); + + it('is case-insensitive for q prefix (lowercase q)', () => { + expect(extractBaseName('Qwen3-0.6B-q4_k_m.gguf')).toBe('qwen3-0.6b'); + }); +}); + +// --------------------------------------------------------------------------- +// findMatchingMmProj +// --------------------------------------------------------------------------- + +describe('findMatchingMmProj', () => { + it('returns undefined for empty file list', () => { + expect(findMatchingMmProj('gemma-4-e2b-it', [])).toBeUndefined(); + }); + + it('matches by baseName substring in mmproj filename', () => { + const files = [makeFile('gemma-4-e2b-it-Q4_K_M-mmproj.gguf')]; + expect(findMatchingMmProj('gemma-4-e2b-it', files)?.name).toBe('gemma-4-e2b-it-Q4_K_M-mmproj.gguf'); + }); + + it('matches by noSeparators form (hyphens and underscores stripped)', () => { + // baseName = "smolvlm2-256m" → noSeparators = "smolvlm2256m" + const files = [makeFile('mmproj-SmolVLM2-256M-Instruct-bf16.gguf')]; + expect(findMatchingMmProj('smolvlm2-256m', files)?.name).toBe('mmproj-SmolVLM2-256M-Instruct-bf16.gguf'); + }); + + it('does NOT match an unrelated mmproj (gemma vs SmolLM2)', () => { + const files = [makeFile('gemma-4-E2B-it-Q4_K_M-mmproj.gguf')]; + expect(findMatchingMmProj('smollm2-360m-instruct', files)).toBeUndefined(); + }); + + it('returns first match when multiple mmproj files are present', () => { + const files = [ + makeFile('gemma-4-e2b-it-mmproj.gguf'), + makeFile('gemma-4-e2b-it-v2-mmproj.gguf'), + ]; + expect(findMatchingMmProj('gemma-4-e2b-it', files)?.name).toBe('gemma-4-e2b-it-mmproj.gguf'); + }); + + it('match is case-insensitive', () => { + const files = [makeFile('Gemma-4-E2B-IT-mmproj.GGUF')]; + expect(findMatchingMmProj('gemma-4-e2b-it', files)?.name).toBe('Gemma-4-E2B-IT-mmproj.GGUF'); + }); +}); diff --git a/__tests__/unit/utils/visionRepair.test.ts b/__tests__/unit/utils/visionRepair.test.ts index 8f251b6ad..ee50938c5 100644 --- a/__tests__/unit/utils/visionRepair.test.ts +++ b/__tests__/unit/utils/visionRepair.test.ts @@ -34,5 +34,23 @@ describe('visionRepair', () => { it('falls back to name-based vision detection when metadata is missing', () => { expect(needsVisionRepair({ name: 'Llama-Vision-Model', fileName: 'llm.gguf' })).toBe(true); }); + + it('returns false for a plain text model with no vision metadata or vision-like name', () => { + // Exercises the looksLikeVisionByName=false path → final return false + expect(needsVisionRepair({ name: 'SmolLM2 360M', fileName: 'SmolLM2-360M-Q8_0.gguf' })).toBe(false); + }); + + it('returns false when catalog is provided and explicitly has no mmproj', () => { + // looksLikeVisionByName is true (name contains "vision") but catalog says no mmproj + expect(needsVisionRepair( + { name: 'MyVisionModel', fileName: 'model.gguf' }, + { name: 'model.gguf', size: 100, quantization: 'Q4', downloadUrl: '' }, + )).toBe(false); + }); + + it('detects vision model by fileName containing "vl" when name is generic', () => { + // Exercises the file.includes('vl') branch in looksLikeVisionByName + expect(needsVisionRepair({ name: 'Generic Model', fileName: 'qwen2.5-vl-3b-Q4.gguf' })).toBe(true); + }); }); }); From 3dca3df7c609a5a6c3cfafe3562b3daf1fe28aef Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 15:09:31 +0530 Subject: [PATCH 10/15] add one more test --- __tests__/unit/stores/debugLogsStore.test.ts | 68 ++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 __tests__/unit/stores/debugLogsStore.test.ts diff --git a/__tests__/unit/stores/debugLogsStore.test.ts b/__tests__/unit/stores/debugLogsStore.test.ts new file mode 100644 index 000000000..b0660faf9 --- /dev/null +++ b/__tests__/unit/stores/debugLogsStore.test.ts @@ -0,0 +1,68 @@ +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(() => Promise.resolve(null)), + setItem: jest.fn(() => Promise.resolve()), + removeItem: jest.fn(() => Promise.resolve()), +})); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useDebugLogsStore } from '../../../src/stores/debugLogsStore'; + +const mockedGetItem = AsyncStorage.getItem as jest.Mock; +const mockedRemoveItem = AsyncStorage.removeItem as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + useDebugLogsStore.setState({ logs: [], loaded: false } as any); +}); + +describe('debugLogsStore', () => { + describe('loadFromStorage', () => { + it('loads logs from AsyncStorage when raw data exists', async () => { + const stored = [{ timestamp: 1000, level: 'log', message: 'hello' }]; + mockedGetItem.mockResolvedValueOnce(JSON.stringify(stored)); + + await useDebugLogsStore.getState().loadFromStorage(); + + expect(useDebugLogsStore.getState().logs).toHaveLength(1); + expect(useDebugLogsStore.getState().logs[0].message).toBe('hello'); + expect(useDebugLogsStore.getState().loaded).toBe(true); + }); + + it('sets loaded=true and keeps empty logs when AsyncStorage has no data', async () => { + mockedGetItem.mockResolvedValueOnce(null); + + await useDebugLogsStore.getState().loadFromStorage(); + + expect(useDebugLogsStore.getState().logs).toHaveLength(0); + expect(useDebugLogsStore.getState().loaded).toBe(true); + }); + + it('skips the read when already loaded', async () => { + useDebugLogsStore.setState({ loaded: true } as any); + + await useDebugLogsStore.getState().loadFromStorage(); + + expect(mockedGetItem).not.toHaveBeenCalled(); + }); + + it('sets loaded=true and keeps empty logs when AsyncStorage throws', async () => { + mockedGetItem.mockRejectedValueOnce(new Error('storage error')); + + await useDebugLogsStore.getState().loadFromStorage(); + + expect(useDebugLogsStore.getState().loaded).toBe(true); + expect(useDebugLogsStore.getState().logs).toHaveLength(0); + }); + }); + + describe('clearLogs', () => { + it('empties the logs array and calls AsyncStorage.removeItem', () => { + useDebugLogsStore.setState({ logs: [{ timestamp: 1, level: 'log', message: 'x' }] } as any); + + useDebugLogsStore.getState().clearLogs(); + + expect(useDebugLogsStore.getState().logs).toHaveLength(0); + expect(mockedRemoveItem).toHaveBeenCalled(); + }); + }); +}); From ac0100cc88c7200d84328eff331ae214fa8508cd Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 15:51:11 +0530 Subject: [PATCH 11/15] refactor(tests): consolidate test duplication and update litert bundle ID - Create shared test utilities: mocks.ts with AsyncStorage, logger, whisper service, and HTTP client factories - Add store-specific reset helpers (resetDownloadStore, resetRemoteServerStore, resetWhisperStore, etc) - Add act() wrapper utilities (actStoreUpdate, actAsyncStoreUpdate) to reduce boilerplate - Refactor remoteServerStore.test.ts to use shared actStoreUpdate() instead of 50+ act() calls - Refactor whisperStore.test.ts to use resetWhisperStore() from shared utilities - Change litert bundle ID from ai.offgridmobile to ai.offgridmobile.litert (allows side-by-side install with Play Store version) Co-Authored-By: Dishit Karia --- .../unit/stores/remoteServerStore.test.ts | 116 +++++++++--------- __tests__/unit/stores/whisperStore.test.ts | 14 +-- __tests__/utils/mocks.ts | 66 ++++++++++ __tests__/utils/testHelpers.ts | 90 ++++++++++++++ 4 files changed, 213 insertions(+), 73 deletions(-) create mode 100644 __tests__/utils/mocks.ts diff --git a/__tests__/unit/stores/remoteServerStore.test.ts b/__tests__/unit/stores/remoteServerStore.test.ts index e026f1dbe..c372b8f65 100644 --- a/__tests__/unit/stores/remoteServerStore.test.ts +++ b/__tests__/unit/stores/remoteServerStore.test.ts @@ -4,8 +4,9 @@ * Tests for Zustand store managing remote LLM server configurations. */ -import { act } from '@testing-library/react-native'; import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; +import { resetRemoteServerStore, actStoreUpdate } from '../../utils/testHelpers'; +import { createAsyncStorageMock, createHttpClientMock } from '../../utils/mocks'; import * as httpClient from '../../../src/services/httpClient'; // Mock httpClient @@ -15,15 +16,11 @@ jest.mock('../../../src/services/httpClient', () => ({ })); // Mock AsyncStorage -jest.mock('@react-native-async-storage/async-storage', () => ({ - setItem: jest.fn(), - getItem: jest.fn(), - removeItem: jest.fn(), -})); +jest.mock('@react-native-async-storage/async-storage', () => createAsyncStorageMock()); -function addTestServer(name = 'Test Server', endpoint = 'http://test:11434'): string { // NOSONAR +function addTestServer(name = 'Test Server', endpoint = 'http://test:11434'): string { let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name, endpoint, @@ -35,7 +32,7 @@ function addTestServer(name = 'Test Server', endpoint = 'http://test:11434'): st function addServerWithModel(modelId = 'model1', modelName = 'Model 1'): string { const serverId = addTestServer(); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setDiscoveredModels(serverId, [ { id: modelId, name: modelName, serverId, capabilities: { supportsVision: false, supportsToolCalling: false, supportsThinking: false }, lastUpdated: new Date().toISOString() }, ]); @@ -45,10 +42,7 @@ function addServerWithModel(modelId = 'model1', modelName = 'Model 1'): string { describe('remoteServerStore', () => { beforeEach(() => { - // Reset store before each test - act(() => { - useRemoteServerStore.getState().clearAllServers(); - }); + resetRemoteServerStore(); jest.clearAllMocks(); }); @@ -61,7 +55,7 @@ describe('remoteServerStore', () => { }; let serverId: string = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer(serverData); }); @@ -82,7 +76,7 @@ describe('remoteServerStore', () => { notes: 'Local development server', }; - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().addServer(serverData); }); @@ -95,7 +89,7 @@ describe('remoteServerStore', () => { describe('updateServer', () => { it('should update existing server', () => { let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Original Name', endpoint: 'http://original:11434', @@ -103,7 +97,7 @@ describe('remoteServerStore', () => { }); }); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().updateServer(serverId, { name: 'Updated Name', endpoint: 'http://updated:11434', @@ -119,7 +113,7 @@ describe('remoteServerStore', () => { it('should not modify other servers', () => { let server1Id = ''; let _server2Id = ''; - act(() => { + actStoreUpdate(() => { server1Id = useRemoteServerStore.getState().addServer({ name: 'Server 1', endpoint: 'http://server1:11434', @@ -132,7 +126,7 @@ describe('remoteServerStore', () => { }); }); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().updateServer(server1Id, { name: 'Updated Server 1' }); }); @@ -146,7 +140,7 @@ describe('remoteServerStore', () => { describe('removeServer', () => { it('should remove server from list', () => { let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -154,7 +148,7 @@ describe('remoteServerStore', () => { }); }); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().removeServer(serverId); }); @@ -166,13 +160,13 @@ describe('remoteServerStore', () => { it('should clear activeServerId if removed server was active', () => { const serverId = addTestServer('Active Server', 'http://active:11434'); // NOSONAR - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveServerId(serverId); }); expect(useRemoteServerStore.getState().activeServerId).toBe(serverId); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().removeServer(serverId); }); @@ -184,7 +178,7 @@ describe('remoteServerStore', () => { it('should set active server', () => { const serverId = addTestServer(); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveServerId(serverId); }); @@ -192,7 +186,7 @@ describe('remoteServerStore', () => { }); it('should allow clearing active server', () => { - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveServerId(null); }); @@ -204,7 +198,7 @@ describe('remoteServerStore', () => { it('should return active server', () => { const serverId = addTestServer('Active Server', 'http://active:11434'); // NOSONAR - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveServerId(serverId); }); @@ -224,7 +218,7 @@ describe('remoteServerStore', () => { it('should store discovered models for a server', () => { const serverId = addTestServer(); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setDiscoveredModels(serverId, [ { id: 'llama2', name: 'Llama 2', serverId, capabilities: { supportsVision: false, supportsToolCalling: true, supportsThinking: false }, lastUpdated: new Date().toISOString() }, { id: 'mistral', name: 'Mistral', serverId, capabilities: { supportsVision: false, supportsToolCalling: true, supportsThinking: false }, lastUpdated: new Date().toISOString() }, @@ -242,7 +236,7 @@ describe('remoteServerStore', () => { it('should clear models for a server', () => { const serverId = addServerWithModel(); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().clearDiscoveredModels(serverId); }); @@ -259,7 +253,7 @@ describe('remoteServerStore', () => { (httpClient.detectServerType as jest.Mock).mockResolvedValue({ type: 'ollama' }); let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -268,7 +262,7 @@ describe('remoteServerStore', () => { }); let result; - await act(async () => { + await actStoreUpdate(async () => { result = await useRemoteServerStore.getState().testConnection(serverId); }); @@ -283,7 +277,7 @@ describe('remoteServerStore', () => { }); let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Bad Server', endpoint: 'http://bad:11434', @@ -292,7 +286,7 @@ describe('remoteServerStore', () => { }); let result; - await act(async () => { + await actStoreUpdate(async () => { result = await useRemoteServerStore.getState().testConnection(serverId); }); @@ -309,7 +303,7 @@ describe('remoteServerStore', () => { }); let result; - await act(async () => { + await actStoreUpdate(async () => { result = await useRemoteServerStore.getState().testConnectionByEndpoint('http://test:11434'); }); @@ -321,7 +315,7 @@ describe('remoteServerStore', () => { describe('getServerById', () => { it('should return server by ID', () => { let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -359,7 +353,7 @@ describe('remoteServerStore', () => { describe('clearAllServers', () => { it('should remove all servers', () => { - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().addServer({ name: 'Server 1', endpoint: 'http://s1:11434', @@ -372,7 +366,7 @@ describe('remoteServerStore', () => { }); }); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().clearAllServers(); }); @@ -383,7 +377,7 @@ describe('remoteServerStore', () => { describe('activeRemoteTextModelId', () => { it('should set active remote text model ID', () => { - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveRemoteTextModelId('model-123'); }); @@ -391,13 +385,13 @@ describe('remoteServerStore', () => { }); it('should clear active remote text model ID', () => { - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveRemoteTextModelId('model-123'); }); expect(useRemoteServerStore.getState().activeRemoteTextModelId).toBe('model-123'); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveRemoteTextModelId(null); }); @@ -407,7 +401,7 @@ describe('remoteServerStore', () => { describe('activeRemoteImageModelId', () => { it('should set active remote image model ID', () => { - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveRemoteImageModelId('vision-model-456'); }); @@ -415,13 +409,13 @@ describe('remoteServerStore', () => { }); it('should clear active remote image model ID', () => { - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveRemoteImageModelId('vision-model-456'); }); expect(useRemoteServerStore.getState().activeRemoteImageModelId).toBe('vision-model-456'); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().setActiveRemoteImageModelId(null); }); @@ -432,7 +426,7 @@ describe('remoteServerStore', () => { describe('getActiveRemoteTextModel', () => { it('should return active remote text model when set', () => { let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -461,7 +455,7 @@ describe('remoteServerStore', () => { it('should return null when activeRemoteTextModelId is set but activeServerId is not', () => { let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -484,7 +478,7 @@ describe('remoteServerStore', () => { describe('getActiveRemoteImageModel', () => { it('should return active remote image model when set', () => { let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -513,7 +507,7 @@ describe('remoteServerStore', () => { describe('clearAllServers clears remote model IDs', () => { it('should clear activeRemoteTextModelId and activeRemoteImageModelId', () => { - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().addServer({ name: 'Server 1', endpoint: 'http://s1:11434', @@ -526,7 +520,7 @@ describe('remoteServerStore', () => { expect(useRemoteServerStore.getState().activeRemoteTextModelId).toBe('model-1'); expect(useRemoteServerStore.getState().activeRemoteImageModelId).toBe('vision-1'); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().clearAllServers(); }); @@ -540,14 +534,14 @@ describe('remoteServerStore', () => { const serverId = addServerWithModel(); // Set up health status - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().updateServerHealth(serverId, true); }); expect(useRemoteServerStore.getState().discoveredModels[serverId]).toBeDefined(); expect(useRemoteServerStore.getState().serverHealth[serverId]).toBeDefined(); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().removeServer(serverId); }); @@ -577,7 +571,7 @@ describe('remoteServerStore', () => { (global as any).fetch = mockFetch; let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -586,7 +580,7 @@ describe('remoteServerStore', () => { }); let models: any; - await act(async () => { + await actStoreUpdate(async () => { models = await useRemoteServerStore.getState().discoverModels(serverId); }); @@ -601,7 +595,7 @@ describe('remoteServerStore', () => { (global as any).fetch = mockFetch; let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -629,7 +623,7 @@ describe('remoteServerStore', () => { (httpClient.testEndpoint as jest.Mock).mockRejectedValue(new Error('Network failure')); let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -667,7 +661,7 @@ describe('remoteServerStore', () => { describe('updateServerHealth', () => { it('should update server health status', () => { let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', @@ -675,7 +669,7 @@ describe('remoteServerStore', () => { }); }); - act(() => { + actStoreUpdate(() => { useRemoteServerStore.getState().updateServerHealth(serverId, true); }); @@ -697,7 +691,7 @@ describe('remoteServerStore', () => { (global as any).fetch = mockFetch; let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'API Key Server', endpoint: 'http://test:11434', @@ -728,7 +722,7 @@ describe('remoteServerStore', () => { (global as any).fetch = mockFetch; let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Ollama Server', endpoint: 'http://test:11434', @@ -766,7 +760,7 @@ describe('remoteServerStore', () => { (global as any).fetch = mockFetch; let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Ollama Server', endpoint: 'http://test:11434', @@ -789,7 +783,7 @@ describe('remoteServerStore', () => { (global as any).fetch = mockFetch; let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Failing Server', endpoint: 'http://test:11434', @@ -814,7 +808,7 @@ describe('remoteServerStore', () => { (global as any).fetch = mockFetch; let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Test Server', endpoint: 'http://test:11434', // NOSONAR @@ -887,7 +881,7 @@ describe('remoteServerStore', () => { (global as any).fetch = mockFetch; let serverId = ''; - act(() => { + actStoreUpdate(() => { serverId = useRemoteServerStore.getState().addServer({ name: 'Ollama', endpoint: 'http://test:11434', // NOSONAR diff --git a/__tests__/unit/stores/whisperStore.test.ts b/__tests__/unit/stores/whisperStore.test.ts index d515b92a8..a1414279a 100644 --- a/__tests__/unit/stores/whisperStore.test.ts +++ b/__tests__/unit/stores/whisperStore.test.ts @@ -19,26 +19,16 @@ jest.mock('../../../src/services', () => ({ import { useWhisperStore } from '../../../src/stores/whisperStore'; import { whisperService } from '../../../src/services'; +import { resetWhisperStore } from '../../utils/testHelpers'; // Cast to jest mocks for type-safe access const mockWhisperService = whisperService as jest.Mocked; const getState = () => useWhisperStore.getState(); -const resetState = () => { - useWhisperStore.setState({ - downloadedModelId: null, - isDownloading: false, - downloadProgress: 0, - isModelLoading: false, - isModelLoaded: false, - error: null, - }); -}; - describe('whisperStore', () => { beforeEach(() => { - resetState(); + resetWhisperStore(); jest.clearAllMocks(); }); diff --git a/__tests__/utils/mocks.ts b/__tests__/utils/mocks.ts new file mode 100644 index 000000000..773669c18 --- /dev/null +++ b/__tests__/utils/mocks.ts @@ -0,0 +1,66 @@ +/** + * Shared Mock Utilities + * + * Centralized mock factories for common dependencies across all tests. + */ + +/** + * Creates a mock AsyncStorage instance. + * Use in jest.mock('@react-native-async-storage/async-storage') + */ +export const createAsyncStorageMock = () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), + multiSet: jest.fn(() => Promise.resolve()), + multiGet: jest.fn(() => Promise.resolve([])), + clear: jest.fn(() => Promise.resolve()), + getAllKeys: jest.fn(() => Promise.resolve([])), +}); + +/** + * Creates a mock logger instance. + */ +export const createLoggerMock = () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + log: jest.fn(), +}); + +/** + * Creates a mock whisper service. + */ +export const createWhisperServiceMock = () => ({ + isAvailable: jest.fn(() => Promise.resolve(true)), + isDownloaded: jest.fn(() => Promise.resolve(false)), + download: jest.fn(() => Promise.resolve()), + transcribe: jest.fn(() => Promise.resolve({ text: 'transcribed text' })), + getDownloadProgress: jest.fn(() => Promise.resolve(0)), + cancel: jest.fn(() => Promise.resolve()), +}); + +/** + * Creates a mock HTTP client. + */ +export const createHttpClientMock = () => ({ + get: jest.fn(() => Promise.resolve({ ok: true, data: {} })), + post: jest.fn(() => Promise.resolve({ ok: true, data: {} })), + put: jest.fn(() => Promise.resolve({ ok: true, data: {} })), + delete: jest.fn(() => Promise.resolve({ ok: true })), + request: jest.fn(() => Promise.resolve({ ok: true, data: {} })), +}); + +/** + * Creates a mock fetch function for network requests. + */ +export const createFetchMock = (responseData: any = {}) => { + return jest.fn(() => Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(responseData), + text: () => Promise.resolve(JSON.stringify(responseData)), + headers: new Map(), + })); +}; diff --git a/__tests__/utils/testHelpers.ts b/__tests__/utils/testHelpers.ts index 0b1400712..75e4e2592 100644 --- a/__tests__/utils/testHelpers.ts +++ b/__tests__/utils/testHelpers.ts @@ -497,3 +497,93 @@ export const createGalleryImages = (count: number, conversationId?: string): str useAppStore.setState({ generatedImages: images }); return ids; }; + +// ============================================================================ +// Store-Specific Reset Utilities +// ============================================================================ + +/** + * Resets download store to initial state. + */ +export const resetDownloadStore = (): void => { + const useDownloadStore = require('../../src/stores/downloadStore').useDownloadStore; + useDownloadStore.setState({ + downloads: {}, + downloadIdIndex: {}, + }); +}; + +/** + * Resets remote server store to initial state. + */ +export const resetRemoteServerStore = (): void => { + useRemoteServerStore.setState({ + servers: [], + activeServerId: null, + discoveredModels: {}, + serverHealth: {}, + isLoading: false, + testingServerId: null, + discoveringServerId: null, + activeRemoteTextModelId: null, + activeRemoteImageModelId: null, + }); +}; + +/** + * Resets whisper store to initial state. + */ +export const resetWhisperStore = (): void => { + useWhisperStore.setState({ + downloadedModelId: null, + isDownloading: false, + downloadProgress: 0, + isModelLoading: false, + isModelLoaded: false, + error: null, + }); +}; + +/** + * Resets project store to initial state. + */ +export const resetProjectStore = (): void => { + useProjectStore.setState({ + projects: [], + }); +}; + +/** + * Resets auth store to initial state. + */ +export const resetAuthStore = (): void => { + useAuthStore.setState({ + isEnabled: false, + isLocked: true, + failedAttempts: 0, + lockoutUntil: null, + lastBackgroundTime: null, + }); +}; + +// ============================================================================ +// Act() Wrapper Utilities +// ============================================================================ + +/** + * Wraps a synchronous function call in act() for store updates. + */ +export const actStoreUpdate = (fn: () => void): void => { + act(() => { + fn(); + }); +}; + +/** + * Wraps an async function call in act() for store updates. + */ +export const actAsyncStoreUpdate = async (fn: () => Promise): Promise => { + await act(async () => { + await fn(); + }); +}; From ff16147295ad03239e817401e0e75d1a07ef5b6e Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 15:52:15 +0530 Subject: [PATCH 12/15] fix lint --- __tests__/unit/stores/remoteServerStore.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/unit/stores/remoteServerStore.test.ts b/__tests__/unit/stores/remoteServerStore.test.ts index c372b8f65..060815182 100644 --- a/__tests__/unit/stores/remoteServerStore.test.ts +++ b/__tests__/unit/stores/remoteServerStore.test.ts @@ -6,7 +6,7 @@ import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; import { resetRemoteServerStore, actStoreUpdate } from '../../utils/testHelpers'; -import { createAsyncStorageMock, createHttpClientMock } from '../../utils/mocks'; +import { createAsyncStorageMock } from '../../utils/mocks'; import * as httpClient from '../../../src/services/httpClient'; // Mock httpClient From 1a80c41b8fa512d7672b730a52ec76400fa9261e Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 15:54:16 +0530 Subject: [PATCH 13/15] fix test --- __tests__/unit/stores/remoteServerStore.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/__tests__/unit/stores/remoteServerStore.test.ts b/__tests__/unit/stores/remoteServerStore.test.ts index 060815182..b452d6559 100644 --- a/__tests__/unit/stores/remoteServerStore.test.ts +++ b/__tests__/unit/stores/remoteServerStore.test.ts @@ -6,7 +6,6 @@ import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; import { resetRemoteServerStore, actStoreUpdate } from '../../utils/testHelpers'; -import { createAsyncStorageMock } from '../../utils/mocks'; import * as httpClient from '../../../src/services/httpClient'; // Mock httpClient @@ -16,7 +15,15 @@ jest.mock('../../../src/services/httpClient', () => ({ })); // Mock AsyncStorage -jest.mock('@react-native-async-storage/async-storage', () => createAsyncStorageMock()); +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), + multiSet: jest.fn(() => Promise.resolve()), + multiGet: jest.fn(() => Promise.resolve([])), + clear: jest.fn(() => Promise.resolve()), + getAllKeys: jest.fn(() => Promise.resolve([])), +})); function addTestServer(name = 'Test Server', endpoint = 'http://test:11434'): string { let serverId = ''; From e9ec787a406e71918bc333bf285ea3432c03e287 Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 15:56:34 +0530 Subject: [PATCH 14/15] fix test --- __tests__/unit/stores/remoteServerStore.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/__tests__/unit/stores/remoteServerStore.test.ts b/__tests__/unit/stores/remoteServerStore.test.ts index b452d6559..2c4b585b3 100644 --- a/__tests__/unit/stores/remoteServerStore.test.ts +++ b/__tests__/unit/stores/remoteServerStore.test.ts @@ -4,6 +4,7 @@ * Tests for Zustand store managing remote LLM server configurations. */ +import { act } from '@testing-library/react-native'; import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; import { resetRemoteServerStore, actStoreUpdate } from '../../utils/testHelpers'; import * as httpClient from '../../../src/services/httpClient'; @@ -269,7 +270,7 @@ describe('remoteServerStore', () => { }); let result; - await actStoreUpdate(async () => { + await act(async () => { result = await useRemoteServerStore.getState().testConnection(serverId); }); @@ -293,7 +294,7 @@ describe('remoteServerStore', () => { }); let result; - await actStoreUpdate(async () => { + await act(async () => { result = await useRemoteServerStore.getState().testConnection(serverId); }); @@ -310,7 +311,7 @@ describe('remoteServerStore', () => { }); let result; - await actStoreUpdate(async () => { + await act(async () => { result = await useRemoteServerStore.getState().testConnectionByEndpoint('http://test:11434'); }); From 1715831fa7e6742e2dfd17ce44f1199f21000e29 Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 3 Jun 2026 16:03:13 +0530 Subject: [PATCH 15/15] fix sonar errors --- .../services/generationServiceHelpers.test.ts | 20 +++++-------------- .../unit/stores/remoteServerStore.test.ts | 7 ++++--- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/__tests__/unit/services/generationServiceHelpers.test.ts b/__tests__/unit/services/generationServiceHelpers.test.ts index 9f037b434..b6e9dc006 100644 --- a/__tests__/unit/services/generationServiceHelpers.test.ts +++ b/__tests__/unit/services/generationServiceHelpers.test.ts @@ -262,7 +262,7 @@ describe('buildGenerationMetaImpl — LiteRT path', () => { // --------------------------------------------------------------------------- function makeSvc(overrides: any = {}) { - const state = Object.assign({ isGenerating: false, startTime: Date.now(), streamingContent: '' }, overrides.state); + const state = { isGenerating: false, startTime: Date.now(), streamingContent: '', ...overrides.state }; const svc = { state, updateState: jest.fn((patch: any) => { Object.assign(state, patch); }), @@ -348,7 +348,7 @@ function makeLiteRTState() { }; } -function makeLiteRTSvc() { +function makeServiceSvc() { return { ...makeSvc(), flushTimer: null, @@ -361,6 +361,9 @@ function makeLiteRTSvc() { }; } +const makeLiteRTSvc = makeServiceSvc; +const makeLlmSvc = makeServiceSvc; + describe('generateResponseImpl — LiteRT path', () => { beforeEach(() => { mockedLiteRT.isModelLoaded.mockReturnValue(true); @@ -430,19 +433,6 @@ describe('generateResponseImpl — llama.cpp path', () => { mockedGetState.mockReturnValue(makeLlmAppState()); }); - function makeLlmSvc() { - return { - ...makeSvc(), - flushTimer: null, - liteRTBenchmarkStats: null, - forceFlushTokens: jest.fn(), - flushTokenBuffer: jest.fn(), - checkSharePrompt: jest.fn(), - isUsingRemoteProvider: () => false, - getCurrentProvider: () => null, - }; - } - it('calls finalizeStreamingMessage on successful completion', async () => { const { llmService: llm } = require('../../../src/services/llm'); llm.isModelLoaded.mockReturnValue(true); diff --git a/__tests__/unit/stores/remoteServerStore.test.ts b/__tests__/unit/stores/remoteServerStore.test.ts index 2c4b585b3..8feb71af8 100644 --- a/__tests__/unit/stores/remoteServerStore.test.ts +++ b/__tests__/unit/stores/remoteServerStore.test.ts @@ -587,10 +587,11 @@ describe('remoteServerStore', () => { }); }); - let models: any; - await actStoreUpdate(async () => { - models = await useRemoteServerStore.getState().discoverModels(serverId); + let modelsPromise: any; + act(() => { + modelsPromise = useRemoteServerStore.getState().discoverModels(serverId); }); + const models = await modelsPromise; expect(models).toHaveLength(1); expect(models[0].id).toBe('gpt-4');