diff --git a/.husky/pre-push b/.husky/pre-push
index 404fa4ccf..804c2a5c3 100755
--- a/.husky/pre-push
+++ b/.husky/pre-push
@@ -52,6 +52,12 @@ if [ -n "$PUSHED_JS" ]; then
echo "▶ Architecture gate (dependency-cruiser)..."
npm run depcruise
+
+ # knip runs in CI (the "architecture" job) as a hard 0-gate, but was NOT in this local hook, so
+ # unused-export failures only surfaced after a wasted CI round-trip (twice). Run it here too. It
+ # analyses the whole project (not just the push range), so keep it in the JS block.
+ echo "▶ Dead-code gate (knip)..."
+ npm run knip
fi
if [ -n "$PUSHED_SWIFT" ]; then
diff --git a/App.tsx b/App.tsx
index 8ccd8422c..4a21237ef 100644
--- a/App.tsx
+++ b/App.tsx
@@ -20,6 +20,7 @@ import { initDebugLogFile, appendDebugLine } from './src/utils/debugLogFile';
import { loadProFeatures } from './src/bootstrap/loadProFeatures';
import { checkProStatus } from './src/services/proLicenseService';
import { hydrateDownloadStore } from './src/services/downloadHydration';
+import { initActiveDownloadPersistence } from './src/services/activeDownloadPersistence';
import { restoreQueuedDownloads } from './src/services/restoreQueuedDownloads';
import { startLoadPolicySync } from './src/services/loadPolicySync';
import { registerCoreDownloadProviders } from './src/services/modelDownloadService/registerProviders';
@@ -153,6 +154,10 @@ function App() {
*/
const recoverDownloadState = useCallback(() => {
(async () => {
+ // Persist the in-flight download set for the rest of the session (idempotent) BEFORE the first
+ // hydrate, so a download started this run is durably recorded and can be stranded as a
+ // failed/retriable card — not vanish — if the app is hard-killed mid-transfer (iOS URLSession).
+ initActiveDownloadPersistence();
await hydrateDownloadStore().catch((error) => {
logger.error('[App] Failed to hydrate download store during startup:', error);
});
diff --git a/__tests__/integration/audio/audioBubbleSpeechTextSingleSource.redflow.test.tsx b/__tests__/integration/audio/audioBubbleSpeechTextSingleSource.redflow.test.tsx
new file mode 100644
index 000000000..f4619b5a2
--- /dev/null
+++ b/__tests__/integration/audio/audioBubbleSpeechTextSingleSource.redflow.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * RED-FLOW (integration) — the audio bubble's Play button must speak the SINGLE-SOURCE speech text.
+ *
+ * commit 592bc456 made prepareMessageForSpeech (= stripMarkdownForSpeech(stripControlTokens(content)))
+ * the ONE transform every speech caller routes through, so the spoken text can never diverge. The audio
+ * bubble's Play/re-synth handler instead hand-composed its own transform — stripMarkdownForSpeech(transcript)
+ * ONLY, skipping stripControlTokens. So a transcript that still carries control/reasoning tokens (a raw
+ * … block, a tool-call block) would be spoken aloud with those tokens, and the transform is
+ * defined twice (drift risk) instead of once.
+ *
+ * This mounts the REAL AudioMessageBubble, arrives at the Play control via a real tap, and asserts the text
+ * the bubble DISPATCHES to the TTS service equals prepareMessageForSpeech(transcript) for a markdown +
+ * control-token input — the single source of truth.
+ *
+ * RED on HEAD: the bubble dispatches stripMarkdownForSpeech(transcript), which leaves the block in
+ * → ≠ prepareMessageForSpeech(transcript).
+ */
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import { AudioMessageBubble } from '@offgrid/pro/audio/ui/AudioMessageBubble';
+import { useTTSStore } from '@offgrid/pro/audio/ttsStore';
+import { prepareMessageForSpeech } from '@offgrid/core/utils/messageContent';
+
+// The file player decodes real audio off a native module — the one genuine IO boundary. Stub the decode.
+jest.mock('@offgrid/pro/audio/audioFilePlayer', () => ({
+ decodeFileWaveform: jest.fn(async () => [] as number[]),
+}));
+
+const initialTTSState = useTTSStore.getState();
+afterEach(() => {
+ jest.clearAllMocks();
+ useTTSStore.setState(initialTTSState, true);
+});
+
+describe('audio bubble Play speaks the single-source speech text (red-flow)', () => {
+ it('dispatches prepareMessageForSpeech(transcript) — control tokens stripped, not just markdown', () => {
+ // A raw transcript with a reasoning block AND markdown — exactly the content a speech caller must
+ // never voice verbatim.
+ const transcript = 'internal reasoning the user must not hear\n## Answer\nThe **capital** is `Paris`.';
+
+ // Capture what the bubble DISPATCHES to the TTS service (the intent the View hands the store).
+ let dispatchedText: string | undefined;
+ useTTSStore.setState({
+ play: async (_messageId: string, opts: { text: string }) => { dispatchedText = opts.text; },
+ } as never);
+
+ // No audioPath → the fileless synth (re-synth) path: tapping Play calls play(id, { text: }).
+ const { getByLabelText } = render(
+ ,
+ );
+
+ fireEvent.press(getByLabelText('Play'));
+
+ // The bubble must speak the SINGLE-SOURCE output. RED on HEAD: it dispatched stripMarkdownForSpeech only,
+ // so the block is still present → ≠ prepareMessageForSpeech(transcript).
+ expect(dispatchedText).toBe(prepareMessageForSpeech(transcript));
+ expect(dispatchedText).not.toMatch(/internal reasoning/); // the reasoning block must be gone
+ });
+});
diff --git a/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts b/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts
new file mode 100644
index 000000000..24ba39cac
--- /dev/null
+++ b/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts
@@ -0,0 +1,102 @@
+/**
+ * RED-FLOW (integration) — a TTS memory refusal on the SPEAK path must surface a dismissible failure
+ * card with a "Load Anyway" affordance, never a silent dead-end.
+ *
+ * DEVICE: in voice/chat mode, tapping the speaker triggers speakMessage → initializeEngine({override:true}).
+ * The override load evicts every evictable resident to free maximum RAM; if a native unload REJECTS, the
+ * residency manager returns { fits: false } even under override. Pre-fix, ttsStore.initializeEngine threw a
+ * PLAIN Error there, caught it into `state.error` (static red text on the Settings panel only), and the
+ * speak path then bailed silently (dispatchPlayback {t:'ended'}) — the speaker icon just stopped. No alert,
+ * no card, no Load Anyway. This violates "any memory refusal on any model type offers Load Anyway".
+ *
+ * This drives the REAL store speak() → REAL ttsPlayback.speakMessage → REAL initializeEngine → REAL
+ * modelResidencyManager over the device-memory harness. Fakes ONLY at the device boundary: a fake TTS
+ * engine (idle + downloaded), and a resident text model whose native unload() REJECTS (the reason override
+ * can't free room). Assert the USER-FACING outcome: a `tts` failure lands in the real failure store, marked
+ * overridable with an onLoadAnyway (what the ModelFailureCard renders as "Load Anyway").
+ *
+ * RED on HEAD: the refusal throws a plain Error → isOverridableMemoryError === false → the failure store is
+ * empty (speak bailed silently); no card, no Load Anyway.
+ */
+import { modelResidencyManager } from '../../../src/services/modelResidency';
+import { setDeviceMemory, resetDeviceMemory } from '../../harness/deviceMemory';
+import { ttsRegistry } from '../../../pro/audio/engine';
+import { useTTSStore } from '../../../pro/audio/ttsStore';
+import { useModelFailureStore } from '../../../src/stores/modelFailureStore';
+
+// Device boundary: a minimal TTS engine that is idle + fully downloaded, and whose initialize() would
+// succeed IF residency let it. The refusal happens in residency (before initialize), so initialize is
+// never reached in the refusal case.
+function makeFakeEngine() {
+ let phase: 'idle' | 'ready' = 'idle';
+ return {
+ id: 'faketts',
+ displayName: 'Fake TTS',
+ capabilities: { peakRamMB: 400, generateAndSave: false },
+ getPhase: () => phase,
+ isSupported: () => true,
+ isFullyDownloaded: () => true,
+ getRequiredAssets: () => [{ id: 'model', sizeBytes: 400 * 1024 * 1024 }],
+ checkAssetStatus: async () => [],
+ getOverallDownloadProgress: () => 1,
+ getVoices: () => [],
+ getActiveVoice: () => null,
+ setVoice: async () => {},
+ getLastDownloadError: () => null,
+ getBridgeComponent: () => null,
+ hydrateDownloaded: () => {},
+ on: () => () => {},
+ off: () => {},
+ once: () => () => {},
+ initialize: async () => { phase = 'ready'; },
+ release: async () => { phase = 'idle'; },
+ destroy: async () => { phase = 'idle'; },
+ speak: async () => {},
+ stop: () => {},
+ pause: () => {},
+ resume: () => {},
+ setSpeed: () => {},
+ } as unknown as never;
+}
+
+describe('TTS speak-path memory refusal is overridable (Load Anyway) — red-flow', () => {
+ afterEach(() => {
+ resetDeviceMemory();
+ useModelFailureStore.getState().clear();
+ });
+
+ it('surfaces a dismissible tts failure card with Load Anyway (not a silent bail)', async () => {
+ // 12GB device. The voice model is a sidecar; the evict-everything force (override → singleModel)
+ // evicts every evictable PEER sidecar to free maximum RAM before loading.
+ setDeviceMemory({ platform: 'ios', totalGB: 12, availGB: 0.2, policy: 'balanced' });
+ useModelFailureStore.getState().clear();
+
+ // A resident peer sidecar (whisper) whose NATIVE unload REJECTS — the override force tries to evict it
+ // to free room, the native unload fails, so makeRoomFor returns { fits:false } even under override.
+ // This is the reachable device refusal on the evict-everything speak-turn force.
+ modelResidencyManager.register(
+ { key: 'whisper', type: 'whisper' as never, sizeMB: 1500, canEvict: () => true },
+ async () => { throw new Error('native unload rejected'); },
+ 1,
+ );
+
+ ttsRegistry.register('faketts', makeFakeEngine);
+ await useTTSStore.getState().setEngine('faketts');
+
+ // The user taps the speaker on an assistant message (the real store speak action).
+ await useTTSStore.getState().speak('hello there', 'msg-1');
+
+ // USER-FACING artifact: mount the REAL ModelFailureCard (no props, no mocks — it reads the real
+ // failure store the speak refusal just populated) and assert the user SEES the "Load Anyway" button
+ // on the tts failure card, not a silent bail. RED on HEAD: plain Error → no tts failure in the store
+ // → the card renders nothing → the button is absent.
+ /* eslint-disable @typescript-eslint/no-var-requires */
+ const React = require('react');
+ const { render } = require('@testing-library/react-native');
+ const { ModelFailureCard } = require('../../../src/components/ModelFailureCard');
+ /* eslint-enable @typescript-eslint/no-var-requires */
+ const view = render(React.createElement(ModelFailureCard));
+ expect(view.queryByTestId('model-failure-load-anyway-tts')).not.toBeNull();
+ expect(view.queryByTestId('model-failure-tts')).not.toBeNull(); // the tts failure card itself is on screen
+ });
+});
diff --git a/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx
new file mode 100644
index 000000000..1effa4f4b
--- /dev/null
+++ b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx
@@ -0,0 +1,81 @@
+/**
+ * DEVICE-CONFIRMED (rendered UI) — the realtime hold-to-talk dictation path must RECOVER from a memory
+ * refusal, not dead-end. Product rule: ANY memory refusal on ANY model type offers recovery — never a
+ * silent dead-end.
+ *
+ * The defect: on a tight device a heavier generation (text) model owns RAM, so the whisper sidecar's
+ * load is BLOCKED by the single-model rule (makeRoomFor → fits=false → loadModel returns 'blocked', it
+ * does NOT throw). The realtime path called whisperStore.loadModel() DIRECTLY: a 'blocked' return is not
+ * a throw, so it fell through to whisperService.startRealtimeTranscription, which throws 'No Whisper model
+ * loaded' → the mic press failed with an error, no transcript, no recovery. The Audio-mode file path
+ * already recovers via ensureWhisperForTranscription (free the generation model, retry loadWhisper); the
+ * realtime path did not.
+ *
+ * Real stack: mount the REAL ChatScreen on a NON-audio (llama) model — so chat-mode hold-to-talk takes the
+ * whisper REALTIME path (Voice.ts startWhisperRecording), not the direct-audio or file path. The text model
+ * is resident, whisper is DOWNLOADED-not-loaded, and the budget is pinned tight so the whisper sidecar
+ * cannot co-reside → the first load blocks. Only device leaves are faked (whisper, llama, RAM, fs).
+ *
+ * Arrive via the REAL hold-to-talk gesture (tapMic grant → releaseMic release), then the (faked) realtime
+ * stream delivers the utterance. The USER-FACING outcome under test: the transcript lands in the composer
+ * (dictation recovered) — proving blocked → free generation model → retry → whisper loaded → transcript.
+ *
+ * RED before the fix: the realtime path called loadModel() directly; 'blocked' fell through to
+ * startRealtimeTranscription → 'No Whisper model loaded' → error state → the composer stays EMPTY.
+ * GREEN: routed through ensureWhisperForTranscription (the same recovery the file path uses) → transcript
+ * in the input.
+ *
+ * The discriminator: on a tight device, WITHOUT the free→retry the load stays blocked → no resident
+ * whisper → startRealtimeTranscription throws → the composer never receives text.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {}, useIsFocused: () => true,
+}));
+
+describe('realtime hold-to-talk dictation recovers when whisper load is blocked (free→retry) — device', () => {
+ it('frees the generation model, loads whisper, and the transcript lands in the composer', async () => {
+ const h = await setupChatScreen({ engine: 'llama', platform: 'android', whisper: true });
+ const { useWhisperStore } = require('../../../src/stores/whisperStore');
+ const { modelResidencyManager } = require('../../../src/services/modelResidency');
+
+ // DOWNLOAD-ONLY whisper: the completed-download boundary artifact (file on disk + downloadedModelId) with
+ // NO resident load — so the realtime turn's first load attempt runs for real (and blocks on the tight budget).
+ const docs = h.boundary.fs!.DocumentDirectoryPath;
+ h.boundary.fs!.seedFile(`${docs}/whisper-models/ggml-tiny.en.bin`, 75 * 1024 * 1024);
+ await useWhisperStore.getState().refreshPresentModels();
+ useWhisperStore.setState({ downloadedModelId: 'tiny.en', isModelLoaded: false });
+
+ // Pin the budget tight: the resident text model fills it, so the whisper sidecar cannot co-reside →
+ // makeRoomFor returns fits=false → whisperStore.loadModel returns 'blocked'.
+ modelResidencyManager.setBudgetOverrideMB(700);
+
+ h.render();
+ const view = h.view!;
+
+ // Precondition (anti-false-green): the composer is empty.
+ const inputBefore = await h.rtl.waitFor(() => view.getByTestId('chat-input'));
+ expect(inputBefore.props.value ?? '').toBe('');
+
+ // REAL chat-mode hold-to-talk on a non-audio (llama) model → the whisper REALTIME path.
+ await h.tapMic(); // grant → onStartRecording → the whisper realtime dictation path
+ await h.settle(200); // let the (blocked→free→retry) load resolve and the realtime session start
+ await h.releaseMic(); // release → onStopRecording (whisper path finalizes)
+
+ // The realtime stream delivers the finished utterance (final event, isCapturing:false).
+ await h.rtl.act(async () => {
+ h.boundary.whisper!.emitRealtime({ text: 'take a note', isCapturing: false });
+ });
+ await h.settle(800); // MIN_TRANSCRIBING_TIME + the finalResult → onTranscript effect
+
+ // THE FIX — dictation RECOVERED: the transcript is in the INPUT BOX.
+ // RED before: the realtime path dead-ended on 'blocked' (startRealtimeTranscription threw
+ // 'No Whisper model loaded') → the composer stayed empty.
+ await h.rtl.waitFor(() => {
+ expect(view.getByTestId('chat-input').props.value ?? '').toContain('take a note');
+ }, { timeout: 4000 });
+ });
+});
diff --git a/__tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx b/__tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx
new file mode 100644
index 000000000..75317e10d
--- /dev/null
+++ b/__tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * RED-FLOW (UI, rendered) — #558 CodeRabbit 🔴: no GHOST recording if the mic is released while the
+ * whisper model is still loading.
+ *
+ * The realtime dictation start now awaits ensureModelReady() (which can free the generation model and
+ * reload whisper — seconds on device). If the user RELEASES the mic during that await, the start's
+ * continuation must NOT proceed to activate a recording — the stop already ran, so a session started
+ * after it would never be stopped (a ghost recording that holds the mic forever).
+ *
+ * Fix under test: a session-intent nonce (useWhisperTranscription) captured before the await; stop/cancel
+ * bump it; the start aborts if it changed. Mounts the real ChatScreen; holds the whisper load via the
+ * device-boundary fake (holdNextLoad), releases the mic mid-load, then resolves the load — and asserts NO
+ * realtime session is active. RED (revert the nonce guard): the continuation runs after release →
+ * startRealtimeTranscription fires → realtimeActive() true (the ghost). Only the device leaves are faked.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {}, useIsFocused: () => true,
+}));
+
+describe('realtime dictation: releasing the mic during model load starts NO ghost recording (#558)', () => {
+ it('aborts the superseded start — no realtime session after release-during-load', async () => {
+ const h = await setupChatScreen({ engine: 'llama', platform: 'android', whisper: true });
+ const { useWhisperStore } = require('../../../src/stores/whisperStore');
+
+ // Whisper downloaded-not-loaded, so the mic press must load it (the async gap the race lives in).
+ const docs = h.boundary.fs!.DocumentDirectoryPath;
+ h.boundary.fs!.seedFile(`${docs}/whisper-models/ggml-tiny.en.bin`, 75 * 1024 * 1024);
+ await useWhisperStore.getState().refreshPresentModels();
+ useWhisperStore.setState({ downloadedModelId: 'tiny.en', isModelLoaded: false });
+
+ h.render();
+
+ // HOLD the whisper load open (device-shaped: a real ggml init takes seconds) so the start is parked
+ // inside the ensureModelReady() await when we release the mic.
+ h.boundary.whisper!.holdNextLoad();
+
+ await h.tapMic(); // start begins → awaits ensureModelReady() → whisper load HELD
+ await h.settle(150); // the start is now parked in the await
+ await h.releaseMic(); // RELEASE during the load → stopRecording bumps the session nonce
+ await h.settle(50);
+
+ // Precondition (anti-false-green): the load really was in flight when we released — no session yet.
+ expect(h.boundary.whisper!.realtimeActive()).toBe(false);
+
+ // Now let the load resolve. The superseded start must NOT resurrect a recording.
+ await h.rtl.act(async () => { h.boundary.whisper!.releaseLoad(); });
+ await h.settle(300);
+
+ // TERMINAL artifact: no realtime session is active and none was subscribed — the ghost never started.
+ // RED (revert the nonce guard): the continuation proceeds post-release → startRealtimeTranscription →
+ // realtimeActive() true.
+ expect(h.boundary.whisper!.realtimeActive()).toBe(false);
+ expect(h.boundary.whisper!.hasRealtimeSubscriber()).toBe(false);
+ }, 30000);
+});
diff --git a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts
new file mode 100644
index 000000000..5a6e1c71b
--- /dev/null
+++ b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts
@@ -0,0 +1,121 @@
+/**
+ * RED-FLOW (integration) — image-model download stuck "failed" after the completed bytes are lost.
+ *
+ * DEVICE GROUND TRUTH (iOS, production build): SDXL (Core ML) completed (2.8/2.8 GB) then showed
+ * "…coreml_apple_…xl-base-ios couldn't be opened because there is no such file", and Retry did
+ * nothing. Root cause (native, fixed separately): the completed file was staged in
+ * NSTemporaryDirectory(), which iOS purges across relaunch — so finalize (moveCompletedDownload →
+ * unzip) ran against a file the OS had deleted.
+ *
+ * This guards the shared JS half: when the completed bytes are unrecoverable (native
+ * moveCompletedDownload rejects "no such file" AND no valid zip/extracted dir survives on disk),
+ * the retry-finalize path must RE-DOWNLOAD from scratch instead of dead-ending. Before the fix,
+ * resumeZipDownload rethrew and the row stuck at 'failed' with no fresh download.
+ *
+ * resumeImageDownload is SHARED (it runs on iOS retry AND on Android app-open resume), so this runs
+ * on BOTH platforms with a device-faithful fixture each (iOS = CoreML zip; Android = MNN zip). The
+ * fallback is additive: on the normal Android path moveCompletedDownload succeeds and this branch
+ * never fires, so this only makes the FAILURE case recover instead of dead-end — on both platforms.
+ *
+ * Falsifier: revert the reDownloadFromMetadata fallback in imageDownloadResume → the entry stays
+ * 'failed' and no new native download row appears → RED (verified on both platforms).
+ */
+import { installNativeBoundary } from '../../harness/nativeBoundary';
+
+const FIXTURES = {
+ ios: {
+ modelId: 'coreml_apple_coreml-stable-diffusion-xl-base-ios',
+ backend: 'coreml',
+ name: 'SDXL (iOS)',
+ downloadUrl: 'https://huggingface.co/apple/coreml-stable-diffusion-xl-base-ios/resolve/main/split_einsum/compiled.zip',
+ },
+ android: {
+ modelId: 'anythingv5-mnn',
+ backend: 'mnn',
+ name: 'Anything V5 (MNN)',
+ downloadUrl: 'https://example.com/models/anythingv5-mnn.zip',
+ },
+} as const;
+
+describe.each(['ios', 'android'] as const)(
+ 'image staging purged — retry re-downloads instead of dead-ending, on %s (red-flow)',
+ (platform) => {
+ afterEach(() => {
+ const { Platform } = require('react-native');
+ Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true });
+ });
+
+ it('re-issues a fresh download when the completed bytes are gone at finalize', async () => {
+ const boundary = installNativeBoundary({ download: true, fs: true });
+ const { Platform } = require('react-native');
+ Object.defineProperty(Platform, 'OS', { value: platform, configurable: true });
+ const { useDownloadStore, isActiveStatus } = require('../../../src/stores/downloadStore');
+ const { useAppStore } = require('../../../src/stores');
+ const { makeImageModelKey } = require('../../../src/utils/modelKey');
+ const { resumeImageDownload } = require('../../../src/screens/ModelsScreen/imageDownloadResume');
+
+ const fx = FIXTURES[platform];
+ const fileName = `${fx.modelId}.zip`;
+ const modelKey = makeImageModelKey(fx.modelId);
+ const total = 2.8 * 1024 * 1024 * 1024;
+
+ // A completed image zip download that failed finalize: bytes fully present, marked 'failed'.
+ // metadata carries a real download URL (the zip download type) so a re-download is possible.
+ useDownloadStore.getState().add({
+ modelKey,
+ downloadId: 'dl-img',
+ modelId: `image:${fx.modelId}`,
+ fileName,
+ quantization: '',
+ modelType: 'image',
+ status: 'failed',
+ bytesDownloaded: total,
+ totalBytes: total,
+ combinedTotalBytes: total,
+ progress: 1,
+ createdAt: 1,
+ metadataJson: JSON.stringify({
+ imageDownloadType: 'zip',
+ imageModelName: fx.name,
+ imageModelDescription: 'test model',
+ imageModelSize: total,
+ imageModelStyle: 'realistic',
+ imageModelBackend: fx.backend,
+ imageModelAttentionVariant: 'split_einsum',
+ imageModelDownloadUrl: fx.downloadUrl,
+ }),
+ });
+
+ // BOUNDARY (the device fact this reproduces): the completed file was staged and then lost
+ // (iOS temp purge / storage clear), so the native move of the staged source now fails
+ // "no such file". Nothing valid survives on disk — the unrecoverable case.
+ boundary.download!.module.moveCompletedDownload.mockRejectedValue(
+ new Error(`The file "download_dl-img_${fileName}" couldn't be opened because there is no such file.`),
+ );
+
+ const entry = useDownloadStore.getState().downloads[modelKey];
+ const appState = useAppStore.getState();
+ const deps = {
+ addDownloadedImageModel: appState.addDownloadedImageModel,
+ activeImageModelId: appState.activeImageModelId,
+ setActiveImageModelId: appState.setActiveImageModelId,
+ setAlertState: () => {},
+ triedImageGen: false,
+ };
+
+ // Precondition: no native download in flight, and the row is a dead 'failed' (the screenshot state).
+ expect(boundary.download!.active().length).toBe(0);
+ expect(useDownloadStore.getState().downloads[modelKey].status).toBe('failed');
+
+ // ACT: the retry/resume-finalize path for an all-bytes-present image download.
+ await resumeImageDownload(entry, deps);
+
+ // Recovery: a FRESH download is now in flight (real native row) and the row is active again —
+ // not stuck 'failed'. RED before the fix: no new row, status stays 'failed'.
+ const rows = boundary.download!.active();
+ expect(rows.some(r => r.modelId === `image:${fx.modelId}` || r.fileName === fileName)).toBe(true);
+ expect(isActiveStatus(useDownloadStore.getState().downloads[modelKey].status)).toBe(true);
+ expect(useDownloadStore.getState().downloads[modelKey].status).not.toBe('failed');
+ });
+ },
+);
diff --git a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx
new file mode 100644
index 000000000..c56d49969
--- /dev/null
+++ b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * RED-FLOW (UI, rendered) — the iOS image-download "stuck failed" bug at the pixel.
+ *
+ * DEVICE (production build): SDXL (Core ML) completed (2.8/2.8 GB) then showed a failed card with
+ * "…couldn't be opened because there is no such file", and tapping Retry did nothing. Root cause:
+ * the completed bytes were staged in NSTemporaryDirectory() (native, fixed separately) and lost;
+ * finalize + every retry re-ran moveCompletedDownload on the dead download → same error.
+ *
+ * This mounts the REAL DownloadManagerScreen over the download-native + FS fakes, taps the REAL
+ * Retry button a user taps, and asserts the card RECOVERS (no longer failed; a fresh download is in
+ * flight). Before the JS fix, resumeZipDownload rethrew and the row stayed failed → the Retry button
+ * is still there and no new download exists → RED. Fakes only at the native boundary; the real
+ * screen, provider, retry wiring, resume/finalize, store and proceedWithDownload all run.
+ */
+import { installNativeBoundary, requireRTL } from '../../harness/nativeBoundary';
+
+describe('rendered — iOS image staging purged: Retry recovers the failed card', () => {
+ it('taps Retry on the failed SDXL card and the download recovers (not stuck failed)', async () => {
+ const boundary = installNativeBoundary({ download: true, fs: true });
+ const React = require('react');
+ // The device bug is iOS (Core ML, production build). Pin the platform so imageProvider.retry takes
+ // the iOS path (imageOps.retry → resume/finalize) and not the Android native-resume branch.
+ const { Platform } = require('react-native');
+ Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true });
+ const { render, waitFor, fireEvent } = requireRTL();
+ const { useDownloadStore } = require('../../../src/stores/downloadStore');
+ const { makeImageModelKey } = require('../../../src/utils/modelKey');
+ const { DownloadManagerScreen } = require('../../../src/screens/DownloadManagerScreen');
+ const { registerCoreDownloadProviders } = require('../../../src/services/modelDownloadService/registerProviders');
+ // The download service has no providers until the app registers them at startup — without this
+ // modelDownloadService.retry() finds no owning provider and silently refuses (status stays failed).
+ registerCoreDownloadProviders();
+
+ const modelId = 'coreml_apple_coreml-stable-diffusion-xl-base-ios';
+ const fileName = `${modelId}.zip`;
+ const modelKey = makeImageModelKey(modelId);
+ const total = 2.8 * 1024 * 1024 * 1024;
+
+ // A completed CoreML zip download that failed finalize: bytes fully present, status 'failed', no
+ // errorCode (so it renders as retryable — the failed card shows the Retry button).
+ useDownloadStore.getState().add({
+ modelKey, downloadId: 'dl-sdxl', modelId: `image:${modelId}`, fileName,
+ quantization: '', modelType: 'image', status: 'failed',
+ bytesDownloaded: total, totalBytes: total, combinedTotalBytes: total, progress: 1, createdAt: 1,
+ metadataJson: JSON.stringify({
+ imageDownloadType: 'zip', imageModelName: 'SDXL (iOS)', imageModelDescription: 'test',
+ imageModelSize: total, imageModelStyle: 'realistic', imageModelBackend: 'coreml',
+ imageModelAttentionVariant: 'split_einsum',
+ imageModelRepo: 'apple/coreml-stable-diffusion-xl-base-ios',
+ imageModelDownloadUrl: 'https://huggingface.co/apple/coreml-stable-diffusion-xl-base-ios/resolve/main/split_einsum/compiled.zip',
+ }),
+ });
+
+ // BOUNDARY: the staged completed file was purged, so the native move now fails "no such file",
+ // and nothing valid survives on disk — the unrecoverable case that trapped retry.
+ boundary.download!.module.moveCompletedDownload.mockRejectedValue(
+ new Error(`The file "download_dl-sdxl_${fileName}" couldn't be opened because there is no such file.`),
+ );
+
+ const view = render(React.createElement(DownloadManagerScreen, {}));
+
+ // Precondition: the failed SDXL card is on screen WITH a Retry button (the screenshot state).
+ const retry = await waitFor(() => {
+ const btn = view.queryByTestId('failed-retry-button');
+ expect(btn).not.toBeNull();
+ return btn;
+ });
+ expect(view.queryByText(/SDXL|coreml_apple/)).not.toBeNull();
+ expect(boundary.download!.active().length).toBe(0);
+
+ // GESTURE: tap Retry, the way the user did on the device.
+ fireEvent.press(retry!);
+
+ // RECOVERY (what the user should now see): the Retry button is gone because the row is no longer
+ // failed — a fresh download is in flight. RED before the fix: still failed, Retry still there, no
+ // new download row.
+ await waitFor(() => {
+ expect(useDownloadStore.getState().downloads[modelKey].status).not.toBe('failed');
+ }, { timeout: 5000 });
+ expect(view.queryByTestId('failed-retry-button')).toBeNull();
+ const rows = boundary.download!.active();
+ expect(rows.some(r => r.modelId === `image:${modelId}` || r.fileName === fileName)).toBe(true);
+ });
+});
diff --git a/__tests__/integration/downloads/orphanProjectorPhantomHydration.rendered.redflow.test.tsx b/__tests__/integration/downloads/orphanProjectorPhantomHydration.rendered.redflow.test.tsx
new file mode 100644
index 000000000..88c4d9266
--- /dev/null
+++ b/__tests__/integration/downloads/orphanProjectorPhantomHydration.rendered.redflow.test.tsx
@@ -0,0 +1,52 @@
+/**
+ * RED-FLOW (UI, rendered) — an orphaned projector sidecar must NOT hydrate as a phantom model card.
+ *
+ * A `*-projector.gguf` (equally `*-clip.gguf`) is a multimodal PROJECTOR that rides with a text/vision
+ * model — it is never a standalone downloadable model. On relaunch, hydrateDownloadStore rebuilds the
+ * download list from the native rows. If a projector sidecar row survives with NO parent back-link
+ * (mmProjDownloadId), downloadHydration must classify it as a projector (via the canonical isMMProjFile,
+ * which matches mmproj/projector/clip) and DROP it — so the user never sees a bogus model row for it.
+ *
+ * The old isMmProjFileName only matched 'mmproj', so a '-projector.gguf' sidecar slipped past the
+ * projector filter, hydrated as a real DownloadEntry, and rendered as a phantom ActiveDownloadCard on
+ * the Download Manager. Revert the fix (match only 'mmproj') and the phantom card reappears → RED.
+ *
+ * Mounts the real DownloadManagerScreen over the download-native + fs harness (fakes ONLY at the device
+ * boundary), calls the real hydrateDownloadStore, and asserts the rendered surface: no card for the
+ * projector filename. Modeled on imageExtractLostRelaunch.rendered.redflow.test.tsx.
+ */
+import { installNativeBoundary, requireRTL } from '../../harness/nativeBoundary';
+
+describe('orphaned projector sidecar (rendered) — no phantom model on hydrate', () => {
+ it('does not surface a `-projector.gguf` sidecar as a model card on the Download Manager', async () => {
+ const boundary = installNativeBoundary({ download: true, fs: true });
+ const React = require('react');
+ const { render, waitFor } = requireRTL();
+ const { hydrateDownloadStore } = require('../../../src/services/downloadHydration');
+ const { DownloadManagerScreen } = require('../../../src/screens/DownloadManagerScreen');
+
+ // A projector sidecar left in the native active set with NO parent back-link (mmProjDownloadId):
+ // an orphan a relaunch reconcile finds. It is a projector, not a model — must be dropped by hydrate.
+ const projectorFileName = 'gemma-4-E2B-it-projector.gguf';
+ boundary.download!.seedActive({
+ downloadId: 'dl-proj',
+ fileName: projectorFileName,
+ modelId: 'unsloth/gemma-4-E2B-it-GGUF',
+ modelType: 'text',
+ status: 'running',
+ bytesDownloaded: 300 * 1024 * 1024,
+ totalBytes: 300 * 1024 * 1024,
+ });
+ boundary.fs!.seedFile('/docs/models/gemma-4-E2B-it-projector.gguf', 300 * 1024 * 1024);
+
+ await hydrateDownloadStore();
+
+ const view = render(React.createElement(DownloadManagerScreen, {}));
+ // Re-render proof: the screen mounted (its title is on screen) before we assert an absence.
+ await waitFor(() => { expect(view.queryByText('Download Manager')).not.toBeNull(); });
+
+ // Correct: the projector is classified as a projector, not a model, so NO card shows its filename.
+ // Before the fix (isMmProjFileName matched only 'mmproj') the sidecar leaked in as a phantom card → RED.
+ expect(view.queryByText(projectorFileName)).toBeNull();
+ });
+});
diff --git a/__tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx b/__tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx
new file mode 100644
index 000000000..30b76dba9
--- /dev/null
+++ b/__tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx
@@ -0,0 +1,118 @@
+/**
+ * RED-FLOW (UI, rendered) — a rehydrated FAILED text download on iOS whose store row LOST its
+ * downloadId (device 2026-07-15: an app-kill mid-download cleared the store's downloadId) must be
+ * RETRIABLE from the Models screen's file card: tapping Retry re-issues a fresh download.
+ *
+ * The bug: the Models-screen file card picks the retry MECHANISM by Platform.OS in the presentation
+ * layer (Android → backgroundDownloadService.retryDownload; iOS → a fresh proceedDownload()), and it
+ * only renders the failed section (with the Retry button) when `storeEntry?.downloadId` is truthy. On
+ * iOS a rehydrated failed entry that lost its downloadId therefore has NO Retry affordance at all — the
+ * exact lost-downloadId case textProvider.retry() was already fixed for, bypassed because this caller
+ * never routes through the provider. So retry is a silent no-op.
+ *
+ * The single owner is modelDownloadService.retry(uniformDownloadId('text', modelKey)) → textProvider.retry
+ * (iOS re-issues from the entry's metadata; needs NO downloadId). This test drives the REAL Models screen
+ * → arrives at the model detail via a real search+tap → the failed card renders → tap Retry → assert the
+ * REAL native download layer received a fresh start (the status leaves 'failed'; a new native row exists).
+ *
+ * Integration boundary: fakes ONLY at the device boundary — the native DownloadManagerModule + fs + RAM
+ * (installNativeBoundary), and the HuggingFace NETWORK transport (searchModels/getModelFiles). Everything
+ * we own runs REAL: ModelsScreen, TextModelsTab, ModelCard, useTextModels, modelDownloadService,
+ * textProvider, modelManager, backgroundDownloadService, the download store. Platform pinned to iOS.
+ */
+import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary';
+
+const MODEL_ID = 'meta/llama-lost';
+const FILE_NAME = 'llama-q4.gguf';
+const MODEL_KEY = `${MODEL_ID}/${FILE_NAME}`;
+
+describe('iOS text retry re-issues a rehydrated failed download that lost its downloadId (red-flow)', () => {
+ it('tapping Retry on a lost-downloadId failed card re-issues a fresh download (not a silent no-op)', async () => {
+ // Device boundary: an iOS phone with plenty of RAM (12GB) so the file is compatible/offered.
+ const boundary = installNativeBoundary({ download: true, fs: true, ram: { platform: 'ios', totalBytes: 12 * GB, availBytes: 8 * GB } });
+
+ // HuggingFace NETWORK transport is outside our system — fake it. getDownloadUrl is a PURE string
+ // builder (the retry re-issue path calls it), so implement it faithfully so a real URL is built.
+ const file = { name: FILE_NAME, size: 3 * GB, quantization: 'Q4_K_M', downloadUrl: `https://huggingface.co/${MODEL_ID}/resolve/main/${FILE_NAME}` };
+ const modelInfo = { id: MODEL_ID, name: 'Llama Lost', author: 'meta', description: 'test', downloads: 100, likes: 1, tags: [], lastModified: '', files: [file] };
+ jest.doMock('../../../src/services/huggingface', () => ({
+ huggingFaceService: {
+ searchModels: jest.fn(async () => [modelInfo]),
+ getModelFiles: jest.fn(async () => [file]),
+ getModelDetails: jest.fn(async () => modelInfo),
+ getDownloadUrl: (modelId: string, fileName: string, revision = 'main') =>
+ `https://huggingface.co/${modelId}/resolve/${revision}/${fileName}`,
+ formatModelSize: jest.fn(() => '3.0 GB'),
+ formatFileSize: jest.fn((b: number) => `${(b / GB).toFixed(1)} GB`),
+ },
+ }));
+
+ /* eslint-disable @typescript-eslint/no-var-requires */
+ const React = require('react');
+ const { render, fireEvent, waitFor, act } = requireRTL();
+ const { useDownloadStore } = require('../../../src/stores/downloadStore');
+ const { registerCoreDownloadProviders } = require('../../../src/services/modelDownloadService/registerProviders');
+ const { ModelsScreen } = require('../../../src/screens/ModelsScreen');
+ /* eslint-enable @typescript-eslint/no-var-requires */
+
+ // Providers must be registered so modelDownloadService can route text retries to textProvider.
+ registerCoreDownloadProviders();
+
+ // Device-boundary residue of the app-kill: a hydrated FAILED text entry whose downloadId was LOST
+ // (empty string — the store's downloadId cleared while the native row was gone). This is the exact
+ // rehydrated state the bug occurs on; it is an outside-our-system leaf (persisted/rehydrated row).
+ useDownloadStore.getState().hydrate([{
+ modelKey: MODEL_KEY,
+ downloadId: '', // LOST on the app-kill
+ modelId: MODEL_ID,
+ fileName: FILE_NAME,
+ quantization: 'Q4_K_M',
+ modelType: 'text',
+ status: 'failed',
+ bytesDownloaded: 1 * GB,
+ totalBytes: 3 * GB,
+ combinedTotalBytes: 3 * GB,
+ progress: 0.33,
+ errorMessage: 'Download failed',
+ createdAt: Date.now(),
+ }]);
+
+ // Prime the synchronous RAM read (getTotalMemoryGB) from the seeded device-info boundary — the same
+ // step Home does before handing the picker its memory numbers. Without it ramGB reads a stale default.
+ const { hardwareService } = require('../../../src/services/hardware');
+ await hardwareService.refreshMemoryInfo();
+
+ const utils = render(React.createElement(ModelsScreen, {}));
+ const { getByTestId, getByText, queryByText } = utils;
+
+ // Arrive at the model detail via REAL gestures: type a search, then submit it (submit runs the
+ // search immediately, past the 500ms debounce), tap the model card.
+ await act(async () => { fireEvent.changeText(getByTestId('search-input'), 'llama'); });
+ await act(async () => {
+ fireEvent(getByTestId('search-input'), 'submitEditing');
+ await new Promise((r) => setTimeout(r, 600)); // let the debounced + submitted search resolve
+ });
+ await waitFor(() => expect(getByText('Llama Lost')).toBeTruthy(), { timeout: 6000 });
+ await act(async () => { fireEvent.press(getByText('Llama Lost')); });
+ await waitFor(() => expect(getByTestId('model-detail-screen')).toBeTruthy(), { timeout: 4000 });
+
+ // The failed file card must expose a Retry control (a failed rehydrated entry the user must recover).
+ await waitFor(() => expect(getByText('Retry')).toBeTruthy(), { timeout: 4000 });
+
+ // No native download exists yet (the row was lost on the kill).
+ expect(boundary.download!.active().length).toBe(0);
+
+ // Tap Retry.
+ await act(async () => { fireEvent.press(getByText('Retry')); });
+
+ // TERMINAL artifact: the retry re-issued a fresh download. The status leaves 'failed' (the failed
+ // section + its Retry button disappear) AND a real native download row now exists.
+ await waitFor(() => {
+ expect(useDownloadStore.getState().downloads[MODEL_KEY]?.status).not.toBe('failed');
+ }, { timeout: 4000 });
+ await waitFor(() => {
+ expect(boundary.download!.active().length).toBeGreaterThanOrEqual(1);
+ }, { timeout: 4000 });
+ expect(queryByText('Retry')).toBeNull();
+ }, 30000);
+});
diff --git a/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx b/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx
new file mode 100644
index 000000000..5c36e517b
--- /dev/null
+++ b/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx
@@ -0,0 +1,112 @@
+/**
+ * #510 4a919e3d — a FORCE-IMAGE send that QUEUES behind an in-flight generation must still
+ * generate an IMAGE when the queue drains. On device the queued force-image message lost its
+ * force flag: QueuedMessage carried no imageMode, so on drain dispatchGenerationFn re-decided the
+ * modality at imageMode='auto' → resolveTurnKind classified the (non-draw) text as TEXT and the
+ * message the user explicitly forced to image generated as a text reply.
+ *
+ * SPEC (the user's view): I turned image mode ON, then sent a message while a previous turn was
+ * still generating. When it finally runs, it must draw an image — not answer as text — because I
+ * forced image mode for that send. The force choice must survive the queue.
+ *
+ * Journey (all real gestures on the real mounted ChatScreen + real generationService/dispatch/
+ * queue; fake ONLY the native LiteRT/llama + diffusion leaves):
+ * 1. place + activate an image model, image mode still AUTO.
+ * 2. send turn #1 (a normal non-draw prompt) whose native completion HOLDS in prefill
+ * (holdBeforeStream) → generation stays in-flight (isGenerating true).
+ * 3. observe the STOP control on screen (anti-false-green: the in-flight state truly rendered,
+ * so the next send genuinely queues behind it).
+ * 4. turn image mode ON (force badge) and send turn #2 with a NON-draw prompt → it QUEUES
+ * behind the in-flight turn #1 (the queued-message path under test).
+ * 5. release turn #1 → the queue drains and dispatches turn #2.
+ *
+ * The prompt for turn #2 ("tell me about cats") matches NO image heuristic, so under auto mode the
+ * classifier routes it to TEXT — the force flag is the ONLY reason it should draw. That makes the
+ * discriminator clean:
+ * RED on HEAD: force lost on drain → dispatched at 'auto' → classified TEXT → a text reply
+ * renders, NO generated image.
+ * GREEN with the fix: imageMode carried through the queue → dispatched as force → the diffusion
+ * boundary runs → the generated-image bubble renders, NO text reply.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {}, useIsFocused: () => true,
+}));
+
+const QUEUED_TEXT_LEAK = 'Cats are small domesticated carnivorous mammals.';
+
+describe('#510 (rendered) — a queued force-image send preserves its force flag on drain', () => {
+ it('draws an image (not a text reply) when a force-image send queued behind an in-flight turn drains', async () => {
+ // llama engine so we can HOLD turn #1 in prefill (holdBeforeStream) → generation stays in-flight.
+ const h = await setupChatScreen({ engine: 'llama', platform: 'android' });
+ h.render();
+ const { rtl } = h;
+ const view = h.view!;
+
+ await h.placeImageModel({ backend: 'coreml' });
+
+ // ---- Turn #1 (TEXT, auto mode): holds in prefill so generation stays in-flight. ----
+ // The queued force-image text is what would leak as a bubble if turn #2 misroutes to text.
+ h.boundary.llama!.scriptCompletion({ text: QUEUED_TEXT_LEAK, holdBeforeStream: true });
+ await h.tapSend('what is the weather like');
+
+ // Anti-false-green precondition: the generating STOP control is genuinely on screen, so the
+ // next send truly queues behind an in-flight turn (not a no-op because it was too fast).
+ await rtl.waitFor(() => { expect(view.queryByTestId('stop-button')).not.toBeNull(); }, { timeout: 4000 });
+
+ // ---- Turn on image mode (force), then send turn #2 → it QUEUES behind turn #1. ----
+ await h.cycleImageMode(); // auto → ON(force)
+ await rtl.waitFor(() => { expect(view.queryByTestId('image-mode-force-badge')).not.toBeNull(); });
+ await h.tapSend('tell me about cats'); // NON-draw prompt: only the force flag should make it an image
+ await h.settle(50); // let handleSendFn enqueue
+
+ // No image generated yet — turn #2 is queued, turn #1 still holds.
+ expect(h.boundary.diffusion.calls.generateImage.length).toBe(0);
+
+ // ---- Release turn #1 → the queue drains and dispatches the queued force-image message. ----
+ h.boundary.llama!.releaseStream();
+ await h.settle(400); // turn #1 finalizes; resetState schedules the drain (~100ms) → dispatch turn #2
+
+ // SPEC: the queued force-image send draws → the generated-image bubble renders on screen, and the
+ // scripted TEXT reply must NOT appear for turn #2.
+ // RED (#510): force lost → classified text → QUEUED_TEXT_LEAK renders again as a second reply, no image.
+ await rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 4000 });
+ expect(view.queryByTestId('generated-image')).not.toBeNull();
+ // Only turn #1's single text reply may exist — turn #2 must NOT have produced a second text reply.
+ expect(view.queryAllByText(new RegExp(QUEUED_TEXT_LEAK)).length).toBe(1);
+ });
+
+ it('COALESCE (M16): two sends queue together and one forced image — the merged dispatch draws an image', async () => {
+ // Exercises the multi-message branch (all.length > 1) where imageMode = all.some(force) ? force : all[0].
+ // My single-message test above only hits the all[0] shortcut; this pins the coalesce force-merge so a
+ // regression to `all[0].imageMode` (dropping the .some) is caught.
+ const h = await setupChatScreen({ engine: 'llama', platform: 'android' });
+ h.render();
+ const { rtl } = h;
+ const view = h.view!;
+ await h.placeImageModel({ backend: 'coreml' });
+
+ // Turn #1 holds in prefill → in-flight, so BOTH following sends queue behind it and coalesce.
+ h.boundary.llama!.scriptCompletion({ text: QUEUED_TEXT_LEAK, holdBeforeStream: true });
+ await h.tapSend('what is the weather like');
+ await rtl.waitFor(() => { expect(view.queryByTestId('stop-button')).not.toBeNull(); }, { timeout: 4000 });
+
+ // Queue #2 (auto/text) then #3 (force image) — both queue while #1 holds → coalesced on drain.
+ await h.tapSend('and how are you'); // auto, non-draw → queued
+ await h.settle(30);
+ await h.cycleImageMode();
+ await rtl.waitFor(() => { expect(view.queryByTestId('image-mode-force-badge')).not.toBeNull(); });
+ await h.tapSend('tell me about cats'); // force → queued (now 2 queued, one forced)
+ await h.settle(50);
+ expect(h.boundary.diffusion.calls.generateImage.length).toBe(0); // nothing drawn while #1 holds
+
+ // Drain: the 2 queued messages coalesce; because one was force, the merged dispatch must draw.
+ h.boundary.llama!.releaseStream();
+ await h.settle(400);
+ await rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 4000 });
+ expect(view.queryByTestId('generated-image')).not.toBeNull();
+ });
+});
diff --git a/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx b/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx
new file mode 100644
index 000000000..100b45e58
--- /dev/null
+++ b/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx
@@ -0,0 +1,176 @@
+/**
+ * DEV-B16 / B17 (capability half) — a REMOTE model that reasons via Gemma-style inline channel
+ * markup (`<|channel>thought …`, NO separate reasoning_content field) must be detected as
+ * thinking-capable during discovery, so the chat Thinking toggle appears for it.
+ *
+ * Ground truth (docs/DEVICE_TEST_FINDINGS.md):
+ * - "Inline-thinking delimiter is model-specific: Qwen3.5 = `…`;
+ * gemma-4-E2B = `<|channel>thought`." (part16)
+ * - B16/B17: a remote model emitted reasoning the WIRE captured, but the app showed reasoning=0
+ * and had "no thinking toggle for remote" → the reasoning never rendered.
+ *
+ * The fix under test lives in src/stores/remoteModelCapabilities.ts `deltaHasThinking`: it now
+ * detects inline channel reasoning through the SHARED grammar (REASONING_DELIMITERS) instead of a
+ * hardcoded ``. probeLmStudioThinking streams a probe during discovery and runs
+ * deltaHasThinking on each delta; a hit sets supportsThinking=true on the discovered model, which
+ * is the ONLY thing that renders the `quick-thinking-toggle`.
+ *
+ * This test drives the REAL discovery path (remoteServerStore.discoverModels →
+ * fetchModelsFromServer → fetchModelCapabilities → fetchLmStudioModelInfo → probeLmStudioThinking →
+ * deltaHasThinking) — it does NOT pre-place caps via installRemoteModel, because that would bypass
+ * the exact code the fix changed. Only the NETWORK transport is faked (global.fetch), device-shaped:
+ * the LM Studio model list + a streaming probe whose delta.content carries the bare Gemma opener.
+ *
+ * SPEC / GREEN: after the user selects that discovered remote model and opens quick-settings, the
+ * Thinking toggle is on screen. RED (revert deltaHasThinking to hardcoded ``): the bare
+ * Gemma opener is missed → supportsThinking stays false → the toggle never renders.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {}, useIsFocused: () => true,
+}));
+
+const ENDPOINT = 'http://localhost:1234';
+const MODEL_ID = 'gemma-4-e2b';
+
+// LM Studio /v1/models list (discovery entry point) — one generative model.
+const V1_MODELS = JSON.stringify({
+ object: 'list',
+ data: [{ id: MODEL_ID, object: 'model', owned_by: 'lmstudio' }],
+});
+
+// LM Studio native /api/v1/models — the model advertised with a matching `key`. Carries NO thinking
+// flag (LM Studio never advertises one — that's B17), so supportsThinking depends entirely on the probe.
+const LMSTUDIO_MODELS = JSON.stringify({
+ models: [{
+ key: MODEL_ID,
+ max_context_length: 8192,
+ capabilities: { vision: false, trained_for_tool_use: false },
+ }],
+});
+
+// The captured-shape probe stream: gemma-4-E2B emits its reasoning INLINE in delta.content using the
+// bare channel opener `<|channel>thought` (device finding part16) — NO reasoning_content field. This is
+// the exact form the old hardcoded-`` deltaHasThinking missed and the shared grammar now catches.
+const GEMMA_CHANNEL_PROBE_SSE =
+ 'data: {"choices":[{"delta":{"role":"assistant","content":"<|channel>thought"}}]}\n\n' +
+ 'data: {"choices":[{"delta":{"content":" the user said hi"}}]}\n\n' +
+ 'data: {"choices":[{"delta":{},"finish_reason":"length"}]}\n\n' +
+ 'data: [DONE]\n\n';
+
+// NEGATIVE discriminator (the anti-M10 guard): a non-thinking model streams PLAIN content — no
+// channel opener, no reasoning_content/reasoning/thinking field. deltaHasThinking MUST return false
+// for every delta → supportsThinking=false → NO toggle. Without this case, an always-true
+// deltaHasThinking (e.g. a mutated `.includes`→`!.includes`, which is trivially true over the
+// multi-delimiter list) would pass the positive test — so this case is what makes the delimiter
+// check load-bearing.
+const PLAIN_PROBE_SSE =
+ 'data: {"choices":[{"delta":{"role":"assistant","content":"Hi"}}]}\n\n' +
+ 'data: {"choices":[{"delta":{"content":" there, how can I help?"}}]}\n\n' +
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' +
+ 'data: [DONE]\n\n';
+
+/** Fake ONLY the network transport (global.fetch) with device-shaped responses per endpoint. Everything
+ * we own — discovery, fetchModelCapabilities, probeLmStudioThinking, deltaHasThinking — runs for real. */
+function installDiscoveryFetch(probeSse: string): () => void {
+ const original = global.fetch;
+ const ok = (body: string): Response =>
+ ({ ok: true, status: 200, json: async () => JSON.parse(body), text: async () => body }) as unknown as Response;
+ const notFound = (): Response =>
+ ({ ok: false, status: 404, json: async () => ({}), text: async () => '' }) as unknown as Response;
+
+ global.fetch = (async (input: RequestInfo | URL) => {
+ const u = typeof input === 'string' ? input : input.toString();
+ // Order matters: `/api/v1/models` (LM Studio native) also ends with `/v1/models`, so match the
+ // more specific path first, or the OpenAI-compat list body would be served for the native probe.
+ if (u.endsWith('/api/v1/models')) return ok(LMSTUDIO_MODELS);
+ if (u.endsWith('/v1/models')) return ok(V1_MODELS);
+ if (u.endsWith('/v1/chat/completions')) return ok(probeSse); // the thinking probe (per-case payload)
+ // /props (llama.cpp) and /api/show (Ollama) must NOT answer with real data, or they'd win the
+ // capability race ahead of the LM Studio probe. A non-llama.cpp / non-Ollama server 404s here.
+ return notFound();
+ }) as typeof global.fetch;
+
+ return () => { global.fetch = original; };
+}
+
+describe('remote Gemma-channel inline reasoning → Thinking toggle appears (DEV-B16/B17)', () => {
+ it('detects <|channel>thought during discovery and shows quick-thinking-toggle for the remote model', async () => {
+ // Mount the real ChatScreen. The local model the harness installs is unloaded below so the
+ // capability path reads the remote model (the screen prefers a remote when one is active).
+ const h = await setupChatScreen({ engine: 'llama', platform: 'android' });
+
+ const { useRemoteServerStore, useAppStore } = require('../../../src/stores');
+ const { llmService } = require('../../../src/services/llm');
+ const { setActiveRemoteTextModelImpl } = require('../../../src/services/remoteServerManagerUtils');
+
+ // Route remote: no local model loaded/selected (mirrors selecting a remote model on device).
+ await llmService.unloadModel();
+ useAppStore.getState().setActiveModelId(null);
+
+ const restoreFetch = installDiscoveryFetch(GEMMA_CHANNEL_PROBE_SSE);
+ try {
+ // REAL "add a server" end state + REAL discovery — this is what runs deltaHasThinking. No caps
+ // are pre-placed; supportsThinking is EMERGENT from the probe stream through the real detection.
+ const serverId = useRemoteServerStore.getState().addServer({
+ name: 'LM Studio', endpoint: ENDPOINT, providerType: 'openai-compatible',
+ });
+ await useRemoteServerStore.getState().discoverModels(serverId);
+
+ // REAL "user selects this discovered remote model" — sets it active + registers the provider
+ // and applies the discovered capabilities. Same action the model picker fires.
+ await setActiveRemoteTextModelImpl(serverId, MODEL_ID);
+ } finally {
+ restoreFetch();
+ }
+
+ h.render();
+
+ // Open the quick-settings popover the way the user does (the composer's quick-settings button).
+ h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByTestId('quick-settings-button')));
+
+ // SPEC: because the remote model was detected as thinking-capable, the Thinking toggle is shown.
+ // RED (revert deltaHasThinking to hardcoded ``): the bare `<|channel>thought` opener is
+ // missed → supportsThinking=false → this toggle never renders.
+ await h.rtl.waitFor(() => {
+ expect(h.view!.queryByTestId('quick-thinking-toggle')).not.toBeNull();
+ }, { timeout: 6000 });
+ // Precondition guard against a false green: the popover IS open (its always-present Image Gen row
+ // is there), so a missing thinking toggle would be a real absence, not an unopened popover.
+ expect(h.view!.queryByTestId('quick-image-mode')).not.toBeNull();
+ });
+
+ it('does NOT show the toggle for a remote model whose probe is PLAIN content (anti-M10 discriminator)', async () => {
+ // Identical flow, but the probe streams plain content with NO reasoning signal at all →
+ // deltaHasThinking must return false → supportsThinking=false → NO thinking toggle. This is the
+ // discriminator: it FAILS if deltaHasThinking is broken to be always-true (the surviving M10 mutant),
+ // so together with the positive case it pins detection to the actual delimiter grammar.
+ const h = await setupChatScreen({ engine: 'llama', platform: 'android' });
+ const { useRemoteServerStore, useAppStore } = require('../../../src/stores');
+ const { llmService } = require('../../../src/services/llm');
+ const { setActiveRemoteTextModelImpl } = require('../../../src/services/remoteServerManagerUtils');
+
+ await llmService.unloadModel();
+ useAppStore.getState().setActiveModelId(null);
+
+ const restoreFetch = installDiscoveryFetch(PLAIN_PROBE_SSE);
+ try {
+ const serverId = useRemoteServerStore.getState().addServer({
+ name: 'LM Studio', endpoint: ENDPOINT, providerType: 'openai-compatible',
+ });
+ await useRemoteServerStore.getState().discoverModels(serverId);
+ await setActiveRemoteTextModelImpl(serverId, MODEL_ID);
+ } finally {
+ restoreFetch();
+ }
+
+ h.render();
+ h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByTestId('quick-settings-button')));
+ // Popover open (guard), but NO thinking toggle for a non-thinking model.
+ await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('quick-image-mode')).not.toBeNull(); });
+ expect(h.view!.queryByTestId('quick-thinking-toggle')).toBeNull();
+ });
+});
diff --git a/__tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx b/__tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx
new file mode 100644
index 000000000..5b1da5955
--- /dev/null
+++ b/__tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx
@@ -0,0 +1,60 @@
+/**
+ * RED-FLOW (UI integration, HEAVY entry point) — SEND vs RESEND must not diverge after the modality
+ * decision. Commit 4a919e3d unified the modality DECISION (resolveTurnKind), but the post-decision
+ * DISPATCH diverged: send guards the image pipeline on `activeImageModel` and FALLS BACK to text when no
+ * image model is loaded, while resend fired the image pipeline UNCONDITIONALLY. So the SAME prompt behaves
+ * differently on resend vs send once the image model is gone.
+ *
+ * SPEC (the OGAM user's view): a turn that produced an image, resent AFTER the image model is unloaded,
+ * must behave like SENDING that prompt with no image model — a graceful TEXT reply, NOT an "Error: No image
+ * model loaded." dead-end. Send already does this (dispatchGenerationFn prepends a note and runs text);
+ * resend must converge on the SAME shared dispatch.
+ *
+ * Arrive-via-UI: force an image (real quick-image-mode toggle) and send → a real image turn is recorded
+ * (imageGenerationService adds an assistant message with an image attachment → recordedTurnKind='image').
+ * Then the image model is unloaded (store transition — the documented harness convention, since the image
+ * picker lives behind a nested sheet that is fragile to gesture in jest), and the turn is RESENT via its
+ * real Retry affordance.
+ *
+ * RED on HEAD: resend fires the image pipeline with no image model → the user sees "No image model loaded."
+ * and NO text reply. GREEN after the shared-dispatch fix: resend falls back to text like send → the scripted
+ * text reply renders and the error alert never appears.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {}, useIsFocused: () => true,
+}));
+
+describe('send/resend parity — resending an image turn with no image model falls back to text (like send)', () => {
+ it('shows a text reply, not "No image model loaded.", when the image turn is resent after the image model is gone', async () => {
+ const h = await setupChatScreen({ engine: 'litert', platform: 'ios' });
+ h.render();
+ await h.placeImageModel();
+
+ // GESTURE: force image mode, then send → the REAL image pipeline runs and records an image turn
+ // (assistant message with an image attachment → recordedTurnKind='image' for this user message).
+ await h.cycleImageMode(); // auto → ON (force)
+ await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('image-mode-force-badge')).not.toBeNull(); });
+ await h.tapSend('a castle on a hill');
+ await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 8000 });
+ await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('generated-image')).not.toBeNull(); }, { timeout: 8000 });
+
+ // The user unloads the image model (store transition — the picker is behind a fragile nested sheet;
+ // this is the documented harness convention for image-model (de)activation). activeImageModel → undefined.
+ await h.rtl.act(async () => { h.useAppStore.setState({ activeImageModelId: null }); });
+
+ // GESTURE: resend the (image-recorded) turn via its real Retry affordance. The next engine turn is
+ // scripted as TEXT — what SHOULD render once resend falls back to text (send's behavior).
+ await h.regenerateLast({ content: 'A castle is a fortified stone structure.' }, 'longpress');
+
+ // Correct (send-parity): a graceful TEXT reply renders, and the image-model error never appears.
+ // RED on HEAD: resend fires the image pipeline unconditionally → "No image model loaded." + no reply.
+ await h.rtl.waitFor(() => { expect(h.view!.queryByText(/A castle is a fortified stone structure\./)).not.toBeNull(); }, { timeout: 8000 });
+ expect(h.view!.queryByText('No image model loaded.')).toBeNull();
+ // The failed image path must NOT have fired a second diffusion call.
+ expect(h.boundary.diffusion.calls.generateImage.length).toBe(1);
+ }, 60000);
+});
diff --git a/__tests__/integration/generation/toolLoopEngineOwnerRouting.test.ts b/__tests__/integration/generation/toolLoopEngineOwnerRouting.test.ts
new file mode 100644
index 000000000..d8709f024
--- /dev/null
+++ b/__tests__/integration/generation/toolLoopEngineOwnerRouting.test.ts
@@ -0,0 +1,161 @@
+/**
+ * DRY / single-owner — generationToolLoop routes through the ONE engine-caps owner (src/services/engines.ts).
+ *
+ * generationToolLoop used to re-derive "which engine is active" INLINE: isLiteRTActive() (line 423) read the
+ * store AND conflated it with `liteRTService.isModelLoaded()`, instead of the owner getActiveEngineService()
+ * (=== liteRTService), which defines "active engine" WITHOUT the loaded check (load-readiness is a separate
+ * concern the sibling generationServiceHelpers.isLiteRTActive already treats separately). isUsingRemote /
+ * callLLMWithRetry / selectEffectiveSchemas each also re-inlined the activeServer+hasProvider+!localLoaded
+ * rule that engines.isRemoteTextModelActive() OWNS. #510 (4d88c249) promised a single source for active caps.
+ *
+ * This test drives the REAL runToolLoop over the REAL engines.ts + REAL stores (only the native leaves
+ * llm/litert and the provider transport are faked — the sanctioned integration boundary), and asserts the
+ * loop's engine choice MATCHES the owner's verdict. The GUARD case is the exact input where the inline
+ * derivation and the owner DISAGREE — a LiteRT model active but not yet reporting loaded: the owner names
+ * `litert`, the old inline conflation names `llama`. On HEAD the loop follows its private conflated rule and
+ * routes to llama → RED. Once the loop routes through the owner it follows getActiveEngineService() → GREEN.
+ */
+jest.mock('../../../src/services/llm');
+jest.mock('../../../src/services/litert');
+
+import { runToolLoop, type ToolLoopContext } from '../../../src/services/generationToolLoop';
+import { liteRTService } from '../../../src/services/litert';
+import { llmService } from '../../../src/services/llm';
+import { getActiveEngineService, isRemoteTextModelActive } from '../../../src/services/engines';
+import { useAppStore } from '../../../src/stores/appStore';
+import { useRemoteServerStore } from '../../../src/stores/remoteServerStore';
+import { providerRegistry } from '../../../src/services/providers';
+import { resetStores, setupWithConversation } from '../../utils/testHelpers';
+import { createDownloadedModel, createMessage } from '../../utils/factories';
+import type { Message } from '../../../src/types';
+
+const mockLiteRT = liteRTService as jest.Mocked;
+const mockLlm = llmService as jest.Mocked;
+
+let remoteProviderGenerate: jest.Mock;
+
+/** The engine the loop ACTUALLY routed to, read off the native leaf that got driven. */
+function engineTheLoopUsed(): 'litert' | 'remote' | 'llama' {
+ if (mockLiteRT.generateRaw.mock.calls.length > 0) return 'litert';
+ if (remoteProviderGenerate?.mock.calls.length > 0) return 'remote';
+ if (mockLlm.generateResponseWithTools.mock.calls.length > 0) return 'llama';
+ throw new Error('no engine leaf was driven');
+}
+
+/** The engine the OWNER (engines.ts) names as active — the single source of truth. */
+function engineOwnerNames(): 'litert' | 'remote' | 'llama' {
+ if (isRemoteTextModelActive()) return 'remote';
+ return getActiveEngineService() === liteRTService ? 'litert' : 'llama';
+}
+
+function makeCtx(conversationId: string): ToolLoopContext {
+ const userMsg: Message = createMessage({ role: 'user', content: 'what is the capital of France' });
+ return {
+ conversationId,
+ messages: [userMsg],
+ enabledToolIds: ['web_search'],
+ isAborted: () => false,
+ onThinkingDone: () => {},
+ onStream: () => {},
+ onFinalResponse: () => {},
+ };
+}
+
+/** Register a real provider record shaped like a registered remote provider whose generate() streams a
+ * plain answer (the network boundary). The registry + isRemoteTextModelActive run for real above it. */
+function registerRemoteProvider(serverId: string): void {
+ remoteProviderGenerate = jest.fn(async (_msgs: unknown, _opts: unknown, cbs: any) => {
+ cbs?.onToken?.('Paris');
+ cbs?.onComplete?.({ content: 'Paris' });
+ return { content: 'Paris', toolCalls: [] };
+ });
+ const provider = {
+ generate: remoteProviderGenerate,
+ capabilities: { supportsThinking: false, supportsVision: false, supportsToolCalling: true },
+ isReady: async () => true,
+ getLoadedModelId: () => 'remote-model',
+ loadModel: jest.fn(async () => {}),
+ };
+ (providerRegistry as any).registerProvider(serverId, provider);
+}
+
+beforeEach(() => {
+ resetStores();
+ jest.clearAllMocks();
+ remoteProviderGenerate = undefined as unknown as jest.Mock;
+ mockLiteRT.prepareConversation.mockResolvedValue(undefined as never);
+ mockLiteRT.generateRaw.mockResolvedValue('Paris');
+ (mockLlm.supportsToolCalling as jest.Mock)?.mockReturnValue?.(false);
+ mockLlm.generateResponseWithTools.mockResolvedValue({ fullResponse: 'Paris', toolCalls: [] } as never);
+});
+
+afterEach(() => {
+ // The registry is a process-singleton (resetStores doesn't touch it) — drop any test provider.
+ for (const id of (providerRegistry as any).getProviderIds?.() ?? []) {
+ if (id !== 'local') (providerRegistry as any).unregisterProvider(id);
+ }
+});
+
+describe('generationToolLoop routes to the engine the engines.ts owner names', () => {
+ it('GUARD (divergence input): a LiteRT model is active but not-yet-loaded — owner names litert, so the loop must route to LiteRT (not llama)', async () => {
+ // The inline conflation `engine==='litert' && liteRTService.isModelLoaded()` disagrees with the owner
+ // getActiveEngineService() (which does NOT check loaded) exactly here. This is the drift the guard catches.
+ mockLlm.isModelLoaded.mockReturnValue(false);
+ mockLiteRT.isModelLoaded.mockReturnValue(false); // not loaded → old inline flips to llama; owner stays litert
+ useAppStore.setState({
+ downloadedModels: [createDownloadedModel({ id: 'lrt', engine: 'litert' })],
+ activeModelId: 'lrt',
+ });
+ const conversationId = setupWithConversation({ messages: [] });
+
+ await runToolLoop(makeCtx(conversationId));
+
+ expect(engineOwnerNames()).toBe('litert');
+ expect(engineTheLoopUsed()).toBe(engineOwnerNames());
+ });
+
+ it('LiteRT active and loaded: loop routes to LiteRT, matching getActiveEngineService()', async () => {
+ mockLlm.isModelLoaded.mockReturnValue(false);
+ mockLiteRT.isModelLoaded.mockReturnValue(true);
+ useAppStore.setState({
+ downloadedModels: [createDownloadedModel({ id: 'lrt', engine: 'litert' })],
+ activeModelId: 'lrt',
+ });
+ const conversationId = setupWithConversation({ messages: [] });
+
+ await runToolLoop(makeCtx(conversationId));
+
+ expect(engineOwnerNames()).toBe('litert');
+ expect(engineTheLoopUsed()).toBe(engineOwnerNames());
+ });
+
+ it('llama active and loaded: loop routes to llama, matching getActiveEngineService()', async () => {
+ mockLlm.isModelLoaded.mockReturnValue(true);
+ mockLiteRT.isModelLoaded.mockReturnValue(false);
+ useAppStore.setState({
+ downloadedModels: [createDownloadedModel({ id: 'gg', engine: 'llama' })],
+ activeModelId: 'gg',
+ });
+ const conversationId = setupWithConversation({ messages: [] });
+
+ await runToolLoop(makeCtx(conversationId));
+
+ expect(engineOwnerNames()).toBe('llama');
+ expect(engineTheLoopUsed()).toBe(engineOwnerNames());
+ });
+
+ it('remote active (server registered, no local loaded): loop routes remote, matching isRemoteTextModelActive()', async () => {
+ mockLlm.isModelLoaded.mockReturnValue(false);
+ mockLiteRT.isModelLoaded.mockReturnValue(false);
+ const serverId = 'srv-1';
+ registerRemoteProvider(serverId);
+ useRemoteServerStore.setState({ activeServerId: serverId, activeRemoteTextModelId: 'remote-model' } as never);
+ useAppStore.setState({ downloadedModels: [], activeModelId: null });
+ const conversationId = setupWithConversation({ messages: [] });
+
+ await runToolLoop(makeCtx(conversationId));
+
+ expect(engineOwnerNames()).toBe('remote');
+ expect(engineTheLoopUsed()).toBe(engineOwnerNames());
+ });
+});
diff --git a/__tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx b/__tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx
new file mode 100644
index 000000000..5a732acd5
--- /dev/null
+++ b/__tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx
@@ -0,0 +1,79 @@
+/**
+ * RED-FLOW (UI integration, HEAVY entry point) — over-budget-but-WARNABLE curated LiteRT model.
+ *
+ * DEFECT (#510 7e652869): the onboarding ModelDownloadScreen pre-filters the curated LiteRT list to
+ * ONLY files that fit the RAM budget, so a curated model that EXCEEDS the budget but carries a
+ * "may exceed your device's memory / Download anyway" confirm (Gemma 4 E4B) is never rendered. Its
+ * warning branch in handleLiteRTDownload is dead code: on a device where E4B is over budget the user
+ * never sees the card, never sees the warning, and CANNOT start the download at all. Meanwhile the
+ * download button is disabled={!isCompatible}, and isCompatible duplicated the budget math and was
+ * false for the over-budget card — so even if it rendered, the warning was unreachable.
+ *
+ * SPEC (OGAM user's view): on a device where E4B exceeds the safe RAM budget but STILL has a warning,
+ * the E4B card IS offered — its download button is enabled and tapping it shows the
+ * "may exceed your device's memory" sheet with a "Download anyway" escape hatch (the guard). A curated
+ * model that is over budget AND has NO warning (E2B on this same 4GB device) stays hidden, because
+ * there is no safe way to offer it.
+ *
+ * Ground truth for the decision is the SINGLE owner curatedLiteRTDownloadWarning (over budget AND has
+ * confirm copy) + fileExceedsBudget (the budget primitive). At 4GB (frac 0.50 → 2.0GB budget):
+ * E2B = 2.41GB → over budget, NO warning → HIDDEN
+ * E4B = 3.41GB → over budget, HAS warning → OFFERED (warning-guarded)
+ *
+ * RED on HEAD: the pre-filter drops BOTH (both exceed 2.0GB), so the E4B card is ABSENT → warning
+ * unreachable. GREEN after fix: E4B present + its download tap surfaces the warning; E2B stays hidden.
+ *
+ * Real ModelDownloadScreen + real hardwareService/memoryBudget/curated registry + real CustomAlert;
+ * fakes ONLY at the native RAM-sensor boundary (installNativeBoundary). NEVER mocks our own code.
+ */
+import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary';
+
+// @react-navigation/native is OUTSIDE our system (an npm lib) — the only thing faked besides the device
+// boundary. The screen only uses navigation.replace (Skip / connected), never during this flow.
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {}, replace: () => {} }),
+ useRoute: () => ({ params: {} }),
+ useFocusEffect: () => {}, useIsFocused: () => true,
+}));
+
+describe('Curated LiteRT onboarding — an over-budget model that HAS a warning is offered (warning-guarded)', () => {
+ it('offers the over-budget E4B card and surfaces its memory warning, while the over-budget no-warning E2B stays hidden (4GB Android)', async () => {
+ // BOUNDARY: a 4GB Android device. frac(4GB)=0.50 → 2.0GB safe budget. Both curated LiteRT files
+ // (E2B 2.41GB, E4B 3.41GB) exceed it; only E4B carries a confirmDownload warning.
+ installNativeBoundary({ ram: { platform: 'android', totalBytes: 4 * GB, availBytes: 3 * GB } });
+
+ const React = require('react');
+ const rtl = requireRTL();
+ const { hardwareService } = require('../../../src/services/hardware');
+ const { ModelDownloadScreen } = require('../../../src/screens/ModelDownloadScreen');
+
+ // Prime the RAM cache the same way the screen's own effect does (getDeviceInfo → getTotalMemoryGB
+ // reads cachedDeviceInfo). This is a device-boundary read, not our state.
+ await hardwareService.getDeviceInfo();
+
+ const nav: any = { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {}, replace: () => {} };
+ const view = rtl.render(React.createElement(ModelDownloadScreen, { navigation: nav }));
+
+ // Wait for the async init effect to settle (loading → loaded).
+ await rtl.waitFor(() => { expect(view.getByText('Set Up Your AI')).toBeTruthy(); }, { timeout: 10000 });
+
+ // The over-budget-but-warnable E4B card IS offered. RED on HEAD: pre-filter dropped it → the
+ // curated LiteRT list was empty (both files over budget). Assert on the LiteRT card specifically
+ // (its testID + displayName) — the display name "Gemma 4 E2B/E4B" is also reused by a recommended
+ // GGUF card, so match the LiteRT surface, not a bare display-name string.
+ const e4bCard = await rtl.waitFor(() => view.getByTestId('litert-model-0'), { timeout: 10000 });
+ expect(rtl.within(e4bCard).getByText('Gemma 4 E4B')).toBeTruthy();
+
+ // The over-budget-with-NO-warning E2B (the OTHER curated LiteRT entry) stays hidden — no safe way
+ // to offer it. So exactly ONE curated LiteRT card renders; there is no second one.
+ expect(view.queryByTestId('litert-model-1')).toBeNull();
+
+ // The warning is REACHABLE: the E4B download button is enabled and tapping it surfaces the sheet.
+ // (On HEAD isCompatible was false → the button was disabled → even a rendered card couldn't warn.)
+ const e4bDownload = await rtl.waitFor(() => view.getByTestId('litert-model-0-download'), { timeout: 10000 });
+ await rtl.act(async () => { rtl.fireEvent.press(e4bDownload); });
+
+ expect(await rtl.waitFor(() => view.getByText(/may exceed your device's memory/), { timeout: 10000 })).toBeTruthy();
+ expect(view.getByText('Download anyway')).toBeTruthy();
+ }, 60000);
+});
diff --git a/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx b/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx
new file mode 100644
index 000000000..1e045c76c
--- /dev/null
+++ b/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * RED-FLOW (UI, rendered) — a memory-refused text-model load MUST show the user a "Load Anyway"
+ * override, never a dead-end "OK" alert. Asserted at the altitude that matters: what the USER SEES on
+ * the mounted ChatScreen, arrived at by real gestures. Real everything; fakes only the RAM sensor +
+ * native leaves.
+ *
+ * DEVICE GROUND TRUTH (2026-07-15, 12GB Android, Aggressive): loading a large text model ("qwythos")
+ * refused with "Failed to load model: … it needs ~6738MB but only 5030MB is available" — an OK-only
+ * alert, NO Load Anyway. Root cause: the pre-load context gate (llmSafetyChecks.resolveSafeContext)
+ * threw a plain Error; loadModelWithOverride only offers "Load Anyway" for an OverridableMemoryError,
+ * so a plain Error fell to the dead-end "Failed to load model" alert.
+ *
+ * The intersection reproduced (numbers pinned from the live [MEM-SM] trace, not guessed): Aggressive
+ * mode → residency makeRoomFor ADMITS (sizeMB 5376 < budget 10813, fits=true); then resolveSafeContext
+ * REFUSES because the model's weight estimate (6144MB, from the 5GB on-disk file) exceeds the raw
+ * available snapshot (5120MB). That refusal — signature "it needs ~XMB but only YMB is available" — is
+ * the device error and the fix site. So the test can ONLY pass when THAT gate refuses (it asserts the
+ * signature), never false-greening on the residency gate.
+ *
+ * RED on HEAD (fix reverted): plain Error → the alert reads "Failed to load model", no "Load Anyway".
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+import { GB } from '../../harness/nativeBoundary';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe('memory refusal shows "Load Anyway" on the rendered alert, not a dead-end (red-flow)', () => {
+ it('tapping send when the pre-load context gate refuses surfaces a "Load Anyway" override the user can tap', async () => {
+ // Pinned to iOS ON PURPOSE: the reclaim-aware gate fix (textPreloadGateReclaimAware) makes the
+ // Android-aggressive cell ADMIT via the LMK reclaim credit, so the refusal this test needs only
+ // survives on iOS (no reclaim credit — the gate reads raw). residency still admits (3.5GB record);
+ // resolveSafeContext refuses on the raw 5GB on-disk weight estimate. deferInitialLoad → first send
+ // triggers the real lazy load. This is why the two memory tests don't contradict each other.
+ const h = await setupChatScreen({
+ engine: 'llama',
+ platform: 'ios',
+ modelFileSizeBytes: 3.5 * GB,
+ ram: { platform: 'ios', totalBytes: 12 * GB, availBytes: 5 * GB },
+ deferInitialLoad: true,
+ });
+
+ /* eslint-disable @typescript-eslint/no-var-requires */
+ const React = require('react');
+ const { ModelLoadingModeSelector } = require('../../../src/components/settings/textGenAdvancedSections');
+ const { startLoadPolicySync } = require('../../../src/services/loadPolicySync');
+ /* eslint-enable @typescript-eslint/no-var-requires */
+
+ // BOUNDARY: the ACTUAL on-disk model file is 5GB — resolveSafeContext sizes the model from the real
+ // file (RNFS.stat), so its weight estimate (6144MB) exceeds the 5120MB raw-available snapshot and it
+ // refuses. (The harness seeds a 500MB placeholder; a real 5GB download is the device reality.)
+ h.boundary.fs!.seedFile('/docs/models/ggml-small.gguf', Math.round(5 * GB));
+
+ h.render();
+
+ // Real app wiring: App.tsx boots this so the settings toggle drives the residency manager.
+ const stopSync = startLoadPolicySync();
+ // GESTURE: turn on Aggressive via the real segmented control (the device was in Aggressive).
+ const toggle = h.rtl.render(React.createElement(ModelLoadingModeSelector, {}));
+ h.rtl.fireEvent.press(toggle.getByTestId('model-loading-mode-aggressive-button'));
+ await h.rtl.waitFor(() => { expect(require('../../../src/services/modelResidency').modelResidencyManager.getLoadPolicy()).toBe('aggressive'); });
+
+ // Precondition: no refusal surface yet.
+ expect(h.view!.queryByText('Load Anyway')).toBeNull();
+
+ // GESTURE: the real first-send lazy load → residency admits → resolveSafeContext refuses.
+ await h.tapSend('hello');
+
+ // TERMINAL ARTIFACT: the override alert offers "Load Anyway", AND its body carries resolveSafeContext's
+ // signature ("it needs ~") so this can only pass when THAT gate (the fix site) refuses — no false-green
+ // on the residency gate. RED on HEAD: plain Error → dead-end "Failed to load model", no "Load Anyway".
+ await h.rtl.waitFor(() => {
+ expect(h.view!.queryByText('Load Anyway')).not.toBeNull();
+ }, { timeout: 8000 });
+ expect(h.view!.queryByText(/it needs ~/)).not.toBeNull();
+ expect(h.view!.queryByText(/Failed to load model/)).toBeNull();
+
+ stopSync();
+ }, 30000);
+});
diff --git a/__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx b/__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx
new file mode 100644
index 000000000..9eab0b5ab
--- /dev/null
+++ b/__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx
@@ -0,0 +1,136 @@
+/**
+ * RED-FLOW (UI integration, HEAVY entry point) — RAM DISPLAY AGREEMENT across surfaces.
+ *
+ * The SAME loaded text model must report the SAME RAM footprint everywhere it is shown. The residency
+ * chip on the Models manager sheet (`models-row-text-ram`) reads the resident's registered `sizeMB`,
+ * which activeModelService computes with the backend-aware `textOverheadMultiplier(inferenceBackend)`
+ * (2.2× on a GPU/NPU backend). The Select-Model picker's "Currently Loaded" RAM label
+ * (`currently-loaded-model-ram`) computed the figure with a FIXED 1.5× (hardwareService.formatModelRam
+ * default), so on a non-CPU backend the two surfaces disagree for the identical model (device 2026-07-14).
+ *
+ * SPEC (user's view): loaded a 2GB model on the GPU backend → the picker RAM label and the sheet RAM chip
+ * show the SAME number of GB. On HEAD the picker shows ~3.0 GB (1.5×) while the sheet chip shows 4.4 GB
+ * (2.2×) → RED. The fix routes the picker label through the SAME backend-aware multiplier owner.
+ *
+ * Real HomeScreen + real picker/sheet gestures + real activeModelService/modelResidencyManager; fakes only
+ * at the native llama/fs/RAM boundary. Backend is switched to GPU via the REAL BackendSelector control
+ * (the same store action the settings screen dispatches), before the load, so the resident registers at 2.2×.
+ */
+import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary';
+import { createDownloadedModel } from '../../utils/factories';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => ({ params: {} }),
+ useFocusEffect: () => {}, useIsFocused: () => true,
+}));
+
+/** Invoke the onPress bound at/above a testID host (AnimatedPressable's onPress lives on the composite). */
+function pressByWalkingUp(node: unknown): void {
+ type N = { props?: Record; parent?: N | null } | null;
+ let n = node as N;
+ for (let d = 0; n && d < 12; d++) {
+ const op = n.props?.onPress;
+ if (typeof op === 'function') { (op as () => void)(); return; }
+ n = n.parent ?? null;
+ }
+ throw new Error('no onPress found walking up from the node');
+}
+
+/** Flatten a rendered node's text content (children may be nested Text elements, arrays, or a string). */
+function renderedText(node: { props?: { children?: unknown } }): string {
+ const walk = (c: unknown): string => {
+ if (c == null || c === false) return '';
+ if (typeof c === 'string' || typeof c === 'number') return String(c);
+ if (Array.isArray(c)) return c.map(walk).join('');
+ const el = c as { props?: { children?: unknown } };
+ return el.props ? walk(el.props.children) : '';
+ };
+ return walk(node.props?.children);
+}
+
+/**
+ * The RAM figure (GB) from a rendered RAM surface. The picker label reads "quant • 2.00 GB • ~3.0 GB RAM"
+ * — the RAM figure is the one before "RAM", NOT the leading disk-size GB. The sheet chip is just "4.4 GB".
+ * Prefer the "GB RAM" match; fall back to the sole GB token (the chip).
+ */
+function ramGb(node: { props?: { children?: unknown } }): string {
+ const text = renderedText(node);
+ const withRam = /([\d.]+)\s*GB\s*RAM/.exec(text);
+ if (withRam) return withRam[1];
+ const bare = /([\d.]+)\s*GB/.exec(text);
+ if (!bare) throw new Error(`no "N GB" token in: ${JSON.stringify(text)}`);
+ return bare[1];
+}
+
+describe('RAM display agreement — picker label matches the residency chip for the same model', () => {
+ it('shows the SAME GB figure on the manager-sheet chip and the picker "Currently Loaded" label (GPU backend)', async () => {
+ const boundary = installNativeBoundary({ llama: true, fs: true, ram: { platform: 'android', totalBytes: 12 * GB, availBytes: 8 * GB } });
+ const g = globalThis as unknown as { window?: Record };
+ if (!g.window) g.window = { dispatchEvent: () => true, addEventListener: () => {}, removeEventListener: () => {} };
+
+ const React = require('react');
+ const rtl = requireRTL();
+ const { hardwareService } = require('../../../src/services/hardware');
+ const { useAppStore } = require('../../../src/stores');
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default ?? require('@react-native-async-storage/async-storage');
+ const { activeModelService } = require('../../../src/services/activeModelService');
+ const { HomeScreen } = require('../../../src/screens/HomeScreen');
+ const { ResidentsProbe } = require('../../harness/ResidentsProbe');
+ const { BackendSelector } = require('../../../src/components/settings/textGenAdvancedSections');
+ const { ModelSelectorModal } = require('../../../src/components/ModelSelectorModal');
+ const { llmService } = require('../../../src/services/llm');
+
+ // BOUNDARY: a downloaded 2GB model = the persisted record + the file on disk. 2GB makes the two
+ // multipliers visibly disagree (1.5× → 3.0 GB, 2.2× → 4.4 GB) yet fit the 8GB-avail budget.
+ const docs = boundary.fs!.DocumentDirectoryPath;
+ const modelPath = `${docs}/models/ggml-small.gguf`;
+ boundary.fs!.seedFile(modelPath, 500 * 1024 * 1024);
+ const model = createDownloadedModel({ id: 'm', name: 'Test Model', engine: 'llama', filePath: modelPath, fileName: 'ggml-small.gguf', fileSize: 2 * GB });
+ await AsyncStorage.setItem('@local_llm/downloaded_models', JSON.stringify([model]));
+ await hardwareService.refreshMemoryInfo();
+ require('../../../src/components/onboarding/spotlightState').setPendingSpotlight(null);
+ useAppStore.setState({ checklistDismissed: true, shownSpotlights: { input: true, voiceHint: true, imageSettings: true } });
+
+ const nav = { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} };
+ const view = rtl.render(React.createElement(
+ React.Fragment, null,
+ React.createElement(ResidentsProbe, {}),
+ React.createElement(HomeScreen, { navigation: nav }),
+ ));
+ await rtl.waitFor(() => { expect(useAppStore.getState().downloadedModels.length).toBeGreaterThan(0); }, { timeout: 10000 });
+
+ // GESTURE: select the text model the way a user does — open the picker, tap the row.
+ rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('browse-models-button')));
+ const rows = await rtl.waitFor(() => { const r = view.queryAllByTestId('model-item'); expect(r.length).toBeGreaterThan(0); return r; }, { timeout: 10000 });
+ rtl.fireEvent.press(rows[0]);
+ await rtl.waitFor(() => { expect(useAppStore.getState().activeModelId).toBe('m'); }, { timeout: 10000 });
+
+ // GESTURE: switch the inference backend to GPU (OpenCL) via the REAL BackendSelector control — the same
+ // store action the settings screen dispatches — BEFORE the load, so the resident registers at 2.2×.
+ const settings = rtl.render(React.createElement(BackendSelector, {}));
+ rtl.fireEvent.press(await rtl.waitFor(() => settings.getByTestId('backend-opencl-button')));
+ await rtl.waitFor(() => { expect(useAppStore.getState().settings.inferenceBackend).toBe('opencl'); });
+ settings.unmount();
+
+ // The REAL load path (residency manager registers the text resident at the GPU-aware sizeMB).
+ await rtl.act(async () => { await activeModelService.loadTextModel('m'); });
+ await rtl.waitFor(() => { expect(view.getByTestId('probe-residents').props.children).toContain('text'); }, { timeout: 10000 });
+
+ // The manager-sheet RAM chip (residency surface) — open the sheet, then read its GB figure.
+ await rtl.act(async () => { pressByWalkingUp(view.getByTestId('models-summary')); });
+ await rtl.waitFor(() => { expect(view.queryByTestId('models-row-text')).not.toBeNull(); }, { timeout: 10000 });
+ const chipGb = ramGb(await rtl.waitFor(() => view.getByTestId('models-row-text-ram'), { timeout: 10000 }));
+
+ // The Select-Model picker "Currently Loaded" RAM label — mounted with the real loaded path.
+ const picker = rtl.render(React.createElement(ModelSelectorModal, {
+ visible: true, onClose: () => {}, onSelectModel: () => {}, onUnloadModel: () => {}, isLoading: false,
+ currentModelPath: llmService.getLoadedModelPath(),
+ }));
+ const pickerGb = ramGb(await rtl.waitFor(() => picker.getByTestId('currently-loaded-model-ram'), { timeout: 10000 }));
+
+ // Same model, same backend → the two surfaces must show the SAME RAM figure.
+ // RED on HEAD: picker=3.0 (fixed 1.5×) vs chip=4.4 (backend-aware 2.2×).
+ expect(pickerGb).toBe(chipGb);
+ }, 60000);
+});
diff --git a/__tests__/integration/memory/textPreloadGateReclaimAware.rendered.redflow.test.tsx b/__tests__/integration/memory/textPreloadGateReclaimAware.rendered.redflow.test.tsx
new file mode 100644
index 000000000..8896bfdbb
--- /dev/null
+++ b/__tests__/integration/memory/textPreloadGateReclaimAware.rendered.redflow.test.tsx
@@ -0,0 +1,135 @@
+/**
+ * RED-FLOW (UI, rendered) — the TEXT pre-load memory gate must read the SAME reclaim-aware available RAM
+ * as the residency gate, so the two can never disagree. On Android the low-memory killer hands background
+ * apps' physical pages to the foreground app, so a clean mmap'd GGUF's true ceiling is the physical model
+ * budget (modelMemoryBudgetMB), NOT the instantaneous raw snapshot. The single owner of that number is
+ * memoryBudget.effectiveAvailableMB (its header: "so they can never disagree").
+ *
+ * DEVICE GROUND TRUTH (qwythos, 12GB Android, Aggressive): the residency gate ADMITTED the model
+ * (reclaim-aware budget), then the SEPARATE text pre-load gate (llmSafetyChecks via llm.ts getMem) REFUSED
+ * on the RAW snapshot — "it needs ~6738MB but only 5030MB available" — refusing a model the reclaim-aware
+ * owner had just accepted. llm.ts's getMem fed checkMemoryForModel/resolveSafeContext the raw
+ * hardwareService.getAppMemoryUsage() snapshot (total−used), never routed through effectiveAvailableMB.
+ * Commit ea877f57 unified Android reclaim-awareness across the residency + override paths but never reached
+ * this THIRD path.
+ *
+ * THE INTERSECTION (numbers pinned, not guessed — mirrors loadAnywayCardRendered.redflow's cell):
+ * - 12GB total / 5GB raw-available, ANDROID, Aggressive.
+ * - declared model size 3.5GB → residency makeRoomFor ADMITS (sizeMB 5376 < aggressive budget 10813). So
+ * residency does NOT refuse first — it cannot mask this gate (the loadAnywayCardRendered false-green trap).
+ * - the ACTUAL on-disk file is 5GB → the pre-load gate sizes weights from RNFS.stat at 6144MB (5GB×1.2).
+ * - RAW available (5120MB) < 6144MB+200 → the raw gate REFUSES.
+ * - reclaim-aware Android-aggressive available = max(5120, 10813) = 10813MB > 6144MB+200 → the gate ADMITS.
+ *
+ * So the ONLY difference the fix makes at this exact cell is: raw refuses (RED) vs reclaim-aware admits and
+ * the model loads + generates (GREEN). That is the user-visible behavioral difference.
+ *
+ * RED on HEAD (getMem raw): the pre-load gate refuses → the model never loads → the send surfaces the
+ * "it needs ~" refusal alert and no assistant reply renders. [MEM-SM] availMB=5120.
+ * GREEN after fix (getMem reclaim-aware): the gate admits → the model loads → the scripted reply renders.
+ * [MEM-SM] availMB=10813.
+ */
+import { setupChatScreen } from '../../harness/chatHarness';
+import { GB } from '../../harness/nativeBoundary';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
+ useRoute: () => require('../../harness/chatHarness').routeHolder,
+ useFocusEffect: () => {},
+ useIsFocused: () => true,
+}));
+
+describe('text pre-load gate reads reclaim-aware available (rendered, red-flow)', () => {
+ it('ANDROID Aggressive: residency admits and the reclaim-aware pre-load gate loads the model (raw gate would refuse)', async () => {
+ // declared 3.5GB → residency (aggressive) admits (sizeMB 5376 < budget 10813). deferInitialLoad → the
+ // first send triggers the real lazy load. android + 12GB total / 5GB raw-avail = the qwythos profile.
+ const h = await setupChatScreen({
+ engine: 'llama',
+ platform: 'android',
+ modelFileSizeBytes: 3.5 * GB,
+ ram: { platform: 'android', totalBytes: 12 * GB, availBytes: 5 * GB },
+ deferInitialLoad: true,
+ });
+
+ const React = require('react');
+ const { ModelLoadingModeSelector } = require('../../../src/components/settings/textGenAdvancedSections');
+ const { startLoadPolicySync } = require('../../../src/services/loadPolicySync');
+
+ // BOUNDARY: the ACTUAL on-disk model file is 5GB — the pre-load gate sizes the model from the real file
+ // (RNFS.stat), so its weight estimate (6144MB) exceeds the 5120MB RAW-available snapshot. The reclaim-
+ // aware owner credits the physical budget (10813MB) instead. (The harness seeds a 500MB placeholder; a
+ // real 5GB download is the device reality.)
+ h.boundary.fs!.seedFile('/docs/models/ggml-small.gguf', Math.round(5 * GB));
+
+ h.render();
+
+ // Real app wiring: App.tsx boots this so the settings toggle drives the residency manager.
+ const stopSync = startLoadPolicySync();
+ // GESTURE: turn on Aggressive via the real segmented control (the device was in Aggressive).
+ const toggle = h.rtl.render(React.createElement(ModelLoadingModeSelector, {}));
+ h.rtl.fireEvent.press(toggle.getByTestId('model-loading-mode-aggressive-button'));
+ await h.rtl.waitFor(() => { expect(require('../../../src/services/modelResidency').modelResidencyManager.getLoadPolicy()).toBe('aggressive'); });
+ toggle.unmount();
+
+ // Precondition: no reply and no refusal surface yet.
+ expect(h.view!.queryByText(/Paris\./)).toBeNull();
+ expect(h.view!.queryByText(/it needs ~/)).toBeNull();
+
+ // GESTURE: the real first-send lazy load → residency admits → the pre-load gate decides.
+ await h.send('what is the capital of France', { text: 'Paris.' });
+
+ // TERMINAL ARTIFACT — the user-visible difference the fix makes:
+ // GREEN (reclaim-aware): the gate admits, the model loads, the scripted reply renders.
+ // RED on HEAD (raw): the gate refuses, no reply, the "it needs ~" refusal alert shows instead.
+ await h.rtl.waitFor(() => {
+ expect(h.view!.queryByText(/Paris\./)).not.toBeNull();
+ }, { timeout: 8000 });
+ // And the raw-snapshot refusal must NOT appear (it would on HEAD).
+ expect(h.view!.queryByText(/it needs ~/)).toBeNull();
+
+ stopSync();
+ }, 30000);
+
+ it('iOS UNCHANGED: the pre-load gate stays RAW (jetsam kills us, no reclaim credit) so the same model is refused', async () => {
+ // Identical intersection on iOS. effectiveAvailableMB returns the RAW snapshot on iOS (no LMK reclaim —
+ // jetsam kills US, not background apps), so the pre-load gate must STILL refuse the 5GB-on-disk model on
+ // 5GB raw-available. This proves the fix did NOT alter iOS jetsam behavior: iOS availability is untouched.
+ const h = await setupChatScreen({
+ engine: 'llama',
+ platform: 'ios',
+ modelFileSizeBytes: 3.5 * GB,
+ ram: { platform: 'ios', totalBytes: 12 * GB, availBytes: 5 * GB },
+ deferInitialLoad: true,
+ });
+
+ const React = require('react');
+ const { ModelLoadingModeSelector } = require('../../../src/components/settings/textGenAdvancedSections');
+ const { startLoadPolicySync } = require('../../../src/services/loadPolicySync');
+
+ // BOUNDARY: the ACTUAL on-disk file is 5GB → the gate sizes weights at 6144MB > 5120MB raw available.
+ h.boundary.fs!.seedFile('/docs/models/ggml-small.gguf', Math.round(5 * GB));
+
+ h.render();
+
+ const stopSync = startLoadPolicySync();
+ // GESTURE: Aggressive (matches the Android case) — but on iOS aggressive gives NO reclaim credit.
+ const toggle = h.rtl.render(React.createElement(ModelLoadingModeSelector, {}));
+ h.rtl.fireEvent.press(toggle.getByTestId('model-loading-mode-aggressive-button'));
+ await h.rtl.waitFor(() => { expect(require('../../../src/services/modelResidency').modelResidencyManager.getLoadPolicy()).toBe('aggressive'); });
+ toggle.unmount();
+
+ expect(h.view!.queryByText(/it needs ~/)).toBeNull();
+
+ // GESTURE: send → residency admits (iOS physical cap holds the 3.5GB spec) → the RAW pre-load gate refuses.
+ await h.send('what is the capital of France', { text: 'Paris.' });
+
+ // TERMINAL ARTIFACT: iOS still refuses on the raw snapshot — the "it needs ~" refusal renders and the reply
+ // does NOT. iOS jetsam behavior is unchanged by the fix.
+ await h.rtl.waitFor(() => {
+ expect(h.view!.queryByText(/it needs ~/)).not.toBeNull();
+ }, { timeout: 8000 });
+ expect(h.view!.queryByText(/Paris\./)).toBeNull();
+
+ stopSync();
+ }, 30000);
+});
diff --git a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx
new file mode 100644
index 000000000..a362b87de
--- /dev/null
+++ b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx
@@ -0,0 +1,131 @@
+/**
+ * Detail "Available Files" device-fit hint — the single owned budget (memoryBudget.fileExceedsBudget).
+ *
+ * SPEC (the OGAM user's view): in a model's detail view, the "Available Files" list offers exactly the
+ * quant files that FIT this device's RAM budget and hides the ones that don't — and that fit decision is
+ * the ONE owned primitive `fileExceedsBudget` (device-tier fraction of TOTAL RAM), never a hand-rolled
+ * copy of the budget arithmetic that could drift from the download-warning / picker / browse-list copies.
+ *
+ * This mounts the REAL ModelsScreen, arrives at a model's detail via a real search+tap, and asserts the
+ * rendered file list against `fileExceedsBudget`'s verdict for each file: the under-budget quant renders,
+ * the over-budget quant is absent. Boundary fakes only: native download + fs + RAM (installNativeBoundary)
+ * and the HuggingFace network transport. The budget math, screen, hooks, ModelCard all run REAL.
+ *
+ * Falsification (DRY): the expected present/absent set is computed from `fileExceedsBudget` itself, so if
+ * a caller's inline copy of the formula drifts from the owner (different fraction, wrong comparison, a
+ * unit slip), the rendered list stops matching the owner's verdict and this test goes red.
+ */
+import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary';
+
+const MODEL_ID = 'org/fit-hint';
+
+describe('detail Available Files fit hint matches the owned fileExceedsBudget verdict (rendered)', () => {
+ it('offers exactly the files fileExceedsBudget says fit — hides the over-budget quant', async () => {
+ // Device: a 6GB Android phone → budget = 6 * modelBudgetFraction(6)=0.60 = 3.6GB.
+ installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 6 * GB, availBytes: 4 * GB } });
+
+ // Two quant files straddling the budget: a 2GB (fits) and a 5GB (exceeds).
+ const fitFile = { name: 'model-Q4_K_M.gguf', size: 2 * GB, quantization: 'Q4_K_M', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-Q4_K_M.gguf` };
+ const bigFile = { name: 'model-Q8_0.gguf', size: 5 * GB, quantization: 'Q8_0', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-Q8_0.gguf` };
+ const modelInfo = { id: MODEL_ID, name: 'Fit Hint Model', author: 'org', description: 'test', downloads: 50, likes: 1, tags: [], lastModified: '', files: [fitFile, bigFile] };
+ jest.doMock('../../../src/services/huggingface', () => ({
+ huggingFaceService: {
+ searchModels: jest.fn(async () => [modelInfo]),
+ getModelFiles: jest.fn(async () => [fitFile, bigFile]),
+ getModelDetails: jest.fn(async () => modelInfo),
+ getDownloadUrl: (m: string, f: string, r = 'main') => `https://hf.co/${m}/resolve/${r}/${f}`,
+ formatModelSize: jest.fn(() => '2.0 GB'),
+ formatFileSize: jest.fn((b: number) => `${(b / GB).toFixed(1)} GB`),
+ },
+ }));
+
+ /* eslint-disable @typescript-eslint/no-var-requires */
+ const React = require('react');
+ const { render, fireEvent, waitFor, act } = requireRTL();
+ const { hardwareService } = require('../../../src/services/hardware');
+ const { fileExceedsBudget } = require('../../../src/services/memoryBudget');
+ const { ModelsScreen } = require('../../../src/screens/ModelsScreen');
+ /* eslint-enable @typescript-eslint/no-var-requires */
+
+ await hardwareService.refreshMemoryInfo();
+ const ramGB = hardwareService.getTotalMemoryGB();
+
+ // The owner's verdict is the source of truth for what the list must show.
+ expect(fileExceedsBudget(fitFile.size, ramGB)).toBe(false); // fits → must render
+ expect(fileExceedsBudget(bigFile.size, ramGB)).toBe(true); // exceeds → must be hidden
+
+ const utils = render(React.createElement(ModelsScreen, {}));
+ const { getByTestId, getByText, queryByText } = utils;
+
+ await act(async () => { fireEvent.changeText(getByTestId('search-input'), 'fit'); });
+ await act(async () => {
+ fireEvent(getByTestId('search-input'), 'submitEditing');
+ await new Promise((r) => setTimeout(r, 600));
+ });
+ await waitFor(() => expect(getByText('Fit Hint Model')).toBeTruthy(), { timeout: 6000 });
+ await act(async () => { fireEvent.press(getByText('Fit Hint Model')); });
+ await waitFor(() => expect(getByTestId('model-detail-screen')).toBeTruthy(), { timeout: 4000 });
+
+ // Wait for the files to load (the fitting file card renders).
+ await waitFor(() => expect(getByText('model-Q4_K_M')).toBeTruthy(), { timeout: 4000 });
+
+ // TERMINAL artifact: the list offers the under-budget quant and HIDES the over-budget one —
+ // exactly the fileExceedsBudget verdict. (Display names strip the .gguf extension.)
+ expect(queryByText('model-Q4_K_M')).not.toBeNull();
+ expect(queryByText('model-Q8_0')).toBeNull();
+ }, 30000);
+
+ it('BOUNDARY (M5a): a file EXACTLY at the budget is treated as over-budget (>=), one just under fits', async () => {
+ // Pins the exact budget comparison (the `>=` in fileExceedsBudget). The verifier's `>=`→`>` mutant
+ // survived because no test straddled equality. Device chosen so the budget is a WHOLE number of
+ // bytes: 4GB × balanced 0.50 = EXACTLY 2.0 GB (2147483648 B) — the only tier where integer bytes can
+ // hit exact equality (0.60×6GB is 3.6GB, not an integer, so >= and > can't differ there). A file of
+ // EXACTLY 2.0GB must be HIDDEN (>= = exceeds); 2.0GB−1byte must SHOW. Reverting to `>` flips the
+ // exact-budget file to "fits" → this test goes red (mutant killed).
+ installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 4 * GB, availBytes: 3 * GB } });
+
+ /* eslint-disable @typescript-eslint/no-var-requires */
+ const { modelBudgetFraction } = require('../../../src/services/memoryBudget');
+ /* eslint-enable @typescript-eslint/no-var-requires */
+ const budgetBytes = 4 * modelBudgetFraction(4, 'android', 'balanced') * GB; // 4 × 0.50 × GB = exactly 2.0 GB (integer bytes)
+ const atBudget = { name: 'model-atbudget.gguf', size: budgetBytes, quantization: 'Q5', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-atbudget.gguf` };
+ const underBudget = { name: 'model-under.gguf', size: budgetBytes - 1, quantization: 'Q4', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-under.gguf` };
+ const modelInfo = { id: MODEL_ID, name: 'Boundary Model', author: 'org', description: 'test', downloads: 50, likes: 1, tags: [], lastModified: '', files: [underBudget, atBudget] };
+ jest.doMock('../../../src/services/huggingface', () => ({
+ huggingFaceService: {
+ searchModels: jest.fn(async () => [modelInfo]),
+ getModelFiles: jest.fn(async () => [underBudget, atBudget]),
+ getModelDetails: jest.fn(async () => modelInfo),
+ getDownloadUrl: (m: string, f: string, r = 'main') => `https://hf.co/${m}/resolve/${r}/${f}`,
+ formatModelSize: jest.fn(() => '2.0 GB'),
+ formatFileSize: jest.fn((b: number) => `${(b / GB).toFixed(2)} GB`),
+ },
+ }));
+
+ /* eslint-disable @typescript-eslint/no-var-requires */
+ const React = require('react');
+ const { render, fireEvent, waitFor, act } = requireRTL();
+ const { hardwareService } = require('../../../src/services/hardware');
+ const { fileExceedsBudget } = require('../../../src/services/memoryBudget');
+ const { ModelsScreen } = require('../../../src/screens/ModelsScreen');
+ /* eslint-enable @typescript-eslint/no-var-requires */
+
+ await hardwareService.refreshMemoryInfo();
+ const ramGB = hardwareService.getTotalMemoryGB();
+ // Owner verdict is the source of truth: exact-budget EXCEEDS (>=), just-under FITS.
+ expect(fileExceedsBudget(atBudget.size, ramGB)).toBe(true);
+ expect(fileExceedsBudget(underBudget.size, ramGB)).toBe(false);
+
+ const { getByTestId, getByText, queryByText } = render(React.createElement(ModelsScreen, {}));
+ await act(async () => { fireEvent.changeText(getByTestId('search-input'), 'boundary'); });
+ await act(async () => { fireEvent(getByTestId('search-input'), 'submitEditing'); await new Promise((r) => setTimeout(r, 600)); });
+ await waitFor(() => expect(getByText('Boundary Model')).toBeTruthy(), { timeout: 6000 });
+ await act(async () => { fireEvent.press(getByText('Boundary Model')); });
+ await waitFor(() => expect(getByTestId('model-detail-screen')).toBeTruthy(), { timeout: 4000 });
+ await waitFor(() => expect(getByText('model-under')).toBeTruthy(), { timeout: 4000 });
+
+ // TERMINAL artifact: just-under renders; EXACTLY-at-budget is hidden (the `>=` boundary).
+ expect(queryByText('model-under')).not.toBeNull();
+ expect(queryByText('model-atbudget')).toBeNull();
+ }, 30000);
+});
diff --git a/__tests__/integration/models/mmProjHydrationClassify.test.ts b/__tests__/integration/models/mmProjHydrationClassify.test.ts
new file mode 100644
index 000000000..8704b6c7c
--- /dev/null
+++ b/__tests__/integration/models/mmProjHydrationClassify.test.ts
@@ -0,0 +1,85 @@
+/**
+ * DRY / single-owner — the "is this download row a projector?" predicate in downloadHydration must be the
+ * canonical isMMProjFile (src/services/mmproj.ts), not a divergent copy.
+ *
+ * downloadHydration.isMmProjFileName only matched 'mmproj' — it MISSED the 'projector' and 'clip' names the
+ * canonical isMMProjFile (introduced by #510 c815752f) also recognises. getParentRows uses that predicate as
+ * the belt-and-suspenders filter that drops an ORPHANED projector sidecar row (one whose parent lost its
+ * mmProjDownloadId back-link after a retry — see modelManager/restore.ts). With the divergent predicate a
+ * '*-projector.gguf' / '*-clip.gguf' sidecar slips through and hydrates as a PHANTOM standalone model entry
+ * in the Download Manager.
+ *
+ * Real hydrateDownloadStore + real downloadStore; only the native backgroundDownloadService snapshot (the
+ * device boundary) is faked. Asserts the observable outcome: no phantom projector entry appears in the store.
+ */
+import { hydrateDownloadStore } from '../../../src/services/downloadHydration';
+import { useDownloadStore } from '../../../src/stores/downloadStore';
+
+jest.mock('../../../src/services/backgroundDownloadService', () => ({
+ backgroundDownloadService: {
+ isAvailable: jest.fn(() => true),
+ getActiveDownloads: jest.fn(),
+ },
+}));
+
+const { backgroundDownloadService } = jest.requireMock('../../../src/services/backgroundDownloadService');
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ backgroundDownloadService.isAvailable.mockReturnValue(true);
+ useDownloadStore.setState({ downloads: {}, downloadIdIndex: {} });
+});
+
+describe('downloadHydration classifies a projector sidecar via the canonical isMMProjFile', () => {
+ // A vision model download whose projector sidecar row has NO mmProjDownloadId back-link (the post-retry
+ // orphan case restore.ts documents) and is named with 'projector' rather than 'mmproj'.
+ const snapshotWith = (projectorFileName: string) => [
+ {
+ downloadId: 'dl-model',
+ modelId: 'author/vision-model',
+ modelKey: 'author/vision-model/vision-Q4_K_M.gguf',
+ fileName: 'vision-Q4_K_M.gguf',
+ modelType: 'text',
+ status: 'running',
+ bytesDownloaded: 500,
+ totalBytes: 1000,
+ combinedTotalBytes: 1000,
+ createdAt: 1000,
+ // no mmProjDownloadId — the back-link was lost, so ONLY the filename predicate can catch the sidecar
+ },
+ {
+ downloadId: 'dl-proj',
+ modelId: 'author/vision-model',
+ modelKey: `author/vision-model/${projectorFileName}`,
+ fileName: projectorFileName,
+ modelType: 'text',
+ status: 'running',
+ bytesDownloaded: 100,
+ totalBytes: 200,
+ combinedTotalBytes: 200,
+ createdAt: 1001,
+ },
+ ];
+
+ it('a *-projector.gguf sidecar is NOT hydrated as a standalone model entry', async () => {
+ backgroundDownloadService.getActiveDownloads.mockResolvedValue(snapshotWith('vision-Q4_K_M-projector.gguf'));
+
+ await hydrateDownloadStore();
+
+ const downloads = useDownloadStore.getState().downloads;
+ // The real model row is present…
+ expect(downloads['author/vision-model/vision-Q4_K_M.gguf']).toBeDefined();
+ // …but the projector sidecar must NOT appear as its own entry.
+ expect(downloads['author/vision-model/vision-Q4_K_M-projector.gguf']).toBeUndefined();
+ });
+
+ it('a *-clip.gguf sidecar is NOT hydrated as a standalone model entry', async () => {
+ backgroundDownloadService.getActiveDownloads.mockResolvedValue(snapshotWith('vision-Q4_K_M-clip.gguf'));
+
+ await hydrateDownloadStore();
+
+ const downloads = useDownloadStore.getState().downloads;
+ expect(downloads['author/vision-model/vision-Q4_K_M.gguf']).toBeDefined();
+ expect(downloads['author/vision-model/vision-Q4_K_M-clip.gguf']).toBeUndefined();
+ });
+});
diff --git a/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx b/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx
new file mode 100644
index 000000000..3cb63e324
--- /dev/null
+++ b/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx
@@ -0,0 +1,89 @@
+/**
+ * RENDERED — the Home "Speech" model picker (WhisperPickerSheet) must reflect an STT download that
+ * is tracked in the CANONICAL download store, not only the ones it started itself.
+ *
+ * Device 2026-07-15: with a voice model downloading, opening Home → Models → Speech showed the plain
+ * download icon with NO progress, while the Models-screen Transcription tab showed the live bar. Root
+ * cause (a DRY/SOLID break): the picker read ONLY whisperStore.downloadProgressById, while the tab read
+ * the canonical downloadStore (+ a whisper-store fallback). A download registered in the canonical store
+ * (started elsewhere, or rehydrated after a relaunch) was invisible to the picker.
+ *
+ * Fix under test: both surfaces now read ONE owner (useSttDownloadState). This mounts the real picker and
+ * seeds the canonical store the way the native background-download service registers an in-flight entry —
+ * the boundary's output — then asserts the matching row renders the transferring % (and a queued entry
+ * renders the clock). RED (revert the picker to read whisperStore only): the canonical entry is invisible,
+ * the row shows the download icon, and these queries throw.
+ */
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { WhisperPickerSheet } from '../../../src/components/models/WhisperPickerSheet';
+import { useDownloadStore } from '../../../src/stores/downloadStore';
+import { useWhisperStore } from '../../../src/stores/whisperStore';
+import type { DownloadEntry } from '../../../src/utils/downloadStatus';
+
+const TOTAL = 142 * 1024 * 1024; // ggml-base.en is 142 MB
+
+// A canonical download-store entry as the native background-download service produces it. The whisper
+// download id is prefixed `whisper-`; the picker's owner strips it back to the bare model id 'base.en'.
+const sttEntry = (over: Partial): DownloadEntry => ({
+ modelKey: 'whisper-base.en',
+ downloadId: 'dl-whisper-base.en',
+ modelId: 'whisper-base.en',
+ fileName: 'ggml-base.en.bin',
+ quantization: '',
+ modelType: 'stt',
+ status: 'running',
+ bytesDownloaded: Math.round(0.42 * TOTAL),
+ totalBytes: TOTAL,
+ combinedTotalBytes: TOTAL,
+ progress: 0.42,
+ createdAt: 0,
+ ...over,
+});
+
+describe('WhisperPickerSheet reflects a canonical-store STT download (device 2026-07-15)', () => {
+ beforeEach(() => {
+ useDownloadStore.setState({ downloads: {} });
+ useWhisperStore.setState({ downloadProgressById: {}, downloadedModelId: null, presentModelIds: [], isModelLoading: false });
+ });
+
+ it('shows the transferring % on the matching row — not the plain download icon', () => {
+ // Boundary: the native service registers a running STT download in the canonical store.
+ useDownloadStore.getState().add(sttEntry({ status: 'running', progress: 0.42 }));
+
+ const { getByText, getByTestId } = render( {}} />);
+
+ // TERMINAL artifact: the Base·EN row shows 42%, driven purely by the canonical store the picker
+ // used to ignore. (RED without the fix: the picker reads whisperStore only → no % → this throws.)
+ expect(getByTestId('whisper-row-progress')).toBeTruthy();
+ expect(getByText('42%')).toBeTruthy();
+ });
+
+ it('shows the queued clock for a pending canonical STT download', () => {
+ useDownloadStore.getState().add(sttEntry({ status: 'pending', progress: 0, bytesDownloaded: 0 }));
+
+ const { getByTestId } = render( {}} />);
+
+ expect(getByTestId('whisper-row-queued')).toBeTruthy();
+ });
+
+ // Fallback path: the whisper store seeds progress before the canonical download-store entry exists
+ // (the RNFS URL-import path never gets one). The single owner covers it, so the picker still reflects
+ // it. (Replaces the deleted mockist TranscriptionModelsTab fallback tests with a real render.)
+ it('shows progress from the whisper-store fallback when there is no canonical entry', () => {
+ useWhisperStore.setState({ downloadProgressById: { 'base.en': 0.3 } });
+
+ const { getByText, getByTestId } = render( {}} />);
+
+ expect(getByTestId('whisper-row-progress')).toBeTruthy();
+ expect(getByText('30%')).toBeTruthy();
+ });
+
+ it('treats a 0% whisper-store fallback (awaiting a slot) as queued', () => {
+ useWhisperStore.setState({ downloadProgressById: { 'base.en': 0 } });
+
+ const { getByTestId } = render( {}} />);
+
+ expect(getByTestId('whisper-row-queued')).toBeTruthy();
+ });
+});
diff --git a/__tests__/rntl/components/ModelSelectorModal.test.tsx b/__tests__/rntl/components/ModelSelectorModal.test.tsx
deleted file mode 100644
index 8b9e403f8..000000000
--- a/__tests__/rntl/components/ModelSelectorModal.test.tsx
+++ /dev/null
@@ -1,1106 +0,0 @@
-/**
- * ModelSelectorModal Component Tests
- *
- * Tests for the modal showing text and image model lists:
- * - Returns null when not visible
- * - Renders "Select Model" title
- * - Shows text models tab by default
- * - Shows downloaded text models
- * - Shows "No models" when empty
- * - Shows unload button when model is loaded
- * - Calls onSelectModel when model pressed
- * - Switches to image tab
- * - Image model selection and loading
- * - Vision model badge
- * - Loading banner
- * - Tab badges
- * - Image model unload
- *
- * Priority: P1 (High)
- */
-
-import React from 'react';
-import { render, fireEvent, act } from '@testing-library/react-native';
-import { ModelSelectorModal } from '../../../src/components/ModelSelectorModal';
-
-jest.mock('../../../src/components/AppSheet', () => ({
- AppSheet: ({ visible, children, title }: any) => {
- if (!visible) return null;
- const { View, Text } = require('react-native');
- return (
-
- {title}
- {children}
-
- );
- },
-}));
-
-const mockUseAppStore = jest.fn();
-const mockUseRemoteServerStore = jest.fn();
-jest.mock('../../../src/stores', () => ({
- useAppStore: (sel?: any) => { const st = mockUseAppStore(); return typeof sel === 'function' ? sel(st) : st; },
- useRemoteServerStore: () => mockUseRemoteServerStore(),
-}));
-
-jest.mock('../../../src/services', () => ({
- activeModelService: {
- loadImageModel: jest.fn().mockResolvedValue(undefined),
- unloadImageModel: jest.fn().mockResolvedValue(undefined),
- unloadTextModel: jest.fn().mockResolvedValue(undefined),
- },
- llmService: {
- isModelLoaded: jest.fn(() => false),
- },
- hardwareService: {
- formatModelSize: jest.fn(() => '4.0 GB'),
- formatBytes: jest.fn(() => '2.0 GB'),
- formatModelRam: jest.fn(() => '~3.0 GB'),
- estimateImageModelRam: jest.fn(() => 2_000_000_000),
- },
- remoteServerManager: {
- clearActiveRemoteModel: jest.fn(),
- setActiveRemoteTextModel: jest.fn().mockResolvedValue(undefined),
- setActiveRemoteImageModel: jest.fn().mockResolvedValue(undefined),
- },
-}));
-
-// Import mocked functions after the mock is defined
-const { activeModelService } = require('../../../src/services');
-
-describe('ModelSelectorModal', () => {
- const defaultProps = {
- visible: true,
- onClose: jest.fn(),
- onSelectModel: jest.fn(),
- onUnloadModel: jest.fn(),
- isLoading: false,
- };
-
- beforeEach(() => {
- jest.clearAllMocks();
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
- mockUseRemoteServerStore.mockReturnValue({
- servers: [],
- activeServerId: null,
- activeRemoteTextModelId: null,
- activeRemoteImageModelId: null,
- discoveredModels: {},
- setActiveServerId: jest.fn(),
- setActiveRemoteImageModelId: jest.fn(),
- });
- });
-
- // ============================================================================
- // Visibility
- // ============================================================================
- describe('visibility', () => {
- it('returns null when not visible', () => {
- const { queryByTestId } = render(
-
- );
-
- expect(queryByTestId('app-sheet')).toBeNull();
- });
-
- it('renders when visible', () => {
- const { getByTestId } = render(
-
- );
-
- expect(getByTestId('app-sheet')).toBeTruthy();
- });
- });
-
- // ============================================================================
- // Title
- // ============================================================================
- describe('title', () => {
- it('renders "Select Model" title', () => {
- const { getByText } = render(
-
- );
-
- expect(getByText('Select Model')).toBeTruthy();
- });
- });
-
- // ============================================================================
- // Text Models Tab (Default)
- // ============================================================================
- describe('text models tab', () => {
- it('shows text models tab by default', () => {
- const { getByText } = render(
-
- );
-
- // "Text" tab label should be rendered
- expect(getByText('Text')).toBeTruthy();
- });
-
- it('shows downloaded text models', () => {
- const { getByText } = render(
-
- );
-
- expect(getByText('Test Model')).toBeTruthy();
- });
-
- it('filters suspicious recovered text models from the selector', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'recovered_bad_1',
- name: 'Recovered Bad Model',
- author: 'Unknown',
- filePath: '/path/bad.gguf',
- fileName: 'bad.gguf',
- fileSize: 400000000,
- quantization: 'Unknown',
- downloadedAt: new Date().toISOString(),
- },
- {
- id: 'model1',
- name: 'Good Model',
- author: 'test',
- filePath: '/path/good.gguf',
- fileName: 'good.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- downloadedAt: new Date().toISOString(),
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const { getByText, queryByText } = render(
-
- );
-
- expect(getByText('Good Model')).toBeTruthy();
- expect(queryByText('Recovered Bad Model')).toBeNull();
- });
-
- it('shows multiple downloaded text models', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Llama 3.2',
- filePath: '/path/llama.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- {
- id: 'model2',
- name: 'Phi 3',
- filePath: '/path/phi.gguf',
- fileSize: 2000000000,
- quantization: 'Q5_K_S',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('Llama 3.2')).toBeTruthy();
- expect(getByText('Phi 3')).toBeTruthy();
- });
-
- it('shows "No Text Models" when downloadedModels is empty', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('No Text Models')).toBeTruthy();
- expect(getByText('Download models from the Models tab')).toBeTruthy();
- });
-
- it('shows "Available Models" title when no model is loaded or selected', () => {
- const { getByText } = render(
-
- );
-
- expect(getByText('Available Models')).toBeTruthy();
- });
-
- it('shows "Switch Model" when a model is SELECTED but not yet loaded (deferred loading)', () => {
- // currentModelPath null (nothing loaded), but activeModelId picks the selected model.
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- { id: 'model1', name: 'Test Model', filePath: '/path/model1.gguf', fileSize: 4000000000, quantization: 'Q4_K_M' },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- activeModelId: 'model1',
- });
- const { getByText, queryByText } = render(
-
- );
-
- // Switcher reflects the selection even though nothing is loaded yet...
- expect(getByText('Switch Model')).toBeTruthy();
- // ...but the "Currently Loaded" / Unload affordance stays hidden (nothing in memory).
- expect(queryByText('Currently Loaded')).toBeNull();
- });
-
- it('shows quantization info for models', () => {
- const { getByText } = render(
-
- );
-
- expect(getByText('Q4_K_M')).toBeTruthy();
- });
-
- it('shows vision badge for vision models', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Vision Model',
- filePath: '/path/vision.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- engine: 'llama',
- isVisionModel: true,
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('Vision')).toBeTruthy();
- });
- });
-
- // ============================================================================
- // Loaded Model / Unload
- // ============================================================================
- describe('loaded model', () => {
- it('shows unload button when a text model is loaded', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- loadedTextModelId: 'model1',
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('Unload')).toBeTruthy();
- expect(getByText('Currently Loaded')).toBeTruthy();
- });
-
- it('calls onUnloadModel when unload button is pressed', () => {
- const onUnloadModel = jest.fn();
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- loadedTextModelId: 'model1',
- });
-
- const { getByText } = render(
-
- );
-
- fireEvent.press(getByText('Unload'));
-
- expect(onUnloadModel).toHaveBeenCalled();
- });
-
- it('shows "Switch Model" title when a model is loaded', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- loadedTextModelId: 'model1',
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('Switch Model')).toBeTruthy();
- });
-
- it('shows loaded model name and metadata', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'My Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- loadedTextModelId: 'model1',
- });
-
- const { getAllByText } = render(
-
- );
-
- // Model name appears in both "Currently Loaded" section and model list
- expect(getAllByText('My Model').length).toBeGreaterThanOrEqual(1);
- });
-
- it('disables model selection when loading', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- {
- id: 'model2',
- name: 'Other Model',
- filePath: '/path/other.gguf',
- fileSize: 2000000000,
- quantization: 'Q5_K_M',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const onSelectModel = jest.fn();
- const { getByText } = render(
-
- );
-
- // Models should be disabled during loading
- fireEvent.press(getByText('Other Model'));
- expect(onSelectModel).not.toHaveBeenCalled();
- });
-
- it('disables unload button when loading', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- loadedTextModelId: 'model1',
- });
-
- const { getByText } = render(
-
- );
-
- // The unload button should exist but be disabled
- expect(getByText('Unload')).toBeTruthy();
- });
- });
-
- // ============================================================================
- // Model Selection
- // ============================================================================
- describe('model selection', () => {
- it('calls onSelectModel when a text model is pressed', () => {
- const onSelectModel = jest.fn();
-
- const { getByText } = render(
-
- );
-
- fireEvent.press(getByText('Test Model'));
-
- expect(onSelectModel).toHaveBeenCalledWith(
- expect.objectContaining({
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- })
- );
- });
-
- it('does not call onSelectModel when pressing the currently loaded model', () => {
- const onSelectModel = jest.fn();
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- ],
- downloadedImageModels: [],
- activeImageModelId: null,
- loadedTextModelId: 'model1',
- });
-
- const { getAllByText } = render(
-
- );
-
- // The model name may appear both in "Currently Loaded" and the list
- const modelTexts = getAllByText('Test Model');
- // Press each instance - none should trigger onSelectModel for current model
- modelTexts.forEach(el => fireEvent.press(el));
- expect(onSelectModel).not.toHaveBeenCalled();
- });
- });
-
- // ============================================================================
- // Image Tab
- // ============================================================================
- describe('image tab', () => {
- it('opens directly on the image tab when initialTab="image" (F-SHEET)', () => {
- // Tapping the "Image" row in the models manager must open the selector focused on
- // Image, not default to Text. The modal honors initialTab; the manager now passes it.
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const { getByText } = render(
-
- );
-
- // No tab press needed — image content is shown immediately.
- expect(getByText('No Image Models')).toBeTruthy();
- });
-
- it('switches to image tab when Image is pressed', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const { getByText } = render(
-
- );
-
- // Press the Image tab
- fireEvent.press(getByText('Image'));
-
- // Should show the empty state for image models
- expect(getByText('No Image Models')).toBeTruthy();
- expect(getByText('Download image models from the Models tab')).toBeTruthy();
- });
-
- it('shows downloaded image models in image tab', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'img-model1',
- name: 'Stable Diffusion',
- size: 2000000000,
- style: 'Realistic',
- },
- ],
- activeImageModelId: null,
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('Stable Diffusion')).toBeTruthy();
- });
-
- it('filters suspicious recovered image models from the selector', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'recovered_image_1',
- name: 'Recovered Image',
- description: 'Recovered',
- modelPath: '/path/recovered',
- size: 100000000,
- downloadedAt: new Date().toISOString(),
- backend: 'mnn',
- },
- {
- id: 'img-model1',
- name: 'Stable Diffusion',
- description: 'Image model',
- modelPath: '/path/sd',
- size: 2000000000,
- downloadedAt: new Date().toISOString(),
- backend: 'mnn',
- },
- ],
- activeImageModelId: null,
- });
-
- const { getByText, queryByText } = render(
-
- );
-
- expect(getByText('Stable Diffusion')).toBeTruthy();
- expect(queryByText('Recovered Image')).toBeNull();
- });
-
- it('shows tab badges when models are loaded', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [
- {
- id: 'model1',
- name: 'Test Model',
- filePath: '/path/model1.gguf',
- fileSize: 4000000000,
- quantization: 'Q4_K_M',
- },
- ],
- downloadedImageModels: [
- {
- id: 'img1',
- name: 'Image Model',
- size: 2000000000,
- style: 'Artistic',
- },
- ],
- activeImageModelId: 'img1',
- });
-
- const { getByText } = render(
-
- );
-
- // Both tabs should render with badge dots when models are loaded
- expect(getByText('Text')).toBeTruthy();
- expect(getByText('Image')).toBeTruthy();
- });
-
- it('calls loadImageModel when selecting an image model', async () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'img1',
- name: 'SD Model',
- size: 2000000000,
- style: 'Creative',
- },
- ],
- activeImageModelId: null,
- });
-
- const onSelectImageModel = jest.fn();
- const { getByText } = render(
-
- );
-
- await act(async () => {
- fireEvent.press(getByText('SD Model'));
- });
-
- // Loaded via the shared loadModelWithOverride helper (id, timeout, override opts).
- expect(activeModelService.loadImageModel).toHaveBeenCalledWith('img1', undefined, undefined);
- });
-
- it('does not call loadImageModel when pressing the currently active image model', async () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'img1',
- name: 'SD Model',
- size: 2000000000,
- style: 'Creative',
- },
- ],
- activeImageModelId: 'img1',
- });
-
- const { getAllByText } = render(
-
- );
-
- // Model name appears in both "Currently Loaded" section and list
- const modelTexts = getAllByText('SD Model');
- await act(async () => {
- modelTexts.forEach(el => fireEvent.press(el));
- });
-
- expect(activeModelService.loadImageModel).not.toHaveBeenCalled();
- });
-
- it('shows currently loaded image model info', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'img1',
- name: 'My Image Model',
- size: 2000000000,
- style: 'Artistic',
- },
- ],
- activeImageModelId: 'img1',
- });
-
- const { getByText, getAllByText } = render(
-
- );
-
- expect(getByText('Currently Loaded')).toBeTruthy();
- // Model name appears in both "Currently Loaded" section and the list
- expect(getAllByText('My Image Model').length).toBeGreaterThanOrEqual(1);
- });
-
- it('calls unloadImageModel when unload button pressed on image tab', async () => {
- const onUnloadImageModel = jest.fn();
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'img1',
- name: 'My Image Model',
- size: 2000000000,
- style: 'Artistic',
- },
- ],
- activeImageModelId: 'img1',
- });
-
- const { getByText } = render(
-
- );
-
- await act(async () => {
- fireEvent.press(getByText('Unload'));
- });
-
- expect(activeModelService.unloadImageModel).toHaveBeenCalled();
- });
-
- it('shows "Switch Model" in image tab when image model is loaded', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'img1',
- name: 'My Image Model',
- size: 2000000000,
- style: 'Artistic',
- },
- ],
- activeImageModelId: 'img1',
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('Switch Model')).toBeTruthy();
- });
-
- it('shows image model style in metadata', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'img1',
- name: 'SD Model',
- size: 2000000000,
- style: 'Realistic',
- },
- ],
- activeImageModelId: null,
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('Realistic')).toBeTruthy();
- });
-
- it('disables tab switching when loading', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [
- {
- id: 'img1',
- name: 'SD Model',
- size: 2000000000,
- style: 'Creative',
- },
- ],
- activeImageModelId: null,
- });
-
- const { getByText, queryByText } = render(
-
- );
-
- // Try to switch to image tab while loading
- fireEvent.press(getByText('Image'));
-
- // Should still show text tab content since tabs are disabled during loading
- expect(queryByText('No Image Models')).toBeNull();
- });
- });
-
- // ============================================================================
- // Loading State
- // ============================================================================
- describe('loading state', () => {
- // Deleted: the "Loading model..." banner was replaced by a per-row spinner (device 2026-07-14). The
- // loading state is now covered by selectorLoaderOnRow.rendered.test.tsx (spinner on the tapped row) and
- // reloadCardShowsLoaderOnActiveRow.test.tsx (spinner on the active row during a no-tap reload).
- });
-
- // ============================================================================
- // Initial Tab
- // ============================================================================
- describe('initial tab', () => {
- it('opens on image tab when initialTab is image', () => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('No Image Models')).toBeTruthy();
- });
-
- it('opens on text tab by default', () => {
- const { getByText } = render(
-
- );
-
- expect(getByText('Test Model')).toBeTruthy();
- });
- });
-
- // ============================================================================
- // Add Server button
- // ============================================================================
- describe('Add Server button', () => {
- beforeEach(() => {
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
- });
-
- it('renders Add Remote Server button in empty state', () => {
- const { getByText } = render(
-
- );
-
- expect(getByText('Add Remote Server')).toBeTruthy();
- });
-
- it('Add Remote Server calls onClose and onAddServer when pressed', () => {
- const onClose = jest.fn();
- const onAddServer = jest.fn();
-
- const { getByText } = render(
-
- );
-
- fireEvent.press(getByText('Add Remote Server'));
-
- expect(onClose).toHaveBeenCalled();
- expect(onAddServer).toHaveBeenCalled();
- });
-
- it('Add Remote Server is disabled when isLoading is true', () => {
- const onClose = jest.fn();
- const onAddServer = jest.fn();
-
- const { getByText } = render(
-
- );
-
- fireEvent.press(getByText('Add Remote Server'));
-
- expect(onClose).not.toHaveBeenCalled();
- expect(onAddServer).not.toHaveBeenCalled();
- });
-
- it('Add Remote Server visible when no models are downloaded', () => {
- const { getByText } = render(
-
- );
-
- expect(getByText('Add Remote Server')).toBeTruthy();
- });
- });
-
- // ============================================================================
- // Remote text models
- // ============================================================================
- describe('remote text models', () => {
- beforeEach(() => {
- mockUseRemoteServerStore.mockReturnValue({
- servers: [{ id: 'srv1', name: 'My Ollama', endpoint: 'http://192.168.1.10:11434' }],
- activeServerId: 'srv1',
- activeRemoteTextModelId: null,
- activeRemoteImageModelId: null,
- discoveredModels: {
- srv1: [
- {
- id: 'llama3',
- name: 'llama3',
- serverId: 'srv1',
- capabilities: { supportsVision: false, supportsToolCalling: false, supportsThinking: false },
- lastUpdated: '2026-01-01T00:00:00Z',
- },
- ],
- },
- serverHealth: { srv1: { isHealthy: true, lastCheck: '2026-01-01T00:00:00Z' } },
- setActiveServerId: jest.fn(),
- setActiveRemoteImageModelId: jest.fn(),
- });
- });
-
- it('shows remote text model in text tab', () => {
- const { getByText } = render(
-
- );
-
- expect(getByText('llama3')).toBeTruthy();
- });
-
- it('shows VLM model with cloud icon indicator', () => {
- mockUseRemoteServerStore.mockReturnValue({
- servers: [{ id: 'srv1', name: 'My Ollama', endpoint: 'http://192.168.1.10:11434' }],
- activeServerId: 'srv1',
- activeRemoteTextModelId: null,
- activeRemoteImageModelId: null,
- discoveredModels: {
- srv1: [
- {
- id: 'llava',
- name: 'llava',
- serverId: 'srv1',
- capabilities: { supportsVision: true, supportsToolCalling: false, supportsThinking: false },
- lastUpdated: '2026-01-01T00:00:00Z',
- },
- ],
- },
- serverHealth: { srv1: { isHealthy: true, lastCheck: '2026-01-01T00:00:00Z' } },
- setActiveServerId: jest.fn(),
- setActiveRemoteImageModelId: jest.fn(),
- });
-
- const { getByText } = render(
-
- );
-
- // The server name section header should appear (rendered via wifi icon + server name)
- expect(getByText('My Ollama')).toBeTruthy();
- expect(getByText('llava')).toBeTruthy();
- });
-
- it('shows vision capability badge for VLM remote model', () => {
- mockUseRemoteServerStore.mockReturnValue({
- servers: [{ id: 'srv1', name: 'My Ollama', endpoint: 'http://192.168.1.10:11434' }],
- activeServerId: 'srv1',
- activeRemoteTextModelId: null,
- activeRemoteImageModelId: null,
- discoveredModels: {
- srv1: [
- {
- id: 'llava',
- name: 'llava',
- serverId: 'srv1',
- capabilities: { supportsVision: true, supportsToolCalling: false, supportsThinking: false },
- lastUpdated: '2026-01-01T00:00:00Z',
- },
- ],
- },
- serverHealth: { srv1: { isHealthy: true, lastCheck: '2026-01-01T00:00:00Z' } },
- setActiveServerId: jest.fn(),
- setActiveRemoteImageModelId: jest.fn(),
- });
-
- const { getByText } = render(
-
- );
-
- expect(getByText('Vision')).toBeTruthy();
- });
-
- it('calls setActiveRemoteTextModel when remote model pressed', async () => {
- const { remoteServerManager } = require('../../../src/services');
-
- const { getByText } = render(
-
- );
-
- await act(async () => {
- fireEvent.press(getByText('llama3'));
- });
-
- expect(remoteServerManager.setActiveRemoteTextModel).toHaveBeenCalledWith(
- 'srv1',
- 'llama3'
- );
- });
-
- it('remote model shows server name as subtitle', () => {
- const { getByText } = render(
-
- );
-
- // Server name appears as a section header in the grouped remote models list
- expect(getByText('My Ollama')).toBeTruthy();
- });
- });
-
- // ============================================================================
- // Image tab remote models
- // ============================================================================
- describe('image tab remote models', () => {
- it('image tab shows no remote models section even if discoveredModels has vision models', () => {
- mockUseRemoteServerStore.mockReturnValue({
- servers: [{ id: 'srv1', name: 'My Ollama', endpoint: 'http://192.168.1.10:11434' }],
- activeServerId: 'srv1',
- activeRemoteTextModelId: null,
- activeRemoteImageModelId: null,
- discoveredModels: {
- srv1: [
- {
- id: 'llava',
- name: 'llava',
- serverId: 'srv1',
- capabilities: { supportsVision: true, supportsToolCalling: false, supportsThinking: false },
- lastUpdated: '2026-01-01T00:00:00Z',
- },
- ],
- },
- serverHealth: { srv1: { isHealthy: true, lastCheck: '2026-01-01T00:00:00Z' } },
- setActiveServerId: jest.fn(),
- setActiveRemoteImageModelId: jest.fn(),
- });
-
- mockUseAppStore.mockReturnValue({
- downloadedModels: [],
- downloadedImageModels: [],
- activeImageModelId: null,
- });
-
- const { queryByTestId, getByText } = render(
-
- );
-
- // Image tab should show empty state — no remote model items
- expect(getByText('No Image Models')).toBeTruthy();
- expect(queryByTestId('remote-model-item')).toBeNull();
- });
- });
-});
diff --git a/__tests__/rntl/components/TranscriptionModelsTab.test.tsx b/__tests__/rntl/components/TranscriptionModelsTab.test.tsx
index c056f8c7a..542e2f8b3 100644
--- a/__tests__/rntl/components/TranscriptionModelsTab.test.tsx
+++ b/__tests__/rntl/components/TranscriptionModelsTab.test.tsx
@@ -181,21 +181,11 @@ describe('TranscriptionModelsTab', () => {
expect(getByTestId('transcription-model-card-0-bytes')).toHaveTextContent(/^0\/\d+$/);
});
- it('treats a whisper-store fallback still at 0% (no store entry yet) as Queued', () => {
- // Pre-slot window: the whisper store seeds progress 0 before the canonical
- // download-store entry exists. That 0% is WAITING for a slot → queued.
- mockWhisperState.downloadProgressById = { 'tiny.en': 0 };
- const { getByTestId, queryByTestId } = render();
- expect(getByTestId('transcription-model-card-0-queued')).toBeTruthy();
- expect(queryByTestId('transcription-model-card-0-downloading')).toBeNull();
- });
-
- it('treats a whisper-store fallback with progress > 0 as actively downloading', () => {
- mockWhisperState.downloadProgressById = { 'tiny.en': 0.3 };
- const { getByTestId, queryByTestId } = render();
- expect(getByTestId('transcription-model-card-0-downloading')).toBeTruthy();
- expect(queryByTestId('transcription-model-card-0-queued')).toBeNull();
- });
+ // NOTE: the whisper-store FALLBACK cases moved to a real rendered test
+ // (__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx). They
+ // used to seed the MOCKED store (mockWhisperState.downloadProgressById) and assert, but the
+ // derivation now lives in the useSttDownloadState owner which reads the REAL stores — a mock of
+ // our own store proves nothing there, so they're deleted rather than repaired (test doctrine).
it('re-derives present models from disk when the screen regains focus', () => {
// Disk is the source of truth: returning from the Download Manager (where a
diff --git a/__tests__/rntl/components/VoiceRecordButton.test.tsx b/__tests__/rntl/components/VoiceRecordButton.test.tsx
index 1c4e74117..9885bcd15 100644
--- a/__tests__/rntl/components/VoiceRecordButton.test.tsx
+++ b/__tests__/rntl/components/VoiceRecordButton.test.tsx
@@ -481,4 +481,23 @@ describe('VoiceRecordButton', () => {
expect(getByTestId('voice-loading')).toBeTruthy();
});
});
+
+ // ============================================================================
+ // Gesture continuity through a cold model load
+ //
+ // The "Slide to cancel" hint now lives inline in the composer (ChatInput.RecordingHint),
+ // not on this button. What MUST hold here is that a cold model load does not swap the mic
+ // for a bare, gesture-less spinner: the hold-to-record wrapper (voice-record-button, which
+ // carries the PanResponder) stays mounted while loading, so hold + slide + release survive
+ // the load (and the release-during-load ghost recording can't happen). If the load render
+ // reverted to the old early-return spinner, voice-record-button would be absent here.
+ // ============================================================================
+ describe('gesture continuity', () => {
+ it('keeps the gesturable mic wrapper mounted during a cold model load (chat mode)', () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId('voice-record-button')).toBeTruthy();
+ });
+ });
});
diff --git a/__tests__/rntl/screens/ImageProgressIndicatorPlaceholder.test.tsx b/__tests__/rntl/screens/ImageProgressIndicatorPlaceholder.test.tsx
new file mode 100644
index 000000000..ea590708e
--- /dev/null
+++ b/__tests__/rntl/screens/ImageProgressIndicatorPlaceholder.test.tsx
@@ -0,0 +1,40 @@
+/**
+ * ImageProgressIndicator — the placeholder image glyph must disappear once the live preview renders.
+ *
+ * Device 2026-07-16: during "Refining Image" the real preview thumbnail is shown, but the placeholder
+ * stayed in the header and overlapped/duplicated the image. It's only meaningful in
+ * the pre-preview "Generating Image" phase. Presentational component (styles/colors injected), so this
+ * renders it directly with both states and asserts the placeholder's presence tracks imagePreviewPath.
+ */
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { ImageProgressIndicator } from '../../../src/screens/ChatScreen/ChatScreenComponents';
+
+// The component reads styles/colors purely for presentation; return benign values for any key.
+const styles = new Proxy({}, { get: () => ({}) }) as never;
+const colors = new Proxy({}, { get: () => '#000000' }) as never;
+
+const renderIndicator = (imagePreviewPath: string | null) =>
+ render(
+ {}}
+ />,
+ );
+
+describe('ImageProgressIndicator placeholder glyph', () => {
+ it('shows the placeholder image glyph BEFORE a preview exists (Generating Image)', () => {
+ const { queryByTestId } = renderIndicator(null);
+ expect(queryByTestId('image-progress-placeholder-icon')).not.toBeNull();
+ });
+
+ it('HIDES the placeholder glyph once the live preview is showing (Refining Image)', () => {
+ // The real thumbnail is up — the placeholder would just overlap it.
+ const { queryByTestId } = renderIndicator('file:///tmp/preview.png');
+ expect(queryByTestId('image-progress-placeholder-icon')).toBeNull();
+ });
+});
diff --git a/__tests__/rntl/screens/ModelDownloadScreen.test.tsx b/__tests__/rntl/screens/ModelDownloadScreen.test.tsx
index 6e3b70e03..96c3a11ac 100644
--- a/__tests__/rntl/screens/ModelDownloadScreen.test.tsx
+++ b/__tests__/rntl/screens/ModelDownloadScreen.test.tsx
@@ -604,15 +604,13 @@ describe('ModelDownloadScreen', () => {
expect(result.queryByTestId('litert-model-0')).toBeNull();
});
- it('filters out LiteRT models that exceed RAM headroom', async () => {
- Platform.OS = 'android';
- // 4GB device: 60% headroom = 2.4GB; both curated models are larger.
- mockHardwareService.getTotalMemoryGB.mockReturnValue(4);
- const result = render();
- await flushPromises();
-
- expect(result.queryByTestId('litert-model-0')).toBeNull();
- });
+ // DELETED (mockist, #510): 'filters out LiteRT models that exceed RAM headroom' jest.mocked our own
+ // stores/services/hardware and asserted the PRE-FIX BUGGY behavior — that an over-budget curated
+ // LiteRT card is hidden at 4GB. That is exactly the defect: an over-budget-but-WARNABLE model (Gemma
+ // 4 E4B) must be OFFERED behind the "Download anyway" sheet, not silently hidden. Per doctrine, a
+ // test that mocks our own code proves nothing and here it encoded the bug, so it is deleted rather
+ // than repaired. The correct behavior is asserted at the RENDERED layer (over a real device-boundary
+ // fake, no mocks of our code) in __tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx.
it('downloading a LiteRT model uses the curated parent id', async () => {
Platform.OS = 'android';
diff --git a/__tests__/unit/services/downloadHydration.test.ts b/__tests__/unit/services/downloadHydration.test.ts
index e742967b1..dc5718123 100644
--- a/__tests__/unit/services/downloadHydration.test.ts
+++ b/__tests__/unit/services/downloadHydration.test.ts
@@ -1,4 +1,6 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { hydrateDownloadStore, isMmProjFileName } from '../../../src/services/downloadHydration';
+import { saveActiveDownloads } from '../../../src/services/activeDownloadPersistence';
import { useDownloadStore } from '../../../src/stores/downloadStore';
jest.mock('../../../src/services/backgroundDownloadService', () => ({
@@ -10,9 +12,10 @@ jest.mock('../../../src/services/backgroundDownloadService', () => ({
const { backgroundDownloadService } = jest.requireMock('../../../src/services/backgroundDownloadService');
-beforeEach(() => {
+beforeEach(async () => {
jest.clearAllMocks();
useDownloadStore.setState({ downloads: {}, downloadIdIndex: {} });
+ await AsyncStorage.clear(); // reset the persisted in-flight snapshot between tests
});
describe('isMmProjFileName', () => {
@@ -152,4 +155,50 @@ describe('hydrateDownloadStore', () => {
const entry = useDownloadStore.getState().downloads['author/model/model.gguf'];
expect(entry.downloadId).toBe('dl-new');
});
+
+ // Cold app-kill recovery: an in-flight download persisted to the durable snapshot must be carried
+ // forward as a failed/retriable card — NOT vanish — when its native row is gone on relaunch (iOS
+ // URLSession drops the task on force-quit). device 2026-07-15.
+ const inflight = {
+ downloadId: 'dl-inflight',
+ modelId: 'author/big-model',
+ modelKey: 'author/big-model/model.gguf',
+ fileName: 'model.gguf',
+ quantization: 'Q4_K_M',
+ modelType: 'text' as const,
+ status: 'running' as const,
+ bytesDownloaded: 1_100_000_000,
+ totalBytes: 5_500_000_000,
+ combinedTotalBytes: 5_500_000_000,
+ progress: 0.2,
+ createdAt: 1000,
+ };
+
+ it('strands a persisted in-flight download as failed when the native row is gone (cold app-kill)', async () => {
+ // Cold kill: in-memory store empty (beforeEach), native snapshot empty (task dropped), but the
+ // in-flight download survives in the durable snapshot loadActiveDownloads() reads.
+ await saveActiveDownloads([inflight]);
+ backgroundDownloadService.isAvailable.mockReturnValue(true);
+ backgroundDownloadService.getActiveDownloads.mockResolvedValue([]);
+
+ await hydrateDownloadStore();
+
+ const entry = useDownloadStore.getState().downloads['author/big-model/model.gguf'];
+ expect(entry).toBeDefined(); // did NOT vanish
+ expect(entry.status).toBe('failed'); // stranded as retriable
+ expect(entry.errorMessage).toMatch(/Interrupted/);
+ });
+
+ it('does NOT strand when the native snapshot still reports the row (Android survives a kill)', async () => {
+ // Android WorkManager survives a kill → the row reappears in the native snapshot → the persisted
+ // snapshot must be ignored for that key, never flip a live download to a false "failed".
+ await saveActiveDownloads([inflight]);
+ backgroundDownloadService.isAvailable.mockReturnValue(true);
+ backgroundDownloadService.getActiveDownloads.mockResolvedValue([{ ...inflight, bytesDownloaded: 2_000_000_000 }]);
+
+ await hydrateDownloadStore();
+
+ const entry = useDownloadStore.getState().downloads['author/big-model/model.gguf'];
+ expect(entry.status).toBe('running'); // live native row wins — no false strand (no Android regression)
+ });
});
diff --git a/__tests__/unit/services/huggingface.test.ts b/__tests__/unit/services/huggingface.test.ts
index 687495957..f2bfa5785 100644
--- a/__tests__/unit/services/huggingface.test.ts
+++ b/__tests__/unit/services/huggingface.test.ts
@@ -102,14 +102,17 @@ describe('HuggingFaceService', () => {
expect(result).toBeUndefined();
});
- it('matches by quantization level', () => {
+ // Quant is NOT a matching signal (#510): one projector serves every quant of its model. Among several
+ // generic projectors (no model-name token) for the repo's single model, F16 is preferred over other
+ // precisions — never the model's own quant.
+ it('prefers F16 among generic projectors, ignoring the model quant', () => {
const mmProjFiles = [
{ path: 'mmproj-Q4_K_M.gguf', size: 100 },
{ path: 'mmproj-f16.gguf', size: 800 },
];
const result = service.findMatchingMMProj('model-Q4_K_M.gguf', mmProjFiles, modelId);
- expect(result.name).toBe('mmproj-Q4_K_M.gguf');
+ expect(result.name).toBe('mmproj-f16.gguf');
});
it('falls back to f16 mmproj when no quant match', () => {
diff --git a/__tests__/unit/services/huggingfaceProjectorStrictness.test.ts b/__tests__/unit/services/huggingfaceProjectorStrictness.test.ts
new file mode 100644
index 000000000..4e3aeb91f
--- /dev/null
+++ b/__tests__/unit/services/huggingfaceProjectorStrictness.test.ts
@@ -0,0 +1,80 @@
+/**
+ * Download-time projector matching (#510 "one strict model<->projector rule").
+ *
+ * These drive the REAL huggingFaceService.getModelFiles over a FAKED HuggingFace network boundary
+ * (only global.fetch is faked — the whole huggingface service + mmproj rule run for real). The
+ * observable is the projector paired onto the downloadable ModelFile (file.mmProjFile): the thing the
+ * download flow then fetches and hands the loader, which decides whether vision works.
+ *
+ * Two behaviors must BOTH hold:
+ * (A) GENERIC single projector (bare `mmproj-F16.gguf`, no model-name token, e.g. ggml-org/gemma-3-*)
+ * must STILL pair → vision works. This is the anti-regression guard.
+ * (B) A projector whose filename names a DIFFERENT model+variant (an E4B projector for an E2B model,
+ * even at the same quant) must be REFUSED → the E2B never gets mispaired to the wrong architecture.
+ *
+ * Real repo shapes: unsloth/gemma-4-E2B-it-GGUF and unsloth/gemma-4-E4B-it-GGUF (src/constants/models.ts);
+ * ggml-org/gemma-3-*-GGUF ships a bare `mmproj-F16.gguf` (src/services/modelManager/download.ts comments).
+ */
+import { huggingFaceService } from '../../../src/services/huggingface';
+
+const originalFetch = global.fetch;
+
+function fakeTreeListing(files: Array<{ path: string; size: number }>) {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(files.map(f => ({ type: 'file', path: f.path, size: f.size }))),
+ }) as unknown as typeof fetch;
+}
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ jest.restoreAllMocks();
+});
+
+describe('download-time projector matching (#510)', () => {
+ // (A) ANTI-REGRESSION: a repo shipping ONE generic projector with no model-name token must still pair
+ // it with the model, or vision silently breaks for ggml-org/gemma-3-*-GGUF (and unsloth gemma-4, which
+ // also ships a bare mmproj-F16.gguf). MUST stay green before AND after the fix.
+ it('(A) pairs a generic single projector (no model-name token) with the model', async () => {
+ fakeTreeListing([
+ { path: 'gemma-3-4b-it-Q4_K_M.gguf', size: 4_000_000_000 },
+ { path: 'mmproj-F16.gguf', size: 800_000_000 },
+ ]);
+
+ const files = await huggingFaceService.getModelFiles('ggml-org/gemma-3-4b-it-GGUF');
+
+ const model = files.find(f => f.name === 'gemma-3-4b-it-Q4_K_M.gguf');
+ expect(model?.mmProjFile?.name).toBe('mmproj-F16.gguf');
+ });
+
+ // (B) WRONG-ARCHITECTURE REFUSAL: an E2B model listing that (mispackaged / user-mixed) contains a
+ // projector whose filename names E4B must NOT pair it — even though it is the same quant. Pairing it
+ // would crash initMultimodal ("Multimodal support not enabled") on device. The E2B must come back
+ // text-only (undefined mmProjFile) so it loads clean or takes the repair path.
+ it('(B) refuses a projector that names a DIFFERENT model+variant (E4B projector, E2B model)', async () => {
+ fakeTreeListing([
+ { path: 'gemma-4-E2B-it-Q4_K_M.gguf', size: 2_000_000_000 },
+ { path: 'gemma-4-E4B-it-mmproj-F16.gguf', size: 800_000_000 },
+ ]);
+
+ const files = await huggingFaceService.getModelFiles('unsloth/gemma-4-E2B-it-GGUF');
+
+ const e2b = files.find(f => f.name === 'gemma-4-E2B-it-Q4_K_M.gguf');
+ expect(e2b?.mmProjFile).toBeUndefined();
+ });
+
+ // (B, positive) When the correct E2B-named projector IS present alongside a wrong E4B one, the exact
+ // model+variant match is chosen — vision works, mispairing is avoided.
+ it('(B) prefers the exact model+variant projector over a wrong-arch one', async () => {
+ fakeTreeListing([
+ { path: 'gemma-4-E2B-it-Q4_K_M.gguf', size: 2_000_000_000 },
+ { path: 'gemma-4-E2B-it-mmproj-F16.gguf', size: 800_000_000 },
+ { path: 'gemma-4-E4B-it-mmproj-F16.gguf', size: 900_000_000 },
+ ]);
+
+ const files = await huggingFaceService.getModelFiles('unsloth/gemma-4-E2B-it-GGUF');
+
+ const e2b = files.find(f => f.name === 'gemma-4-E2B-it-Q4_K_M.gguf');
+ expect(e2b?.mmProjFile?.name).toBe('gemma-4-E2B-it-mmproj-F16.gguf');
+ });
+});
diff --git a/__tests__/unit/services/llmToolGeneration.test.ts b/__tests__/unit/services/llmToolGeneration.test.ts
index 46700d881..0aa604ad3 100644
--- a/__tests__/unit/services/llmToolGeneration.test.ts
+++ b/__tests__/unit/services/llmToolGeneration.test.ts
@@ -406,6 +406,29 @@ describe('generateWithToolsImpl', () => {
expect(result.toolCalls[0].arguments).toEqual({ expression: '3*3' });
});
+ it('parses arguments that use smart/curly quotes as delimiters (no empty-args retry loop)', async () => {
+ // Device 2026-07-15: a local model emitted `{“expression”:“7 * 7”}` with CURLY double quotes.
+ // Strict JSON.parse throws → args became {} → the calculator got an empty call → schema
+ // validation failed → the model retried the same call in a loop. The parser now normalizes the
+ // curly delimiters, so the real expression reaches the tool on the first try.
+ const completion = jest.fn(async (_params: any, cb: any) => {
+ cb({
+ tool_calls: [
+ { id: 'call_curly', function: { name: 'calculator', arguments: '{“expression”:“7 * 7”}' } },
+ ],
+ });
+ return {};
+ });
+ const deps = createMockDeps({ context: { completion } });
+
+ const result = await generateWithToolsImpl(deps, [createUserMessage('multiply 7 by 7')], {
+ tools: SAMPLE_TOOLS,
+ });
+
+ expect(result.toolCalls[0].name).toBe('calculator');
+ expect(result.toolCalls[0].arguments).toEqual({ expression: '7 * 7' }); // NOT {} — curly quotes normalized
+ });
+
it('handles tool call with missing function fields gracefully', async () => {
const completion = jest.fn(async (_params: any, cb: any) => {
cb({
diff --git a/__tests__/unit/stores/remoteModelCapabilities.test.ts b/__tests__/unit/stores/remoteModelCapabilities.test.ts
index b5d4c5663..a6e078dcd 100644
--- a/__tests__/unit/stores/remoteModelCapabilities.test.ts
+++ b/__tests__/unit/stores/remoteModelCapabilities.test.ts
@@ -486,6 +486,31 @@ describe('fetchLmStudioModelInfo — probeLmStudioThinking SSE branches', () =>
expect(result.supportsThinking).toBe(true);
});
+ it('detects thinking via inline Gemma <|channel>thought reasoning in content delta', async () => {
+ // A remote model emitting Gemma-channel reasoning INLINE (no reasoning_content field)
+ // must still be detected as thinking. The probe hardcoded a `` check and missed
+ // the Gemma/Qwen channel delimiters that the rest of the reasoning grammar knows.
+ globalThis.fetch = jest.fn()
+ .mockResolvedValueOnce(modelResponse('gemma'))
+ .mockResolvedValueOnce({
+ ok: true,
+ text: async () => 'data: {"choices":[{"delta":{"content":"<|channel>thought\\nreasoning"}}]}\ndata: [DONE]\n',
+ } as any);
+ const result = await fetchLmStudioModelInfo('http://localhost:1234', 'gemma');
+ expect(result.supportsThinking).toBe(true);
+ });
+
+ it('detects thinking via inline Qwen <|channel|>analysis reasoning in content delta', async () => {
+ globalThis.fetch = jest.fn()
+ .mockResolvedValueOnce(modelResponse('qwen'))
+ .mockResolvedValueOnce({
+ ok: true,
+ text: async () => 'data: {"choices":[{"delta":{"content":"<|channel|>analysis<|message|>reasoning"}}]}\ndata: [DONE]\n',
+ } as any);
+ const result = await fetchLmStudioModelInfo('http://localhost:1234', 'qwen');
+ expect(result.supportsThinking).toBe(true);
+ });
+
it('detects thinking via reasoning_content delta', async () => {
globalThis.fetch = jest.fn()
.mockResolvedValueOnce(modelResponse('m2'))
diff --git a/__tests__/unit/utils/messageContent.test.ts b/__tests__/unit/utils/messageContent.test.ts
index a5b4eb144..90d1df621 100644
--- a/__tests__/unit/utils/messageContent.test.ts
+++ b/__tests__/unit/utils/messageContent.test.ts
@@ -208,6 +208,27 @@ describe('stripControlTokens', () => {
const input = 'Before \n{\n "name": "search",\n "query": "test"\n}\n after';
expect(stripControlTokens(input)).toBe('Before after');
});
+
+ // DR7 follow-on: the tool-loop extractor (parseXmlStyleToolCall in generationToolLoop)
+ // accepts `……` markup, but the shared stripper
+ // did NOT strip it — so a model emitting this form leaked the raw markup into the visible
+ // answer. Stripper and extractor must recognise the SAME grammar.
+ it('strips / XML-style tool-call markup (extractor grammar)', () => {
+ const input =
+ 'Sure, let me check.\n{"city":"Paris"}celsius\nDone.';
+ const stripped = stripControlTokens(input);
+ expect(stripped).not.toContain('');
+ expect(stripped).toContain('Sure, let me check.');
+ expect(stripped).toContain('Done.');
+ });
+
+ it('strips an unclosed tool-call opener at end (EOS mid-call)', () => {
+ expect(
+ stripControlTokens('Working on it.{"city":"NY'),
+ ).toBe('Working on it.');
+ });
});
diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md
index 7bb025331..83d1db41c 100644
--- a/docs/GAPS_BACKLOG.md
+++ b/docs/GAPS_BACKLOG.md
@@ -274,3 +274,102 @@ state-machine traces:
onboarding screen with device-init + Android litert rendering is heavier). Follow-up: add a
`ModelDownloadScreen`-mounted rendered test (Android, 12GB) that taps the E4B litert download and asserts
no "may exceed your device's memory" sheet — the exact device-reported surface (IMG_0142).
+
+---
+
+## Cosmetic voice-mode label (deferred from the 0.0.103 device session)
+
+**Verdict: instrument-and-revisit.** During the 0.0.103-beta device session, two fixes landed (Lean
+per-model eject + thinking-block width). A third item — a **cosmetic label/chip in voice mode**
+rendering the wrong text — was deferred: it is purely cosmetic (no functional impact) and pinning the
+exact wrong value needs a device-log pull, not code reading. Next device session: pull the live tail of
+`offgrid-debug.log` from the `.dev` container, grep the `[*-SM]` traces while entering voice mode, read
+the actual rendered label value, then fix in `pro/audio/` UI. NOT a release hazard.
+
+---
+
+## #510 audit follow-ups (deferred from the load-anyway/dedup fix batch, 2026-07-15)
+
+- **Onboarding litert download-warning unreachable** (`ModelDownloadScreen.tsx:299`): fix is code-ready
+ (route the over-budget-but-warnable card through the owned `curatedLiteRTDownloadWarning`) but blocked
+ by a mockist test `__tests__/rntl/screens/ModelDownloadScreen.test.tsx:607` that asserts the buggy
+ pre-filter. Per doctrine: update/delete that mockist test, then land the fix.
+- **`ModelSelectorModal.test.tsx` is mockist** (jest.mocks our stores/services/hardware) — 44 tests over a
+ fake store. The RAM-parity fix is really proven by `pickerRamMatchesResidencyChip.rendered.redflow.test.tsx`;
+ replace this file with rendered coverage post-release. Source carries a harmless `s.settings?.` to keep it green.
+- **Queued-message imageMode carry** (`useChatGenerationActions`/`generationService`/`useChatScreen`): a
+ force-image send that gets queued loses its force flag (re-decided at 'auto' on drain). Needs the
+ QueuedMessage interface + drain handler edited together — own PR.
+- **huggingface.findMatchingMMProj strict migration**: keep the generic-single-projector case, refuse a
+ projector naming a DIFFERENT model (E4B for E2B). Own download-listing matcher in mmproj.ts. See the
+ it.failing at `huggingfaceProjectorStrictness.test.ts`.
+- **Reclaim-aware pre-load gate**: in progress on its own branch (device-verify on 12GB Android before merge).
+
+---
+
+## DEVICE FINDING (2026-07-15, iPhone) — false "something else is generating an image" (stale IMG-SM lock)
+
+Symptom: image generation refused with a message that something else is generating an image ("I can't
+help you right now, you can reload the model") when NOTHING else was generating. Reloading the model
+cleared it and generation started.
+
+Mechanism: `imageGenerationService.generateImage` rejects when `isInFlight(state.phase)` is true
+(imageGenerationService.ts:402). The known failure paths reset the phase (`_ensureImageModelLoaded`→`_fail`;
+`_runGenerationAndSave` catch→`resetState`/`_fail`), so a DIFFERENT path leaves `state.phase` stuck
+in-flight ('loading'/'enhancing'/'generating') — plausibly tied to a refused/slow SDXL load or an
+interrupted 120s ANE compile. Reload resets the service state → clears the false lock.
+
+NOT fixed yet (would be a speculative guard without a red-verifiable repro). TO PIN IT: reproduce on
+device, `xcrun devicectl device copy` the `.dev` container's `offgrid-debug.log`, grep `[IMG-SM]` — the
+stuck transition (a `phase X → ` with no following reset) names the exact path. Then fix at
+that seam + a rendered red-flow (image mode → trigger the stuck path → next generate must NOT report
+"already generating"). Candidate hardening once pinned: a top-level try/finally in generateImage so no
+throw can leave the phase in-flight, and/or a self-healing staleness check on the isInFlight rejection.
+
+---
+
+## #510 audit — remaining PARTIAL fixes (found during the finding→code verification, 2026-07-15)
+
+These are honestly NOT fully closed by the load-anyway batch — logged so they are not lost:
+
+- **STT terminal-failure has no override card.** The realtime dictation now RECOVERS via
+ ensureWhisperForTranscription (free the generation model → retry) — the common case. But if that retry
+ ALSO fails, transcriptionOutcome.ts returns a static "Couldn't load the voice model — free some memory
+ and try again" string, NOT a reportModelFailure('stt', {onLoadAnyway}) card. There is no generation
+ model left to free at that point, so there is genuinely nothing more to do — but the product rule
+ ("any memory refusal offers Load Anyway on any type") is only PARTIALLY met for STT: recovery yes,
+ terminal override no. reportModelFailure is now called for text/image/tts but NOT stt/embedding.
+- **Embedding-model load failure never surfaces a card.** modelFailureHandler reserves an 'embedding'
+ type but nothing calls reportModelFailure('embedding', …). A RAG/embedding load failure is still
+ silent. Low user impact (embedding is background) but it violates the "nothing is silent" promise.
+- **ModelPickerSheet:216 RAM display**: the fit VERDICT uses the owned fileExceedsBudget, but the
+ displayed "~X GB RAM" number is a separate 1.5x estimate — the "(may not fit)" tag and the number can
+ disagree at the margin. Assessed as by-design (verdict is authoritative; number is a hint) but noted.
+
+---
+
+## #510 audit — STT-terminal + embedding: VERIFIED WORKING-AS-DESIGNED (not bugs, do NOT "fix")
+
+Re-examined the two items I earlier logged as "partial fixes needed". Code inspection shows both are
+correct terminal states, NOT dead-ends — surfacing failure cards would be theater or a regression:
+- **Embedding load failure**: `src/services/rag/retrieval.ts:43,53` catch a failed embedding load/embed
+ and RETURN `ragDatabase.getChunksByProject` (keyword/FTS chunks) — search still works (graceful
+ degradation). `toolEmbeddingRouter`/`generationToolLoop:821` likewise fall back to "use all tools".
+ A reportModelFailure('embedding') card would interrupt a working degraded flow → NOT added.
+- **STT terminal**: `ensureWhisperForTranscription` frees the generation model and retries; if whisper
+ STILL won't load, whisper-alone exceeds the device → a genuine HARD limit. The "free some memory"
+ string is the honest message; a Load-Anyway there is a guaranteed-fail no-op. The recovery IS the fix.
+CONCLUSION: these two need no code change. Removed from the "to fix" list.
+
+## #26 text-half (deferred, cosmetic-low)
+ModelPickerSheet text RAM hint still uses formatModelRam's 1.5 default, not the backend-aware
+textOverheadMultiplier the residency chip / TextTab use — so on a GPU backend the picker number can
+read lower than the chip. Verdict (fileExceedsBudget) is correct; this is a display-number nicety.
+Fix = pass settings.inferenceBackend into ModelPickerSheet + formatModelRam(model, textOverheadMultiplier(backend)).
+Deferred to avoid a new HomeScreen-picker dependency right before release. Image half fixed.
+
+## M5a (marginal, logged) — exact budget boundary untested
+fileExceedsBudget's boundary (size == budget: `>` vs `>=`) has no test straddling the exact equality —
+the verifier's `>`↔`>=` mutant survived. Off-by-one-byte at the budget edge; no user-visible impact
+(a model exactly at the budget is a measure-zero case). Add a boundary test if fileExceedsBudget is
+touched again. Not fixed now (marginal, near release).
diff --git a/docs/RELEASE_DEVICE_CHECKLIST_pr558.md b/docs/RELEASE_DEVICE_CHECKLIST_pr558.md
new file mode 100644
index 000000000..cb5084f60
--- /dev/null
+++ b/docs/RELEASE_DEVICE_CHECKLIST_pr558.md
@@ -0,0 +1,79 @@
+# PR #558 — on-device verification checklist
+
+The jest tests prove the JS decisions over faked device boundaries. THESE are the native/real-hardware
+outcomes only a physical device confirms. Run on both a 12GB Android (Mac's real target) and an iPhone
+where noted. Mark P/F.
+
+## HIGH priority — the risky memory changes (verify FIRST)
+
+1. **[Android 12GB] Reclaim gate — big model loads instead of false-refusing.**
+ Aggressive mode. Have a couple of background apps open (real memory pressure). Load a ~5GB text
+ model (e.g. qwythos-class). EXPECT: it loads and generates — NOT "it needs ~X but only Y available".
+ This is the reclaim-aware fix; the risk it must NOT do: jetsam/OOM-kill the app on load. If it crashes
+ → the reclaim credit is too generous on your device. FAIL = crash or false-refuse.
+
+2. **[Android 12GB + iPhone] Load Anyway appears on a genuine refusal, any mode.**
+ Force a refusal (Balanced or a model bigger than even the reclaim budget). EXPECT: an "Insufficient
+ Memory" alert WITH a "Load Anyway" button — never an OK-only dead-end. Tap Load Anyway → it attempts
+ the load. (On a truly-too-big model it may still OOM — that's the accepted risk of the override.)
+
+3. **[iPhone] SDXL (Core ML) image download → finalize + first generate.**
+ Download SDXL. EXPECT: completes, unzips, registers (NO "…couldn't be opened, no such file"). Then
+ generate one image. The first ANE compile (~120s) is the OOM-risk moment — confirm it doesn't hard-crash.
+ If it crashes on first compile → SDXL is too heavy for the device (a real limit, not our bug).
+
+## HIGH — the voice/mic gesture fixes (newest; device-only, no jest layout)
+
+V1. **[both] "Slide to cancel" pill forms properly.** Empty composer (the send slot IS the mic).
+ Press and HOLD the mic. EXPECT: a "Slide to cancel" pill appears to the LEFT of the mic with the
+ full text on ONE line — not clipped, not wrapped into a smear, not overlapping the mic. (This is the
+ cut-off bug; jest can prove width+single-line but not the pixels — your eyes are the only proof.)
+
+V2. **[both] Slide-to-cancel actually cancels vs sends.** Hold to record and speak. (a) Slide LEFT
+ past ~1cm and release → EXPECT: recording discarded, NOTHING lands in the composer. (b) Hold, speak,
+ release WITHOUT sliding → EXPECT: it transcribes into the composer.
+
+V3. **[both] Cold-load gesture continuity + NO ghost recording.** Trigger a cold whisper load first:
+ fresh launch, or load a big text model so whisper was evicted. Now press-and-HOLD the mic. EXPECT:
+ the button shows a spinner but STAYS a button you can slide/release (not a dead spinner). Now RELEASE
+ while it is still spinning up (before recording starts). EXPECT: it cancels cleanly — mic returns to
+ idle, no transcript, and NOTHING keeps recording in the background. Press again → records normally.
+ FAIL = mic stuck recording/spinning forever, or a session you can never stop (the ghost).
+
+## MEDIUM — the recovery/parity fixes
+
+4. **[both] Image download interrupted → Retry recovers.** Start a large image download, kill WiFi mid-way
+ so it fails, restore WiFi, tap Retry. EXPECT: it re-downloads and finalizes (not a stuck failed card).
+
+5. **[both] Voice dictation under memory pressure.** Load a text model, then hold-to-talk dictate.
+ EXPECT: transcribes (frees the model + loads whisper if needed) — never a silent empty composer.
+
+6. **[both] TTS speak under memory pressure.** In audio mode with a text model resident, have the
+ assistant speak a reply. If TTS can't fit, EXPECT: a visible failure card with Load Anyway — not the
+ speaker icon silently stopping.
+
+7. **[Android] Onboarding — over-budget curated LiteRT model (E4B) is offered with a warning.**
+ On the onboarding model-download screen, EXPECT: the over-budget E4B card is PRESENT and shows a
+ "may exceed your device's memory / Download anyway" warning — not hidden/undownloadable.
+
+## LOW — behavior polish
+
+8. **[both] Tool-using model:** reply shows clean text, no `` markup leaking.
+9. **[both] Remote model that reasons inline (LM Studio/Ollama, gemma-style):** the Thinking toggle appears.
+10. **[both] Resend a message with no image model loaded:** it regenerates as text (same as send) — not a crash/"no image model".
+11. **[both] Force image mode, queue a send behind a running generation:** the queued one still draws an image (doesn't fall back to text).
+
+## Regression sanity (the "did we break Android" gate)
+12. **[Android]** General chat, image gen, voice — all still work as before. The whole batch's shared JS
+ changes were bundle-verified for Android; this is the human confirmation.
+
+## Known device-limit (not a bug to fix)
+- If SDXL (or any multi-GB Core ML/dirty model) jetsams on the first ANE compile, that is a genuine
+ device memory ceiling — the app's gate/Load-Anyway behaved correctly by warning first.
+
+## iOS build-config (verify on the next Debug install)
+13. **[iPhone] Debug build home-screen name = "Off Grid AI Debug".** After installing the .dev (Debug)
+ build, the app icon label must read "Off Grid AI Debug" (distinct from the release "Off Grid AI").
+ NOTE: this is a build-time plist-variable expansion — config is set correctly but only a real Debug
+ build confirms the name renders. If it shows literally "$(INFOPLIST_KEY_CFBundleDisplayName)" or blank,
+ the var didn't expand → flag it.
diff --git a/ios/DownloadManagerModule.swift b/ios/DownloadManagerModule.swift
index eb8f8f69e..e10adcdde 100644
--- a/ios/DownloadManagerModule.swift
+++ b/ios/DownloadManagerModule.swift
@@ -251,6 +251,25 @@ class DownloadManagerModule: RCTEventEmitter {
return resolved.hasPrefix(documentsDir) || resolved.hasPrefix(cachesDir) || resolved.hasPrefix(tmpDir)
}
+ // MARK: - Durable staging
+
+ /// A DURABLE directory to stage a completed download until JS finalizes it (moves it to the model
+ /// dir / unzips it). This MUST NOT be NSTemporaryDirectory(): iOS reaps the temp dir across app
+ /// relaunches and under memory pressure, so a completed-but-not-yet-finalized file staged there is
+ /// gone by the time finalization runs — especially after the queued-survival rework, which now
+ /// restores and finalizes downloads AFTER a relaunch. A large image zip (multi-GB) is the worst case:
+ /// it commonly spans a backgrounding/relaunch before its unzip completes, and the vanished staged
+ /// zip surfaced as "…couldn't be opened because there is no such file" on iOS. Documents is durable
+ /// (never auto-purged), inside the sandbox allowlist, and we exclude it from iCloud backup.
+ static func completedStagingDirectory() -> String {
+ let documentsDir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first
+ ?? NSTemporaryDirectory()
+ let dir = (documentsDir as NSString).appendingPathComponent(".download-staging")
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ excludeFromBackup(at: URL(fileURLWithPath: dir))
+ return dir
+ }
+
// MARK: - RCTEventEmitter
override init() {
@@ -1178,8 +1197,10 @@ extension DownloadManagerModule {
downloadId: String,
info: inout DownloadInfo,
fileManager: FileManager) {
- let tmpDir = NSTemporaryDirectory()
- let destPath = "\(tmpDir)/download_\(downloadId)_\(info.fileName)"
+ // Stage the completed file in a DURABLE dir (not NSTemporaryDirectory, which iOS purges across
+ // relaunch / under memory pressure) so a finalize that runs after a relaunch can still find it.
+ let stagingDir = DownloadManagerModule.completedStagingDirectory()
+ let destPath = "\(stagingDir)/download_\(downloadId)_\(info.fileName)"
let destURL = URL(fileURLWithPath: destPath)
try? fileManager.removeItem(at: destURL)
@@ -1188,6 +1209,9 @@ extension DownloadManagerModule {
do {
try fileManager.moveItem(at: location, to: destURL)
NSLog("[DownloadManager] Single file saved to: %@", destPath)
+ // Exclude the staged file itself from backup (per-file flag; the dir flag is not inherited by
+ // files created later). These are large model artifacts we never want in an iCloud backup.
+ DownloadManagerModule.excludeFromBackup(at: destURL)
info.localUri = destPath
info.status = "completed"
info.bytesDownloaded = info.totalBytes
@@ -1305,9 +1329,12 @@ class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate {
downloadTask.taskIdentifier, location.path)
// CRITICAL: The file at `location` is deleted by URLSession as soon as this method returns.
- // We must copy it to a safe location SYNCHRONOUSLY before returning.
+ // We must copy it to a safe location SYNCHRONOUSLY before returning. That location must be
+ // DURABLE (not NSTemporaryDirectory) — if the app is killed between here and handleCompletion,
+ // a temp copy would be reaped and the completed bytes lost. See completedStagingDirectory().
let fileManager = FileManager.default
- let safeTmp = NSTemporaryDirectory() + "dl_task_\(downloadTask.taskIdentifier)_\(UUID().uuidString).tmp"
+ let stagingDir = DownloadManagerModule.completedStagingDirectory()
+ let safeTmp = "\(stagingDir)/dl_task_\(downloadTask.taskIdentifier)_\(UUID().uuidString).tmp"
let safeURL = URL(fileURLWithPath: safeTmp)
do {
diff --git a/ios/OffgridMobile.xcodeproj/project.pbxproj b/ios/OffgridMobile.xcodeproj/project.pbxproj
index 1f77c5695..0c2575cea 100644
--- a/ios/OffgridMobile.xcodeproj/project.pbxproj
+++ b/ios/OffgridMobile.xcodeproj/project.pbxproj
@@ -419,7 +419,7 @@
DEVELOPMENT_TEAM = 84V6KCAC49;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = OffgridMobile/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = "Off Grid AI";
+ INFOPLIST_KEY_CFBundleDisplayName = "Off Grid AI Debug";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
diff --git a/ios/OffgridMobile/Info.plist b/ios/OffgridMobile/Info.plist
index 0f510ff7e..a5fff7a7b 100644
--- a/ios/OffgridMobile/Info.plist
+++ b/ios/OffgridMobile/Info.plist
@@ -7,7 +7,7 @@
CFBundleDevelopmentRegion
en
CFBundleDisplayName
- Off Grid AI
+ $(INFOPLIST_KEY_CFBundleDisplayName)
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
diff --git a/pro b/pro
index 3599b5b13..ff0d87423 160000
--- a/pro
+++ b/pro
@@ -1 +1 @@
-Subproject commit 3599b5b132119c9b485c355c7fcb089a6fe1802f
+Subproject commit ff0d874234c23d3dd2a781b77baafe8102c3fad7
diff --git a/scripts/release.sh b/scripts/release.sh
index dc8dd3fe9..c3ffda041 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -126,6 +126,14 @@ gh release create "v${NEW_VERSION}" \
--title "Off Grid v${NEW_VERSION}" \
--notes-file "$NOTES_FILE"
+# Announce the release in Slack — fail-soft, a chat message must never fail a published release.
+# Webhook comes from .env.keygen locally (mirrors the SLACK_WEBHOOK_URL repo secret the CI release
+# workflows use); notify-slack-release.mjs no-ops if it is unset.
+SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-$(sed -n 's/^SLACK_WEBHOOK_URL=//p' "$ROOT_DIR/.env.keygen" 2>/dev/null)}" \
+ PRODUCT="Off Grid AI Mobile" VERSION="$NEW_VERSION" CHANNEL_LABEL="stable" \
+ RELEASE_URL="$(gh release view "v${NEW_VERSION}" --json url -q .url 2>/dev/null)" NOTES_FILE="$NOTES_FILE" \
+ node "$ROOT_DIR/scripts/notify-slack-release.mjs" || true
+
# Clean up temp file
rm -f "$NOTES_FILE"
diff --git a/scripts/uat.sh b/scripts/uat.sh
index 9d44b685b..965b28144 100755
--- a/scripts/uat.sh
+++ b/scripts/uat.sh
@@ -184,6 +184,14 @@ fi
printf '\n\n%s\n' "$BUILD_LINE" >> "$NOTES_FILE"
gh release create "$TAG" "${GH_ARGS[@]}" --prerelease --title "Off Grid ${BETA_VERSION} (beta)" --notes-file "$NOTES_FILE"
+# Announce the beta in Slack — fail-soft, a chat message must never fail a shipped build. Webhook comes
+# from .env.keygen locally (mirrors the SLACK_WEBHOOK_URL repo secret the release workflows use);
+# notify-slack-release.mjs no-ops if it is unset.
+SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-$(sed -n 's/^SLACK_WEBHOOK_URL=//p' "$ROOT_DIR/.env.keygen" 2>/dev/null)}" \
+ PRODUCT="Off Grid AI Mobile" VERSION="$BETA_VERSION" CHANNEL_LABEL="beta" \
+ RELEASE_URL="$(gh release view "$TAG" --json url -q .url 2>/dev/null)" NOTES_FILE="$NOTES_FILE" \
+ node "$ROOT_DIR/scripts/notify-slack-release.mjs" || true
+
rm -f "$NOTES_FILE" "${ANDROID_CHANGELOG:-}" "$APK_DST" "$AAB_DST"
echo ""
info "${BOLD}Beta ${BETA_VERSION} shipped.${NC}"
diff --git a/src/components/ChatInput/ComposerIconsRow.tsx b/src/components/ChatInput/ComposerIconsRow.tsx
new file mode 100644
index 000000000..fc76349ea
--- /dev/null
+++ b/src/components/ChatInput/ComposerIconsRow.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { View, TouchableOpacity, Animated } from 'react-native';
+import Icon from 'react-native-vector-icons/Feather';
+import { useTheme, useThemedStyles } from '../../theme';
+import { createStyles } from './styles';
+
+type TriggerRef = React.RefObject | null>;
+
+interface ComposerIconsRowProps {
+ /** Collapses the icons to zero width once the user starts typing. */
+ hasText: boolean;
+ iconsAnim: Animated.Value;
+ pillIconsExpandedWidth: number;
+ attachTriggerRef: TriggerRef;
+ onAttachPress: () => void;
+ quickSettingsTriggerRef: TriggerRef;
+ onQuickSettingsPress: () => void;
+ showSettingsDot: boolean;
+ disabled?: boolean;
+}
+
+/**
+ * The +/settings icon cluster on the right of the composer pill, which animates to zero width as
+ * the user types. Extracted from ChatInput so that render stays under the max-lines lint budget
+ * (no behaviour change).
+ */
+export const ComposerIconsRow: React.FC = ({
+ hasText, iconsAnim, pillIconsExpandedWidth, attachTriggerRef, onAttachPress,
+ quickSettingsTriggerRef, onQuickSettingsPress, showSettingsDot, disabled,
+}) => {
+ const { colors } = useTheme();
+ const styles = useThemedStyles(createStyles);
+ return (
+
+
+
+
+
+
+
+ {showSettingsDot && }
+
+
+
+ );
+};
diff --git a/src/components/ChatInput/RecordingHint.tsx b/src/components/ChatInput/RecordingHint.tsx
new file mode 100644
index 000000000..1279d5a9b
--- /dev/null
+++ b/src/components/ChatInput/RecordingHint.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { View, Text } from 'react-native';
+import Icon from 'react-native-vector-icons/Feather';
+import { useTheme, useThemedStyles } from '../../theme';
+import { createStyles } from './styles';
+
+/**
+ * Push-to-talk hint shown INLINE in the composer while holding to record (the WhatsApp pattern):
+ * a recording dot on the left and "‹ Slide to cancel" centred. The mic sits to the right, outside
+ * the pill, where the thumb is. Living in the composer (not as a floating pill over the mic) keeps
+ * it always visible and never overlapping the mic (device 2026-07-15).
+ */
+export const RecordingHint: React.FC = () => {
+ const { colors } = useTheme();
+ const styles = useThemedStyles(createStyles);
+ return (
+
+
+
+
+ Slide to cancel
+
+
+ );
+};
diff --git a/src/components/ChatInput/Voice.ts b/src/components/ChatInput/Voice.ts
index fdf0db46e..9491154a9 100644
--- a/src/components/ChatInput/Voice.ts
+++ b/src/components/ChatInput/Voice.ts
@@ -32,18 +32,6 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
const [isTranscribingFile, setIsTranscribingFile] = useState(false);
const [directError, setDirectError] = useState(null);
- const {
- isRecording: isWhisperRecording,
- isModelLoading,
- isTranscribing: isWhisperTranscribing,
- partialResult,
- finalResult,
- error: whisperError,
- startRecording: startWhisperRecording,
- stopRecording: stopWhisperRecording,
- clearResult,
- } = useWhisperTranscription();
-
const supportsDirectAudio = (): boolean =>
activeModelService.supportsAudioInput() && audioRecorderService.supportsDirectAudioInput();
@@ -56,7 +44,9 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
// Ensure whisper is resident before transcribing (the decision lives in the pure
// ensureWhisperForTranscription — it frees a blocking generation model, but never
- // evicts on a hard whisper-load failure). One seam for both paths below.
+ // evicts on a hard whisper-load failure). ONE seam for EVERY path: the file paths below
+ // AND the realtime hold-to-talk dictation (injected into useWhisperTranscription), so a
+ // memory-blocked dictation recovers instead of dead-ending.
const ensureWhisper = (): Promise => ensureWhisperForTranscription({
isLoaded: () => whisperService.isModelLoaded(),
hasDownloadedModel: () => !!downloadedModelId,
@@ -66,6 +56,18 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment,
freeGenerationModels: () => activeModelService.unloadAllModels(true).then(() => {}),
});
+ const {
+ isRecording: isWhisperRecording,
+ isModelLoading,
+ isTranscribing: isWhisperTranscribing,
+ partialResult,
+ finalResult,
+ error: whisperError,
+ startRecording: startWhisperRecording,
+ stopRecording: stopWhisperRecording,
+ clearResult,
+ } = useWhisperTranscription({ ensureModelReady: ensureWhisper });
+
const isTranscribing = isWhisperTranscribing || isTranscribingFile;
const isRecording = isDirectRecording || isAudioModeRecording || isWhisperRecording;
const error = directError ?? whisperError;
diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx
index e156a7972..42e8ac2d0 100644
--- a/src/components/ChatInput/index.tsx
+++ b/src/components/ChatInput/index.tsx
@@ -4,6 +4,8 @@ import Icon from 'react-native-vector-icons/Feather';
import { useTheme, useThemedStyles } from '../../theme';
import { ImageModeState, MediaAttachment } from '../../types';
import { VoiceRecordButton } from '../VoiceRecordButton';
+import { RecordingHint } from './RecordingHint';
+import { ComposerIconsRow } from './ComposerIconsRow';
import { AttachStep } from 'react-native-spotlight-tour';
import { triggerHaptic } from '../../utils/haptics';
import logger from '../../utils/logger';
@@ -363,52 +365,38 @@ export const ChatInput: React.FC = ({
/>
-
-
-
-
-
-
-
-
- {showSettingsDot && }
-
-
-
+ {isRecording ? (
+ // Push-to-talk hint inline in the composer (WhatsApp pattern) — see RecordingHint.
+
+ ) : (
+ <>
+
+
+ >
+ )}
{activeSpotlight === 12 ? (
diff --git a/src/components/ChatInput/styles.ts b/src/components/ChatInput/styles.ts
index e7191d3ac..4f2a797f6 100644
--- a/src/components/ChatInput/styles.ts
+++ b/src/components/ChatInput/styles.ts
@@ -132,6 +132,33 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
paddingBottom: Platform.OS === 'ios' ? 10 : 6,
paddingRight: 4,
},
+ // Push-to-talk hint that replaces the text field while holding to record: a recording dot on the
+ // left, "‹ Slide to cancel" centred (the mic sits to the right, outside the pill).
+ recordingRow: {
+ flex: 1,
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ minHeight: 36,
+ },
+ recordingDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: colors.primary,
+ marginRight: SPACING.sm,
+ },
+ slideToCancel: {
+ flex: 1,
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ justifyContent: 'center' as const,
+ gap: 2,
+ },
+ slideToCancelText: {
+ ...TYPOGRAPHY.body,
+ color: colors.textMuted,
+ fontFamily: FONTS.mono,
+ },
// Icons row inside pill (right side)
pillIcons: {
flexDirection: 'row' as const,
diff --git a/src/components/ModelSelectorModal/TextTab.tsx b/src/components/ModelSelectorModal/TextTab.tsx
index 2684aa24a..3cf416451 100644
--- a/src/components/ModelSelectorModal/TextTab.tsx
+++ b/src/components/ModelSelectorModal/TextTab.tsx
@@ -4,6 +4,8 @@ import Icon from 'react-native-vector-icons/Feather';
import { useTheme, useThemedStyles } from '../../theme';
import { DownloadedModel, RemoteModel } from '../../types';
import { hardwareService } from '../../services';
+import { textOverheadMultiplier } from '../../services/activeModelService/types';
+import { useAppStore } from '../../stores';
import { ModelRow } from '../ModelRow';
import { createAllStyles } from './styles';
@@ -29,6 +31,11 @@ export const TextTab: React.FC = ({
}) => {
const { colors } = useTheme();
const styles = useThemedStyles(createAllStyles);
+ // RAM label uses the SAME backend-aware overhead owner (textOverheadMultiplier) that
+ // activeModelService uses to register the resident's sizeMB, so this label and the residency
+ // chip on the manager sheet agree for the identical loaded model (they diverged: fixed 1.5×
+ // here vs 2.2× on a GPU/NPU backend there — device 2026-07-14).
+ const ramMultiplier = textOverheadMultiplier(useAppStore(s => s.settings?.inferenceBackend));
// "Loaded" drives the Currently-Loaded + Unload section (only meaningful once a model
// is actually in memory). "Active" also counts the selected-but-not-yet-loaded model
// so the switcher reads "Switch Model" and highlights the active choice under deferred
@@ -63,7 +70,7 @@ export const TextTab: React.FC = ({
{activeLocalModel
- ? `${activeLocalModel.quantization} • ${hardwareService.formatModelSize(activeLocalModel)} • ${hardwareService.formatModelRam(activeLocalModel)} RAM`
+ ? `${activeLocalModel.quantization} • ${hardwareService.formatModelSize(activeLocalModel)} • ${hardwareService.formatModelRam(activeLocalModel, ramMultiplier)} RAM`
: `Remote • ${activeRemoteModelInfo?.serverName ?? 'Model'}`}
diff --git a/src/components/VoiceRecordButton/index.tsx b/src/components/VoiceRecordButton/index.tsx
index 0995389d0..8bbb604c6 100644
--- a/src/components/VoiceRecordButton/index.tsx
+++ b/src/components/VoiceRecordButton/index.tsx
@@ -106,6 +106,34 @@ const buildChatButtonStyle = (
opts.disabled && styles.buttonDisabled,
];
+/** Audio-mode (tap-to-toggle) busy face: the load vs transcribe spinner. Audio mode has no
+ * hold gesture, so it can safely replace the whole button while busy. Module scope keeps the
+ * pick out of the component's complexity budget. */
+const AudioBusyFace: React.FC<{ kind: 'loading' | 'transcribing'; loadingAnim: Animated.Value }> = ({ kind, loadingAnim }) =>
+ kind === 'loading'
+ ?
+ : ;
+
+/** The inner face of the chat-mode hold button — a spinner while a cold model load /
+ * transcription is in flight, the mic otherwise. Extracted to module scope so the
+ * branch stays out of the component's cyclomatic-complexity budget, and so the ONE
+ * gesturable wrapper can swap its face without ever unmounting (the slide-to-cancel /
+ * ghost-recording fix). */
+const ChatButtonFace: React.FC<{
+ kind: 'loading' | 'transcribing' | 'ready' | 'downloading' | 'unavailable';
+ loadingAnim: Animated.Value;
+ buttonStyle: ReturnType;
+ isRecording: boolean;
+}> = ({ kind, loadingAnim, buttonStyle, isRecording }) => {
+ if (kind === 'loading') return ;
+ if (kind === 'transcribing') return ;
+ return (
+
+
+
+ );
+};
+
export const VoiceRecordButton: React.FC = ({
isRecording,
isAvailable,
@@ -132,6 +160,11 @@ export const VoiceRecordButton: React.FC = ({
isRecording,
downloadProgressById,
});
+ // State-machine trace: which face the mic renders. This is the crux of the
+ // slide-to-cancel / release-during-load behaviour — when kind flips to 'loading'
+ // the hold-to-record view (and its PanResponder) is replaced by a gesture-less
+ // spinner, so the finger that is still down loses its cancel affordance.
+ logger.log('[VoiceButton-SM] render kind=', buttonState.kind, 'asSend=', asSendButton, 'recording=', isRecording);
const pulseAnim = useRef(new Animated.Value(1)).current;
const loadingAnim = useRef(new Animated.Value(0)).current;
@@ -175,10 +208,13 @@ export const VoiceRecordButton: React.FC = ({
useEffect(() => {
if (isRecording) {
+ // Jump the mic noticeably bigger the instant it's pressed (like WhatsApp), so it's obvious the
+ // hold registered, then breathe gently around that enlarged size while recording.
+ pulseAnim.setValue(1.4);
const pulse = Animated.loop(
Animated.sequence([
- Animated.timing(pulseAnim, { toValue: 1.2, duration: 500, useNativeDriver: true }),
- Animated.timing(pulseAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
+ Animated.timing(pulseAnim, { toValue: 1.5, duration: 600, useNativeDriver: true }),
+ Animated.timing(pulseAnim, { toValue: 1.4, duration: 600, useNativeDriver: true }),
]),
);
pulse.start();
@@ -218,19 +254,17 @@ export const VoiceRecordButton: React.FC = ({
/>
);
- if (buttonState.kind === 'loading') {
+ // Audio mode (tap-to-toggle) has no hold gesture, so a load/transcribe spinner can
+ // safely REPLACE the button. Chat mode (asSendButton, hold-to-record + slide-to-cancel)
+ // must NOT early-return here: replacing the button with a bare spinner unmounts the
+ // PanResponder mid-hold, severing the finger's gesture the instant a cold model load
+ // begins — that is what broke slide-to-cancel and left a ghost recording on release
+ // (no responderRelease reached a handler). Chat mode keeps ONE gesturable wrapper
+ // mounted across ready/loading/transcribing and swaps only the inner face (below).
+ if (!asSendButton && (buttonState.kind === 'loading' || buttonState.kind === 'transcribing')) {
return (
-
- {alert}
-
- );
- }
-
- if (buttonState.kind === 'transcribing') {
- return (
-
-
+
{alert}
);
@@ -293,15 +327,12 @@ export const VoiceRecordButton: React.FC = ({
}
// ── Chat mode: hold-to-record with slide-to-cancel ─────────────────────────
+ // The mic follows the finger (translateX) and scales up while pressed. The "Slide to cancel"
+ // hint is NOT drawn here — it lives inline in the composer (ChatInput), the WhatsApp pattern —
+ // so it's always visible and never overlaps the mic. The gesturable wrapper stays mounted across
+ // ready/loading so the hold + slide + release gesture is continuous even through a cold load.
return (
- {isRecording && (
-
- Slide to cancel
-
- )}
{isRecording && partialResult && (
{partialResult}
@@ -313,9 +344,7 @@ export const VoiceRecordButton: React.FC = ({
style={[styles.buttonWrapper, { transform: [{ scale: isRecording ? pulseAnim : 1 }, { translateX: cancelOffsetX }] }]}
{...(disabled ? {} : panResponder.panHandlers)}
>
-
-
-
+
{alert}
diff --git a/src/components/VoiceRecordButton/styles.ts b/src/components/VoiceRecordButton/styles.ts
index 93465bc8b..77693b87a 100644
--- a/src/components/VoiceRecordButton/styles.ts
+++ b/src/components/VoiceRecordButton/styles.ts
@@ -168,19 +168,6 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
backgroundColor: colors.textMuted,
transform: [{ rotate: '-45deg' }],
},
- cancelHint: {
- position: 'absolute' as const,
- left: -100,
- paddingHorizontal: 12,
- paddingVertical: 6,
- backgroundColor: `${colors.primary}40`,
- borderRadius: 12,
- },
- cancelHintText: {
- color: colors.primary,
- fontSize: 12,
- fontWeight: '500' as const,
- },
partialResultContainer: {
position: 'absolute' as const,
right: 50,
diff --git a/src/components/models/WhisperPickerSheet.tsx b/src/components/models/WhisperPickerSheet.tsx
index 9c513ff5b..be1229425 100644
--- a/src/components/models/WhisperPickerSheet.tsx
+++ b/src/components/models/WhisperPickerSheet.tsx
@@ -8,6 +8,7 @@ import type { ThemeColors } from '../../theme';
import { TYPOGRAPHY, SPACING } from '../../constants';
import { WHISPER_MODELS } from '../../services';
import { useWhisperStore } from '../../stores/whisperStore';
+import { useSttDownloadState } from '../../hooks/useSttDownloadState';
type Props = {
visible: boolean;
@@ -24,13 +25,15 @@ export const WhisperPickerSheet: React.FC = ({ visible, onClose }) => {
const downloadedModelId = useWhisperStore((s) => s.downloadedModelId);
const isModelLoading = useWhisperStore((s) => s.isModelLoading);
const presentModelIds = useWhisperStore((s) => s.presentModelIds);
- const downloadProgressById = useWhisperStore((s) => s.downloadProgressById);
const downloadModel = useWhisperStore((s) => s.downloadModel);
const selectModel = useWhisperStore((s) => s.selectModel);
const deleteModelById = useWhisperStore((s) => s.deleteModelById);
const refreshPresentModels = useWhisperStore((s) => s.refreshPresentModels);
- const anyDownloading = Object.keys(downloadProgressById).length > 0;
+ // In-flight download state from the SINGLE owner the Transcription tab also reads, so the picker
+ // and the tab can never disagree (the picker used to read only whisperStore.downloadProgressById
+ // and missed downloads tracked in the canonical store — device 2026-07-15).
+ const { stateFor, anyDownloading } = useSttDownloadState();
useEffect(() => {
if (visible && !anyDownloading) refreshPresentModels();
@@ -43,11 +46,10 @@ export const WhisperPickerSheet: React.FC = ({ visible, onClose }) => {
{WHISPER_MODELS.map((m) => {
const active = downloadedModelId === m.id;
const present = presentModelIds.includes(m.id);
- // Per-model: show this row's own progress, and disable only this row
- // while it downloads — several models can download at once, each with
- // its own percentage (the old single-slot value jumped between them).
- const progress = downloadProgressById[m.id];
- const busy = progress !== undefined;
+ // Per-model in-flight state from the shared owner: this row's own progress, disabled only
+ // while it is busy — several models can download at once, each with its own percentage.
+ const dl = stateFor(m.id);
+ const busy = dl?.active ?? false;
return (
= ({ visible, onClose }) => {
{m.size} MB
{(() => {
- if (busy) return {Math.round(progress * 100)}%;
+ if (dl?.queued) return ;
+ if (dl?.downloading) return {Math.round(dl.progress * 100)}%;
// selectModel sets downloadedModelId optimistically, so the active row IS the one loading —
// show a spinner on it while it loads (not a premature checkmark), matching text/image.
if (active && isModelLoading) return ;
diff --git a/src/hooks/useSttDownloadState.ts b/src/hooks/useSttDownloadState.ts
new file mode 100644
index 000000000..f390f2448
--- /dev/null
+++ b/src/hooks/useSttDownloadState.ts
@@ -0,0 +1,79 @@
+/**
+ * The SINGLE owner of "is this STT (Whisper) model downloading right now, and how far".
+ *
+ * Two surfaces render Whisper model rows — the Models screen's Transcription tab and the Home
+ * "Speech" picker sheet — and they MUST agree. Previously each derived its own answer: the tab
+ * read the canonical download tracker (downloadStore) with a whisper-store fallback, while the
+ * picker read ONLY whisperStore.downloadProgressById. So a download tracked in the canonical store
+ * (started elsewhere, or rehydrated after a relaunch) was invisible to the picker — it showed the
+ * plain "download" icon with no progress while the tab showed the live bar (device 2026-07-15).
+ *
+ * This is that derivation, defined ONCE. The pure `deriveSttDownloadState` is zero-IO (unit-testable);
+ * the hook wraps it over the two live stores. Both surfaces call the hook, so they can never disagree.
+ */
+import { useMemo } from 'react';
+import { useWhisperStore } from '../stores/whisperStore';
+import { useDownloadStore } from '../stores/downloadStore';
+import { isActiveStatus, isQueuedStatus, isDownloadingStatus, type DownloadEntry } from '../utils/downloadStatus';
+
+interface SttDownloadEntry {
+ /** 0..1 transfer progress. */
+ progress: number;
+ /** In flight (downloading OR queued) — the row is busy and not tappable. */
+ active: boolean;
+ /** Bytes are transferring right now (show the percentage). */
+ downloading: boolean;
+ /** Waiting for a concurrency slot (show a clock, not 0%). */
+ queued: boolean;
+}
+
+/** Whisper download-store ids are prefixed `whisper-`; the model ids the UI uses are bare. */
+const bareWhisperId = (modelId: string): string =>
+ modelId.startsWith('whisper-') ? modelId.slice('whisper-'.length) : modelId;
+
+/**
+ * PURE, zero-IO: merge the canonical download tracker with the whisper-store fallback into a
+ * per-model in-flight map. The canonical store wins (it survives relaunch and reports failed as
+ * inactive); the whisper store covers the RNFS URL-import path, which never creates a canonical
+ * entry. A fallback entry still at 0% is WAITING for a slot → queued; once a byte lands (p>0) it
+ * is transferring (without this, queued STT models rendered "0%" instead of "Queued").
+ */
+function deriveSttDownloadState(
+ downloads: Record,
+ downloadProgressById: Record,
+): { byId: Record; anyDownloading: boolean } {
+ const byId: Record = {};
+ for (const e of Object.values(downloads)) {
+ if (e.modelType !== 'stt') continue;
+ byId[bareWhisperId(e.modelId)] = {
+ progress: e.progress ?? 0,
+ active: isActiveStatus(e.status),
+ downloading: isDownloadingStatus(e.status),
+ queued: isQueuedStatus(e.status),
+ };
+ }
+ for (const [id, p] of Object.entries(downloadProgressById)) {
+ if (id in byId) continue; // canonical entry wins
+ byId[id] = { progress: p, active: true, downloading: p > 0, queued: p === 0 };
+ }
+ const anyDownloading = Object.values(byId).some((s) => s.active);
+ return { byId, anyDownloading };
+}
+
+export interface UseSttDownloadState {
+ /** In-flight state for one whisper model id, or undefined when not downloading. */
+ stateFor: (whisperModelId: string) => SttDownloadEntry | undefined;
+ /** True while any transcription model is actively downloading. */
+ anyDownloading: boolean;
+}
+
+/** Subscribe both stores and expose the single derivation. */
+export function useSttDownloadState(): UseSttDownloadState {
+ const downloads = useDownloadStore((s) => s.downloads);
+ const downloadProgressById = useWhisperStore((s) => s.downloadProgressById);
+ const { byId, anyDownloading } = useMemo(
+ () => deriveSttDownloadState(downloads, downloadProgressById),
+ [downloads, downloadProgressById],
+ );
+ return useMemo(() => ({ stateFor: (id: string) => byId[id], anyDownloading }), [byId, anyDownloading]);
+}
diff --git a/src/hooks/useWhisperTranscription.ts b/src/hooks/useWhisperTranscription.ts
index 51d11a545..5e1966216 100644
--- a/src/hooks/useWhisperTranscription.ts
+++ b/src/hooks/useWhisperTranscription.ts
@@ -11,6 +11,18 @@ const useMountedRef = () => {
return mounted;
};
+export interface UseWhisperTranscriptionParams {
+ /**
+ * Get whisper resident before the realtime session starts, recovering from a memory refusal — the
+ * SAME owner the file path uses (ensureWhisperForTranscription: try load; on a 'blocked' single-model
+ * refusal, free the generation model and retry). Resolves true when whisper is loaded, false when it
+ * can't be (no model / hard failure). Injected so both dictation modes share ONE recovery path — the
+ * realtime path must NOT call whisperStore.loadModel() directly (a 'blocked' return there is not a
+ * throw, so it dead-ends into startRealtimeTranscription → 'No Whisper model loaded', no recovery).
+ */
+ ensureModelReady: () => Promise;
+}
+
export interface UseWhisperTranscriptionResult {
isRecording: boolean;
isModelLoaded: boolean;
@@ -25,7 +37,7 @@ export interface UseWhisperTranscriptionResult {
clearResult: () => void;
}
-export const useWhisperTranscription = (): UseWhisperTranscriptionResult => {
+export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscriptionParams): UseWhisperTranscriptionResult => {
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const [partialResult, setPartialResult] = useState('');
@@ -33,11 +45,16 @@ export const useWhisperTranscription = (): UseWhisperTranscriptionResult => {
const [error, setError] = useState(null);
const [recordingTime, setRecordingTime] = useState(0);
const isCancelled = useRef(false);
+ // Session-intent nonce: bumped whenever a start is superseded (stop/cancel). startRecording captures
+ // it before the async ensureModelReady() gap (which now frees+reloads the model, so it can be seconds)
+ // and aborts the continuation if it changed — otherwise releasing/cancelling the mic DURING the load
+ // would still activate a recording that no stop event can reach (a ghost session). (#558 CodeRabbit)
+ const startNonce = useRef(0);
const mountedRef = useMountedRef();
const transcribingStartTime = useRef(null);
const pendingResult = useRef(null);
- const { downloadedModelId, isModelLoaded, isModelLoading, loadModel } = useWhisperStore();
+ const { isModelLoaded, isModelLoading } = useWhisperStore();
// On unmount, stop any in-flight realtime session. Without this the mic kept
// capturing after the user navigated away without releasing the button — the
@@ -109,6 +126,8 @@ export const useWhisperTranscription = (): UseWhisperTranscriptionResult => {
const stopRecording = useCallback(async () => {
logger.log('[Whisper] stopRecording called');
+ // Supersede any in-flight start still awaiting model load — it must not resurrect a recording.
+ startNonce.current++;
// Immediately update UI to show "Transcribing..." state
// But keep recording in background for better accuracy
if (mountedRef.current) setIsRecording(false);
@@ -149,6 +168,7 @@ export const useWhisperTranscription = (): UseWhisperTranscriptionResult => {
setPartialResult('');
setIsTranscribing(false);
isCancelled.current = true;
+ startNonce.current++; // supersede an in-flight start awaiting model load (no ghost recording)
pendingResult.current = null;
transcribingStartTime.current = null;
// Also ensure recording is stopped
@@ -171,18 +191,27 @@ export const useWhisperTranscription = (): UseWhisperTranscriptionResult => {
return;
}
+ // Capture this start's intent BEFORE the async model-load gap. If stop/cancel bumps the nonce while
+ // we await, this start has been superseded → abort, or we'd activate a ghost recording no stop reaches.
+ const currentNonce = ++startNonce.current;
+
if (!whisperService.isModelLoaded()) {
- logger.log('[Whisper] Model not loaded, trying to load...');
- // Try to load if we have a downloaded model
- if (downloadedModelId) {
- try {
- await loadModel();
- } catch {
- setError('Failed to load Whisper model. Please try again.');
- return;
- }
- } else {
- setError('No transcription model downloaded. Go to Settings to download one.');
+ logger.log('[Whisper] Model not loaded, ensuring readiness (blocked → free generation model → retry)...');
+ // Route through the SAME recovery the file path uses: a 'blocked' single-model refusal frees the
+ // resident generation model and retries. Never call loadModel() directly here — a 'blocked' return
+ // is not a throw, so it would dead-end into startRealtimeTranscription → 'No Whisper model loaded'.
+ let ready = false;
+ try {
+ ready = await ensureModelReady();
+ } catch {
+ ready = false;
+ }
+ if (startNonce.current !== currentNonce || !mountedRef.current) {
+ logger.log('[Whisper] Start superseded during model load (stopped/cancelled) — aborting, no ghost recording');
+ return;
+ }
+ if (!ready) {
+ setError("Couldn't load the voice model — free some memory and try again");
return;
}
}
@@ -245,7 +274,7 @@ export const useWhisperTranscription = (): UseWhisperTranscriptionResult => {
Vibration.vibrate([0, 50, 50, 50]);
}
}
- }, [downloadedModelId, loadModel, isRecording, stopRecording, finalizeTranscription]);
+ }, [ensureModelReady, isRecording, stopRecording, finalizeTranscription]);
return {
isRecording,
diff --git a/src/screens/ChatScreen/ChatScreenComponents.tsx b/src/screens/ChatScreen/ChatScreenComponents.tsx
index 44a2ea985..1e13ae480 100644
--- a/src/screens/ChatScreen/ChatScreenComponents.tsx
+++ b/src/screens/ChatScreen/ChatScreenComponents.tsx
@@ -197,9 +197,14 @@ export const ImageProgressIndicator: React.FC<{
)}
-
-
-
+ {/* The placeholder image glyph is only meaningful BEFORE the live preview renders.
+ Once the preview thumbnail is up (Refining Image), drop it — it just overlapped
+ the real image (device 2026-07-16). */}
+ {!imagePreviewPath && (
+
+
+
+ )}
{imagePreviewPath ? 'Refining Image' : 'Generating Image'}
diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts
index b3dec9e1f..971222567 100644
--- a/src/screens/ChatScreen/useChatGenerationActions.ts
+++ b/src/screens/ChatScreen/useChatGenerationActions.ts
@@ -454,6 +454,58 @@ export async function startGenerationFn(deps: GenerationDeps, call: StartGenerat
generationSession.end();
}
let _msgIdSeq = 0; const nextMsgId = () => `${Date.now()}-${(++_msgIdSeq).toString(36)}`;
+
+/** The outcome of the shared post-decision dispatch: either the turn is fully HANDLED here (an image was
+ * generated, or the text route bailed because no text model could be provisioned), or the caller must run
+ * its own text executor with the (possibly image-fallback-augmented) messageText. */
+type ResolvedDispatch = { handled: true } | { handled: false; messageText: string };
+
+/**
+ * THE single post-decision dispatch seam — shared by send (dispatchGenerationFn) AND resend
+ * (regenerateResponseFn) so the two can never diverge once resolveTurnKind has chosen the modality.
+ * Given the resolved `kind`, it applies the SAME image-model guard, text-model provisioning, and
+ * image-fallback note to both paths; the only per-path variance (whether the user message already
+ * exists in history, and what to do when no text model can be provisioned) is injected via `opts`.
+ * The prior bug was two post-decision sites: resend fired the image pipeline UNCONDITIONALLY (no
+ * activeImageModel guard) and had no text-provision path, so the same prompt behaved differently on
+ * resend vs send when no image model / no text model was loaded. This is now decided in ONE place.
+ */
+async function dispatchResolvedTurn(
+ deps: GenerationDeps,
+ kind: TurnKind,
+ opts: {
+ /** The user text for the turn (image prompt + text-route base before the image-fallback note). */
+ text: string;
+ /** Attachments carried on the user message (kept on the image user message, e.g. a voice note). */
+ attachments?: MediaAttachment[];
+ conversationId: string;
+ /** True on resend: the user message already exists in history, so the image path must not re-add it. */
+ imageSkipsUserMessage: boolean;
+ /** Called when the text route needs a text model (image-only device) but none could be provisioned —
+ * send stashes a pending message here; resend just bails. Return value is ignored (the turn is handled). */
+ onTextModelUnavailable: () => void;
+ },
+): Promise {
+ const shouldGenerateImage = kind === 'image';
+ if (shouldGenerateImage && deps.activeImageModel) {
+ logger.log('[ROUTE-SM] dispatch → IMAGE pipeline');
+ await handleImageGenerationFn(deps, { prompt: opts.text, conversationId: opts.conversationId, attachments: opts.attachments, skipUserMessage: opts.imageSkipsUserMessage });
+ return { handled: true };
+ }
+ logger.log(`[ROUTE-SM] dispatch → TEXT generation (shouldGenerateImage=${shouldGenerateImage})`);
+ // Text route, no text model selected (image-only device): load one / open selector.
+ if (!shouldGenerateImage && deps.hasTextModel === false && !deps.activeModelInfo?.isRemote) {
+ const ready = await deps.ensureTextModelForChat();
+ if (!ready) {
+ opts.onTextModelUnavailable();
+ return { handled: true };
+ }
+ }
+ let messageText = appendAttachmentText(opts.text, opts.attachments);
+ if (shouldGenerateImage && !deps.activeImageModel) messageText = `[User wanted an image but no image model is loaded] ${messageText}`;
+ return { handled: false, messageText };
+}
+
export type DispatchCall = { text: string; attachments?: MediaAttachment[]; conversationId: string; imageMode?: 'auto' | 'force' | 'disabled' };
/**
* THE routing layer: the single place a message is classified and dispatched to
@@ -467,34 +519,24 @@ export async function dispatchGenerationFn(
startTextGeneration: (convId: string, messageText: string) => Promise,
): Promise {
const { text, attachments, conversationId, imageMode = 'auto' } = call;
- let messageText = appendAttachmentText(text, attachments);
+ const messageTextForRoute = appendAttachmentText(text, attachments);
// [ROUTE-SM]: confirms the turn reached the router (esp. the voice path) + the
// final routed destination — so a "pipeline never triggered" is visible in logs.
logger.log(`[ROUTE-SM] dispatch text="${text.slice(0, 60)}" imageMode=${imageMode} hasImageModel=${!!deps.activeImageModel}`);
// ONE decision seam (resolveTurnKind); a NEW turn has no recorded kind so the route rule decides.
- const kind = await resolveTurnKind(deps, { text: messageText, forceImageMode: imageMode === 'force', imageEnabled: imageMode !== 'disabled' });
- const shouldGenerateImage = kind === 'image';
- if (shouldGenerateImage && deps.activeImageModel) {
- logger.log('[ROUTE-SM] dispatch → IMAGE pipeline');
- await handleImageGenerationFn(deps, { prompt: text, conversationId, attachments }); // adds user msg (keeps voice note)
- return;
- }
- logger.log(`[ROUTE-SM] dispatch → TEXT generation (shouldGenerateImage=${shouldGenerateImage})`);
- // Text route, no text model selected (image-only device): load one / open selector.
- if (!shouldGenerateImage && deps.hasTextModel === false && !deps.activeModelInfo?.isRemote) {
- const ready = await deps.ensureTextModelForChat();
- if (!ready) {
- deps.setPendingMessage?.(text, attachments);
- return;
- }
- }
- if (shouldGenerateImage && !deps.activeImageModel) messageText = `[User wanted an image but no image model is loaded] ${messageText}`;
+ const kind = await resolveTurnKind(deps, { text: messageTextForRoute, forceImageMode: imageMode === 'force', imageEnabled: imageMode !== 'disabled' });
+ // ONE post-decision dispatch seam, shared with resend (image-model guard + text-provision path).
+ const result = await dispatchResolvedTurn(deps, kind, {
+ text, attachments, conversationId, imageSkipsUserMessage: false,
+ onTextModelUnavailable: () => { deps.setPendingMessage?.(text, attachments); },
+ });
+ if (result.handled) return;
deps.addMessage(conversationId, { role: 'user', content: text, attachments });
- await startTextGeneration(conversationId, messageText);
+ await startTextGeneration(conversationId, result.messageText);
}
export type SendCall = { text: string; attachments?: MediaAttachment[]; imageMode?: 'auto' | 'force' | 'disabled'; startGeneration: (convId: string, text: string) => Promise; setDebugInfo: SetState };
export async function handleSendFn(deps: GenerationDeps, call: SendCall): Promise {
- const { text, attachments, imageMode, startGeneration } = call;
+ const { text, attachments, imageMode = 'auto', startGeneration } = call;
abortPreload(); // user acted — stop background warming so it can't block them
if (!deps.hasActiveModel) { deps.setAlertState(showAlert('No Model Selected', 'Please select a model first.')); return; }
// Vision gate (shared with resend): never send an image to a model that can't do vision.
@@ -510,7 +552,9 @@ export async function handleSendFn(deps: GenerationDeps, call: SendCall): Promis
// Cross-modality serialization: queue if any generation is running (routed later).
if (generationService.getState().isGenerating || imageGenerationService.getState().isGenerating) {
const messageText = appendAttachmentText(text, attachments);
- generationService.enqueueMessage({ id: nextMsgId(), conversationId: targetConversationId, text, attachments, messageText });
+ // Carry the user's forced modality through the queue so a queued force-image send is dispatched as
+ // image on drain — not re-decided at 'auto' by resolveTurnKind (#510).
+ generationService.enqueueMessage({ id: nextMsgId(), conversationId: targetConversationId, text, attachments, messageText, imageMode });
return;
}
await dispatchGenerationFn(deps, { text, attachments, conversationId: targetConversationId, imageMode }, startGeneration);
@@ -541,15 +585,22 @@ export async function regenerateResponseFn(deps: GenerationDeps, call: Regenerat
if (!deps.activeConversationId || !deps.hasActiveModel) { logger.log('[RESEND-SM] regenerate BAIL: no conv or no active model'); return; }
await modelResidencyManager.reclaimSttForGeneration(); // free idle Whisper before the LLM reload (memory-tight)
const targetConversationId = deps.activeConversationId;
- const messageText = appendAttachmentText(userMessage.content, userMessage.attachments);
+ const messageTextForRoute = appendAttachmentText(userMessage.content, userMessage.attachments);
// Same decision seam as dispatch (resolveTurnKind): a replay passes the RECORDED kind, which wins
// verbatim — an image turn re-runs the image pipeline, NEVER re-classifies to text and fails to
// load a text model (the 1★ resend bug). Only a legacy turn with no recorded kind classifies.
- const kind = await resolveTurnKind(deps, { text: messageText, recordedKind });
- if (kind === 'image') {
- await handleImageGenerationFn(deps, { prompt: userMessage.content, conversationId: targetConversationId, skipUserMessage: true });
- return;
- }
+ const kind = await resolveTurnKind(deps, { text: messageTextForRoute, recordedKind });
+ // SAME post-decision dispatch seam as send: the image path is guarded on activeImageModel (so an
+ // image turn resent with no image model FALLS BACK to text like send, instead of erroring), and the
+ // text route provisions a text model on an image-only device (like send). skipUserMessage: the user
+ // message already exists in history on resend.
+ const result = await dispatchResolvedTurn(deps, kind, {
+ text: userMessage.content, attachments: userMessage.attachments, conversationId: targetConversationId,
+ imageSkipsUserMessage: true,
+ onTextModelUnavailable: () => { deps.setPendingMessage?.(userMessage.content, userMessage.attachments); },
+ });
+ if (result.handled) return;
+ const messageText = result.messageText;
// Same vision gate as the send path: resending a turn whose message carries an image must not push it to a
// model that can't do vision (would crash with "Multimodal support not enabled"). Shared gate → identical UX.
if (blockedImageForNonVisionModel(deps, userMessage.attachments)) return;
diff --git a/src/screens/ChatScreen/useChatScreen.ts b/src/screens/ChatScreen/useChatScreen.ts
index 3d5d9dbfe..4529c11e7 100644
--- a/src/screens/ChatScreen/useChatScreen.ts
+++ b/src/screens/ChatScreen/useChatScreen.ts
@@ -257,8 +257,10 @@ export const useChatScreen = () => {
// Drain queued messages through the same routing layer as a fresh send.
const handleQueuedSend = useCallback(async (item: QueuedMessage) => {
+ // Pass the queued send's forced modality (imageMode) so a message the user forced to image mode
+ // is dispatched as image, not re-decided at 'auto' by resolveTurnKind (#510).
await dispatchGenerationFn(genDepsRef.current,
- { text: item.text, attachments: item.attachments, conversationId: item.conversationId }, startGenerationRef.current);
+ { text: item.text, attachments: item.attachments, conversationId: item.conversationId, imageMode: item.imageMode }, startGenerationRef.current);
}, []);
useEffect(() => {
diff --git a/src/screens/DownloadManagerScreen/retryHandlers.ts b/src/screens/DownloadManagerScreen/retryHandlers.ts
index b5e0d2be3..c5670d90e 100644
--- a/src/screens/DownloadManagerScreen/retryHandlers.ts
+++ b/src/screens/DownloadManagerScreen/retryHandlers.ts
@@ -15,6 +15,7 @@ import { backgroundDownloadService } from '../../services';
import { DownloadItem } from './items';
import logger from '../../utils/logger';
import { proceedWithDownload } from '../ModelsScreen/imageDownloadActions';
+import { imageDescriptorFromMetadata } from '../ModelsScreen/imageDescriptor';
import { resumeImageDownload } from '../ModelsScreen/imageDownloadResume';
export function parseEntryMetadata(entry: DownloadEntry): Record | null {
@@ -60,20 +61,7 @@ async function retryIosImageDownload(entry: DownloadEntry, setAlertState: (s: Al
setAlertState,
triedImageGen: appState.onboardingChecklist.triedImageGen,
};
- await proceedWithDownload({
- id: modelId,
- name: meta.imageModelName,
- description: meta.imageModelDescription,
- downloadUrl: meta.imageModelDownloadUrl ?? '',
- size: meta.imageModelSize,
- style: meta.imageModelStyle,
- backend: meta.imageModelBackend,
- attentionVariant: meta.imageModelAttentionVariant,
- huggingFaceRepo: meta.imageModelRepo,
- huggingFaceFiles: meta.imageModelHuggingFaceFiles,
- coremlFiles: meta.imageModelCoremlFiles,
- repo: meta.imageModelRepo,
- }, deps);
+ await proceedWithDownload(imageDescriptorFromMetadata(modelId, meta), deps);
}
/**
diff --git a/src/screens/ModelDownloadScreen.tsx b/src/screens/ModelDownloadScreen.tsx
index 2cdd1c50b..ac96fc008 100644
--- a/src/screens/ModelDownloadScreen.tsx
+++ b/src/screens/ModelDownloadScreen.tsx
@@ -24,7 +24,7 @@ import { useRemoteServerStore } from '../stores/remoteServerStore';
import { hardwareService, modelManager, remoteServerManager } from '../services';
import { startModelDownload } from '../services/startModelDownload';
import { recommendedModelsForDevice, trendingModelIdsForDevice } from '../utils/recommendedModels';
-import { modelBudgetFraction } from '../services/memoryBudget';
+import { fileExceedsBudget } from '../services/memoryBudget';
import { discoverLANServers } from '../services/networkDiscovery';
import { ModelFile, DownloadedModel, RemoteServer } from '../types';
import { RootStackParamList } from '../navigation/types';
@@ -102,7 +102,12 @@ const LiteRTModelCard: React.FC = ({ file, index, curatedEntry,
isQueued={!!progress?.queued}
downloadProgress={progress?.progress}
downloadBytes={progress?.bytes}
- isCompatible={file.size / (1024 ** 3) < totalRamGB * modelBudgetFraction(totalRamGB)}
+ // Offer the card (enable its download button) when the file fits the budget OR carries a
+ // device-aware warning — the "Download anyway" sheet in handleLiteRTDownload is the guard for
+ // the over-budget case. Both branches route through the single owners (fileExceedsBudget +
+ // curatedLiteRTDownloadWarning), never a re-inlined budget calc. A card disabled here would make
+ // its warning branch unreachable (button disabled), which was the #510 defect.
+ isCompatible={!fileExceedsBudget(file.size, totalRamGB) || curatedLiteRTDownloadWarning(file.name, file.size, totalRamGB) !== null}
recommended={{ pillLabel: 'Recommended' }}
supportsAcceleration
onPress={() => {}}
@@ -291,12 +296,18 @@ export const ModelDownloadScreen: React.FC = ({ navigation }) => {
const totalRamGB = hardwareService.getTotalMemoryGB();
- // Curated LiteRT models — Android-only, filtered to what fits in RAM (same
- // 60%-of-RAM headroom the model browser uses). No HF fetch needed; the files
- // come straight from the curated registry with their download URLs baked in.
+ // Curated LiteRT models — Android-only. Offer a file when it FITS the RAM budget, OR when it is
+ // over budget but carries a device-aware warning (e.g. Gemma 4 E4B): those download behind the
+ // "Download anyway" confirm sheet (handleLiteRTDownload) rather than being silently hidden. An
+ // over-budget file with NO warning stays hidden — there is no safe way to offer it. The decision
+ // is the single owners (fileExceedsBudget + curatedLiteRTDownloadWarning), not a re-inlined budget
+ // calc; hiding warnable files here made the warning branch dead code (#510). No HF fetch needed;
+ // the files come straight from the curated registry with their download URLs baked in.
const liteRTFiles = React.useMemo(
() => (Platform.OS === 'android'
- ? buildCuratedLiteRTFiles().filter((f) => f.size / (1024 ** 3) < totalRamGB * modelBudgetFraction(totalRamGB))
+ ? buildCuratedLiteRTFiles().filter((f) =>
+ !fileExceedsBudget(f.size, totalRamGB)
+ || curatedLiteRTDownloadWarning(f.name, f.size, totalRamGB) !== null)
: []),
[totalRamGB],
);
diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx
index 189c59293..af1c8526e 100644
--- a/src/screens/ModelsScreen/TextModelsTab.tsx
+++ b/src/screens/ModelsScreen/TextModelsTab.tsx
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { View, Text, FlatList, TextInput, ActivityIndicator, RefreshControl, TouchableOpacity, InteractionManager, Platform } from 'react-native';
import DeviceInfo from 'react-native-device-info';
import Icon from 'react-native-vector-icons/Feather';
-import { modelBudgetFraction } from '../../services/memoryBudget';
+import { fileExceedsBudget } from '../../services/memoryBudget';
import { AttachStep, useSpotlightTour } from 'react-native-spotlight-tour';
import { Card, ModelCard } from '../../components';
import { AnimatedEntry } from '../../components/AnimatedEntry';
@@ -25,8 +25,9 @@ import { SORT_OPTIONS } from './constants';
import { formatNumber, getTextModelCompatibility } from './utils';
import { curatedLiteRTDownloadWarning, LITERT_PARENT_ID } from '../../services/curatedLiteRTRegistry';
import { LITERT_FILE_META, LITERT_RECOMMENDED_MODEL, LITERT_PARENT_RECOMMENDED } from './litertRecommended';
-import { backgroundDownloadService, modelManager } from '../../services';
-import { useAppStore } from '../../stores';
+import { modelManager } from '../../services';
+import { modelDownloadService } from '../../services/modelDownloadService';
+import { uniformDownloadId } from '../../services/modelDownloadService/uniformId';
function hasNonSortFilters(fs: FilterState): boolean {
return fs.orgs.length > 0 || fs.type !== 'all' || fs.source !== 'all' || fs.size !== 'all' || fs.quant !== 'all';
@@ -100,7 +101,6 @@ 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
@@ -157,42 +157,6 @@ const ModelDetailView: React.FC = ({
return { downloadKey: modelKey, progress, downloaded, downloadedModel, needsVisionRepair, repairingVision, canCancel, hasFailed, errorMessage };
};
- 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 () => {
- const models = await modelManager.getDownloadedModels();
- setDownloadedModels(models);
- const key = useDownloadStore.getState().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 }) => {
const s = getFileCardState(item);
const proceedDownload = () => {
@@ -204,12 +168,16 @@ const ModelDetailView: React.FC = ({
const displayName = liteRTMeta?.displayName ?? item.name.replace('.gguf', '');
const recommended = liteRTMeta ? { pillLabel: 'Recommended', highlightText: liteRTMeta.highlight } : undefined;
const storeEntry = storeDownloads[s.downloadKey];
- const failedState = s.hasFailed && s.errorMessage && storeEntry?.downloadId
+ // Retry routes through the single owner (modelDownloadService → textProvider): Android resumes the
+ // native row, iOS re-issues from the entry's metadata. The provider owns the platform decision AND
+ // the lost-downloadId case (a rehydrated app-killed entry can have no downloadId), so the failed
+ // card must render its Retry regardless of downloadId — gating on it here made iOS retry unreachable.
+ const failedState = s.hasFailed && s.errorMessage && storeEntry
? {
errorMessage: s.errorMessage,
bytesDownloaded: storeEntry.bytesDownloaded,
totalBytes: storeEntry.combinedTotalBytes || storeEntry.totalBytes,
- onRetry: () => Platform.OS === 'android' ? handleRetryDownload(s.downloadKey, storeEntry.downloadId) : proceedDownload(),
+ onRetry: () => { modelDownloadService.retry(uniformDownloadId('text', s.downloadKey)).catch(() => {}); },
onRemove: () => handleCancelDownload(s.downloadKey),
}
: undefined;
@@ -222,7 +190,7 @@ const ModelDetailView: React.FC = ({
downloadProgress={s.progress?.progress}
downloadBytes={s.progress && !s.hasFailed ? { downloaded: s.progress.bytesDownloaded, total: s.progress.totalBytes } : undefined}
isRepairingVision={s.repairingVision}
- isCompatible={item.size / (1024 ** 3) < ramGB * modelBudgetFraction(ramGB)} testID={`file-card-${index}`}
+ isCompatible={!fileExceedsBudget(item.size, ramGB)} testID={`file-card-${index}`}
onDownload={onDownload}
onDelete={s.downloaded ? () => handleDeleteModel(`${selectedModel.id}/${item.name}`) : undefined}
onRepairVision={s.needsVisionRepair && !s.progress && !s.repairingVision ? () => handleRepairMmProj(selectedModel, item) : undefined}
@@ -287,7 +255,7 @@ const ModelDetailView: React.FC = ({
) : (
f.size > 0 && f.size / (1024 ** 3) < ramGB * modelBudgetFraction(ramGB) && (filterState.quant === 'all' || f.name.includes(filterState.quant)))
+ .filter(f => f.size > 0 && !fileExceedsBudget(f.size, ramGB) && (filterState.quant === 'all' || f.name.includes(filterState.quant)))
.sort((a, b) => {
if (selectedModel.id === LITERT_PARENT_ID) return a.size - b.size; // curated: small-first
// Tier: Q4_K_M (CPU default, lowest size) → GPU/NPU Q4_0/Q8_0 → rest (CPU
diff --git a/src/screens/ModelsScreen/TranscriptionModelsTab.tsx b/src/screens/ModelsScreen/TranscriptionModelsTab.tsx
index 27d725c60..6b775e819 100644
--- a/src/screens/ModelsScreen/TranscriptionModelsTab.tsx
+++ b/src/screens/ModelsScreen/TranscriptionModelsTab.tsx
@@ -10,7 +10,7 @@
* The whisper store tracks a single active model; downloading another switches
* the active one.
*/
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Feather';
@@ -20,7 +20,7 @@ import { useTheme, useThemedStyles } from '../../theme';
import type { ThemeColors, ThemeShadows } from '../../theme';
import { TYPOGRAPHY, SPACING } from '../../constants';
import { useWhisperStore } from '../../stores';
-import { useDownloadStore, isActiveStatus, isQueuedStatus, isDownloadingStatus } from '../../stores/downloadStore';
+import { useSttDownloadState } from '../../hooks/useSttDownloadState';
import { WHISPER_MODELS } from '../../services';
import { createStyles as createModelsScreenStyles } from './styles';
import logger from '../../utils/logger';
@@ -81,52 +81,16 @@ export const TranscriptionModelsTab: React.FC = () => {
const [alertState, setAlertState] = useState(initialAlertState);
const {
- downloadedModelId, presentModelIds, downloadProgressById, downloadModel,
+ downloadedModelId, presentModelIds, downloadModel,
selectModel, deleteModelById, refreshPresentModels, error: whisperError, clearError,
} = useWhisperStore();
- // In-flight STT state from the canonical download tracker (same store the Download
- // Manager reads), so the two screens can never disagree. A failed entry reports
- // active=false here, so a stuck "downloading" bar on this tab can't linger while the
- // Download Manager shows "failed" — the model just becomes downloadable again.
- const downloads = useDownloadStore((s) => s.downloads);
- const sttDownloadState = useMemo(() => {
- const byModel: Record = {};
- for (const e of Object.values(downloads)) {
- if (e.modelType !== 'stt') continue;
- const id = e.modelId.startsWith('whisper-') ? e.modelId.slice('whisper-'.length) : e.modelId;
- // Split queued vs transferring via the shared classifier so a queued STT model
- // shows the clock — the same rule the Text/Image tabs use.
- byModel[id] = {
- progress: e.progress ?? 0,
- active: isActiveStatus(e.status),
- downloading: isDownloadingStatus(e.status),
- queued: isQueuedStatus(e.status),
- };
- }
- return byModel;
- }, [downloads]);
-
- // Per-model in-flight state: prefer the canonical download tracker; fall back to the
- // whisper store for the RNFS URL-import path, which has no download-store entry.
- const downloadStateFor = useCallback((id: string): { progress: number; active: boolean; downloading: boolean; queued: boolean } | undefined => {
- const fromStore = sttDownloadState[id];
- if (fromStore) return fromStore;
- const p = downloadProgressById[id];
- if (p === undefined) return undefined;
- // Fallback path: the whisper store seeds progress 0 at request time, but the
- // canonical download-store entry is only created once a concurrency slot opens.
- // So a fallback entry still at 0% is WAITING for a slot → queued (not a 0%
- // active download); once the first byte lands (p > 0) it is transferring. Without
- // this, queued STT models rendered "0%" instead of "Queued".
- return { progress: p, active: true, downloading: p > 0, queued: p === 0 };
- }, [sttDownloadState, downloadProgressById]);
-
- // True while any transcription model is actively downloading. Disk probes are
- // deferred until everything settles so an in-flight file isn't mistaken for absent.
- const anyDownloading =
- Object.values(sttDownloadState).some((s) => s.active) ||
- Object.keys(downloadProgressById).some((id) => !(id in sttDownloadState));
+ // In-flight STT state from the SINGLE owner (canonical download tracker + whisper-store
+ // fallback), shared with the Home "Speech" picker so the two surfaces can never disagree.
+ // A failed entry reports active=false, so a stuck "downloading" bar can't linger while the
+ // Download Manager shows "failed" — the model just becomes downloadable again. Disk probes
+ // are deferred until nothing is downloading so an in-flight file isn't mistaken for absent.
+ const { stateFor: downloadStateFor, anyDownloading } = useSttDownloadState();
// Probe disk on mount and whenever downloads finish, so every on-disk model
// (not just the active one) shows as downloaded.
diff --git a/src/screens/ModelsScreen/imageDescriptor.ts b/src/screens/ModelsScreen/imageDescriptor.ts
new file mode 100644
index 000000000..494b46dc2
--- /dev/null
+++ b/src/screens/ModelsScreen/imageDescriptor.ts
@@ -0,0 +1,23 @@
+import { ImageModelDescriptor } from './types';
+
+/** Reconstruct an ImageModelDescriptor from a download entry's persisted metadata — the SINGLE
+ * source for "re-download this image model from what we remembered about it". Used by the iOS
+ * retry path (retryHandlers) and by resume's re-download-on-unrecoverable fallback so the two
+ * can't drift. Pure (zero-IO). Safe defaults keep it valid; undefined coreml/hf fields route it
+ * to the zip download path. */
+export function imageDescriptorFromMetadata(modelId: string, meta: Record): ImageModelDescriptor {
+ return {
+ id: modelId,
+ name: meta.imageModelName,
+ description: meta.imageModelDescription ?? '',
+ downloadUrl: meta.imageModelDownloadUrl ?? '',
+ size: meta.imageModelSize ?? 0,
+ style: meta.imageModelStyle ?? '',
+ backend: meta.imageModelBackend ?? 'coreml',
+ attentionVariant: meta.imageModelAttentionVariant,
+ huggingFaceRepo: meta.imageModelRepo,
+ huggingFaceFiles: meta.imageModelHuggingFaceFiles,
+ coremlFiles: meta.imageModelCoremlFiles,
+ repo: meta.imageModelRepo,
+ };
+}
diff --git a/src/screens/ModelsScreen/imageDownloadResume.ts b/src/screens/ModelsScreen/imageDownloadResume.ts
index 7203a8f99..183a6f33a 100644
--- a/src/screens/ModelsScreen/imageDownloadResume.ts
+++ b/src/screens/ModelsScreen/imageDownloadResume.ts
@@ -4,7 +4,8 @@ import { modelManager, backgroundDownloadService } from '../../services';
import { resolveCoreMLModelDir } from '../../utils/coreMLModelUtils';
import { ONNXImageModel } from '../../types';
import { useDownloadStore, DownloadEntry } from '../../stores/downloadStore';
-import { ImageDownloadDeps, registerAndNotify } from './imageDownloadActions';
+import { ImageDownloadDeps, registerAndNotify, proceedWithDownload } from './imageDownloadActions';
+import { imageDescriptorFromMetadata } from './imageDescriptor';
import { validateImageModelDir, ensureImageExtractionComplete } from '../../utils/imageModelIntegrity';
import { makeImageModelKey } from '../../utils/modelKey';
import logger from '../../utils/logger';
@@ -78,6 +79,24 @@ async function cleanupInvalidArtifact(path: string): Promise {
}
}
+/** The completed bytes are unrecoverable — the native staging was purged (iOS temp reaping on builds
+ * before durable staging, or the user cleared storage) and neither a valid zip nor an extracted dir
+ * survives on disk. There is nothing to finalize, so re-download from scratch through the normal
+ * flow (which reuses the existing failed store row via retryEntry) instead of dead-ending on the same
+ * "no such file" on every retry. Reconstructs the zip descriptor from the entry's persisted metadata. */
+async function reDownloadFromMetadata(ctx: ResumeCtx): Promise {
+ const { modelId, metadata, deps } = ctx;
+ if (!metadata.imageModelDownloadUrl) {
+ // No URL to re-fetch from. Surface a clear, honest failure rather than a stale native error.
+ useDownloadStore.getState().setStatus(ctx.entry.downloadId, 'failed', {
+ message: 'Download could not be re-downloaded. Remove it and download again.',
+ });
+ return;
+ }
+ logger.log(`[ImageDownload] resumeImageDownload zip - staged bytes gone, re-downloading ${modelId}`);
+ await proceedWithDownload(imageDescriptorFromMetadata(modelId, metadata), deps);
+}
+
async function resumeZipDownload(ctx: ResumeCtx): Promise {
const { entry, modelId, metadata, deps } = ctx;
const imageModelsDir = modelManager.getImageModelsDirectory();
@@ -142,14 +161,20 @@ async function resumeZipDownload(ctx: ResumeCtx): Promise {
if (!(await RNFS.exists(imageModelsDir))) await RNFS.mkdir(imageModelsDir);
try {
await backgroundDownloadService.moveCompletedDownload(entry.downloadId, zipPath);
- } catch (error) {
+ } catch (error: any) {
const recoveredModelDirValid = await validateModelDir(modelDir, metadata.imageModelBackend);
const recoveredZipValid = await validateZipArtifact(zipPath, expectedZipBytes);
if (recoveredModelDirValid) {
await registerAndNotify(deps, { imageModel: await buildModel(modelDir), modelName: metadata.imageModelName });
return;
}
- if (!recoveredZipValid) throw error;
+ // Completed bytes are gone and nothing valid survives — re-download instead of dead-ending
+ // on the same "no such file" every retry (the iOS temp-purge symptom). Does not rethrow.
+ if (!recoveredZipValid) {
+ logger.warn(`[ImageDownload] resumeImageDownload zip - completed bytes unrecoverable (${error?.message || error}) — re-downloading ${modelId}`);
+ await reDownloadFromMetadata(ctx);
+ return;
+ }
}
if (!(await RNFS.exists(modelDir))) await RNFS.mkdir(modelDir);
await RNFS.writeFile(`${modelDir}/_zip_name`, entry.fileName, 'utf8').catch(() => {});
diff --git a/src/screens/ModelsScreen/useTextModels.ts b/src/screens/ModelsScreen/useTextModels.ts
index 57e21441c..a2c931079 100644
--- a/src/screens/ModelsScreen/useTextModels.ts
+++ b/src/screens/ModelsScreen/useTextModels.ts
@@ -4,7 +4,7 @@ import { useFocusEffect } from '@react-navigation/native';
import { showAlert, AlertState } from '../../components/CustomAlert';
import { RECOMMENDED_MODELS, TRENDING_FAMILIES, MODEL_ORGS } from '../../constants';
import { useAppStore } from '../../stores';
-import { modelBudgetFraction } from '../../services/memoryBudget';
+import { fileExceedsBudget } from '../../services/memoryBudget';
import { useDownloadStore } from '../../stores/downloadStore';
import { huggingFaceService, modelManager, hardwareService, activeModelService } from '../../services';
import { startModelDownload } from '../../services/startModelDownload';
@@ -86,7 +86,7 @@ function computeFilteredResults(
}
}
const filesWithSize = (model.files || []).filter(f => f.size > 0);
- if (filesWithSize.length > 0 && !filesWithSize.some(f => f.size / (1024 ** 3) < ramGB * modelBudgetFraction(ramGB))) return false;
+ if (filesWithSize.length > 0 && !filesWithSize.some(f => !fileExceedsBudget(f.size, ramGB))) return false;
return true;
});
return filtered.map(model => {
diff --git a/src/services/activeDownloadPersistence.ts b/src/services/activeDownloadPersistence.ts
new file mode 100644
index 000000000..d57810126
--- /dev/null
+++ b/src/services/activeDownloadPersistence.ts
@@ -0,0 +1,72 @@
+/**
+ * Durable persistence for IN-FLIGHT downloads (running/processing), so a cold app-kill can strand
+ * them as failed/retriable cards instead of letting them vanish.
+ *
+ * Why this exists (device 2026-07-15): downloadStore is not persisted, and hydrateDownloadStore()
+ * rebuilds only from native rows. On iOS a hard app-kill drops the URLSession task, so an in-flight
+ * download has no native row on relaunch AND no in-memory entry — strandInterruptedEntries (which read
+ * only the in-memory store) had nothing to carry forward, so the download disappeared entirely.
+ * (Android's WorkManager row SURVIVES a kill and reappears in the native snapshot, so nothing is ever
+ * stranded there — this persistence is platform-neutral and changes no Android behaviour.)
+ *
+ * Scope: only ACTIVE-but-NOT-QUEUED entries. Queued (pending) starts are already persisted+replayed by
+ * queuedDownloadPersistence/restoreQueuedDownloads; persisting them here too would double-handle them.
+ * Written on SET/STATUS change only (never on byte-progress ticks), the same low cadence as the queue.
+ */
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { useDownloadStore } from '../stores/downloadStore';
+import { isActiveStatus, isQueuedStatus, type DownloadEntry } from '../utils/downloadStatus';
+import type { ModelKey } from '../utils/modelKey';
+import logger from '../utils/logger';
+
+const ACTIVE_DOWNLOADS_KEY = '@offgrid/active_downloads';
+
+/** PURE: the in-flight subset worth persisting — active (running/processing) but NOT queued (the
+ * queue owns its own persistence) and not terminal (completed/failed/cancelled). Zero-IO. */
+function serializeActiveDownloads(downloads: Record): DownloadEntry[] {
+ return Object.values(downloads).filter((e) => isActiveStatus(e.status) && !isQueuedStatus(e.status));
+}
+
+/** Thin adapter: write the projection durably. Best-effort — never throws (a failed write must not
+ * wedge downloads), logged under [DL-SM] so a lost snapshot is diagnosable. */
+export async function saveActiveDownloads(entries: DownloadEntry[]): Promise {
+ try {
+ if (entries.length === 0) await AsyncStorage.removeItem(ACTIVE_DOWNLOADS_KEY);
+ else await AsyncStorage.setItem(ACTIVE_DOWNLOADS_KEY, JSON.stringify(entries));
+ } catch (e) {
+ logger.log(`[DL-SM] persist active downloads failed err=${e instanceof Error ? e.message : String(e)}`);
+ }
+}
+
+/** Thin adapter: read the persisted projection. Returns [] on absence or a corrupt payload. */
+export async function loadActiveDownloads(): Promise {
+ try {
+ const stored = await AsyncStorage.getItem(ACTIVE_DOWNLOADS_KEY);
+ if (!stored) return [];
+ const parsed = JSON.parse(stored);
+ return Array.isArray(parsed) ? (parsed as DownloadEntry[]) : [];
+ } catch (e) {
+ logger.log(`[DL-SM] load active downloads failed err=${e instanceof Error ? e.message : String(e)}`);
+ return [];
+ }
+}
+
+let subscribed = false;
+let lastSignature = '';
+
+/**
+ * Subscribe the download store and persist the in-flight set whenever its membership/status changes —
+ * NOT on byte-progress ticks (the signature is keys+status only, so a running download's progress
+ * updates don't churn AsyncStorage). Idempotent; call once at launch.
+ */
+export function initActiveDownloadPersistence(): void {
+ if (subscribed) return;
+ subscribed = true;
+ useDownloadStore.subscribe((state) => {
+ const active = serializeActiveDownloads(state.downloads);
+ const signature = active.map((e) => `${e.modelKey}:${e.status}`).sort().join('|');
+ if (signature === lastSignature) return;
+ lastSignature = signature;
+ saveActiveDownloads(active).catch(() => { /* saveActiveDownloads already logs; never throws */ });
+ });
+}
diff --git a/src/services/downloadHydration.ts b/src/services/downloadHydration.ts
index d39cea685..f7ec0f3b1 100644
--- a/src/services/downloadHydration.ts
+++ b/src/services/downloadHydration.ts
@@ -2,6 +2,8 @@ import { backgroundDownloadService } from './backgroundDownloadService';
import { useDownloadStore, DownloadEntry, DownloadStatus, ModelType, isActiveStatus } from '../stores/downloadStore';
import { makeModelKey, ModelKey } from '../utils/modelKey';
import { BackgroundDownloadStatus } from '../types';
+import { isMMProjFile } from './mmproj';
+import { loadActiveDownloads } from './activeDownloadPersistence';
import logger from '../utils/logger';
type NativeDownloadRow = {
@@ -22,9 +24,14 @@ type NativeDownloadRow = {
metadataJson?: string;
};
+/**
+ * Is this download-row filename a multimodal projector (mmproj) rather than a model weights file?
+ * Delegates to the single source of truth (src/services/mmproj.ts) so "is this a projector" is defined
+ * once — the previous local copy matched only 'mmproj' and missed 'projector'/'clip' names. Re-exported so
+ * modelManager/restore.ts's orphaned-sidecar filter shares the exact same rule (DRY).
+ */
export function isMmProjFileName(fileName: string): boolean {
- const lower = fileName.toLowerCase();
- return lower.includes('mmproj');
+ return isMMProjFile(fileName);
}
function mapNativeStatus(status: BackgroundDownloadStatus): DownloadStatus {
@@ -148,10 +155,18 @@ function toDownloadEntry(
*/
function strandInterruptedEntries(
hydratedKeys: Set,
+ persistedPrior: DownloadEntry[],
): DownloadEntry[] {
+ // Prior in-flight entries come from TWO sources, so an interrupted download is caught after a
+ // FOREGROUND resume (in-memory store still populated) AND a cold app-kill (in-memory gone, only the
+ // durably-persisted snapshot survives). In-memory wins on conflict (it's the more recent truth).
+ const priors = new Map();
+ for (const e of persistedPrior) priors.set(e.modelKey, e);
+ for (const e of Object.values(useDownloadStore.getState().downloads)) priors.set(e.modelKey, e);
+
const stranded: DownloadEntry[] = [];
- for (const prior of Object.values(useDownloadStore.getState().downloads)) {
- if (hydratedKeys.has(prior.modelKey)) continue; // still has a live native row
+ for (const prior of priors.values()) {
+ if (hydratedKeys.has(prior.modelKey)) continue; // still has a live native row (Android WorkManager survives → never stranded)
if (!isActiveStatus(prior.status)) continue; // already completed/failed/cancelled
logger.log(
`[DL-SM] ${prior.modelType}:${prior.modelId} hydrate: native row gone (app-kill) → failed/retriable`,
@@ -191,9 +206,11 @@ export async function hydrateDownloadStore(): Promise {
// Native rows are the source of truth for what is genuinely in flight; but a row that
// VANISHED (vs one that reports a new status) means an interrupted transfer whose task
// the OS discarded. Preserve the prior in-flight entry as failed/retriable so it never
- // silently disappears from the Download Manager.
+ // silently disappears from the Download Manager — including across a cold app-kill, where
+ // the prior entry survives only in the durably-persisted snapshot (loadActiveDownloads).
const hydratedKeys = new Set(entries.map(e => e.modelKey));
- entries.push(...strandInterruptedEntries(hydratedKeys));
+ const persistedPrior = await loadActiveDownloads();
+ entries.push(...strandInterruptedEntries(hydratedKeys, persistedPrior));
useDownloadStore.getState().hydrate(entries);
}
diff --git a/src/services/generationService.ts b/src/services/generationService.ts
index 159e9947f..06a89c09c 100644
--- a/src/services/generationService.ts
+++ b/src/services/generationService.ts
@@ -25,6 +25,10 @@ type StreamChunk = string | { content?: string; reasoningContent?: string };
export interface QueuedMessage {
id: string; conversationId: string; text: string;
attachments?: MediaAttachment[]; messageText: string;
+ /** The modality the user forced for THIS send (force/disabled/auto). Carried through the queue so a
+ * message the user explicitly forced to image mode is dispatched as image on drain — never re-decided
+ * at 'auto' by resolveTurnKind (#510: a queued force-image send generated as text). */
+ imageMode?: 'auto' | 'force' | 'disabled';
}
export interface GenerationState {
@@ -346,6 +350,9 @@ class GenerationService {
text: all.map(m => m.text).join('\n\n'),
attachments: all.flatMap(m => m.attachments || []),
messageText: all.map(m => m.messageText).join('\n\n'),
+ // If ANY coalesced send forced image mode, the combined dispatch must force image too — the
+ // user's explicit force must never be dropped by the merge (mirror of the single-message carry).
+ imageMode: all.some(m => m.imageMode === 'force') ? 'force' : all[0].imageMode,
};
this.queueProcessor(combined).catch(e => { logger.error('[GenerationService] Queue processor error:', e); });
}
diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts
index a1eec092f..ff2ecace2 100644
--- a/src/services/generationToolLoop.ts
+++ b/src/services/generationToolLoop.ts
@@ -15,8 +15,10 @@ import { selectRelevantTools } from './litertToolSelector';
import { isMcpEnabled } from './mcpContextBoost';
import { selectToolsByEmbedding } from './toolEmbeddingRouter';
import { providerRegistry } from './providers';
+import { getActiveEngineService, isRemoteTextModelActive } from './engines';
import type { GenerationOptions, CompletionResult } from './providers/types';
import logger from '../utils/logger';
+import { XML_TOOL_CALL_FUNCTION_MARKER, XML_TOOL_CALL_PARAMETER_MARKER } from '../utils/messageContent';
const MAX_TOOL_ITERATIONS = 3;
const MAX_TOTAL_TOOL_CALLS = 5;
// On-device: above this many tools, run a fast routing pass to pick the relevant ones
@@ -32,11 +34,13 @@ const MCP_TOOL_ROUTE_TOPK = 12;
const MAX_LITERT_TOOL_CALLS = 3;
type StreamChunk = string | StreamToken;
function parseXmlStyleToolCall(body: string, idSuffix: number): ToolCall | null {
- const funcMatch = body.match(//);
+ // Marker sources are shared with stripControlTokens (messageContent) so the extractor and the
+ // display stripper key on the SAME ``/`` grammar and cannot drift.
+ const funcMatch = body.match(new RegExp(XML_TOOL_CALL_FUNCTION_MARKER));
if (!funcMatch) return null;
const name = funcMatch[1];
const args: Record = {};
- const paramPattern = /([\s\S]*?)(?= m.id === activeModelId)?.engine === 'litert' && liteRTService.isModelLoaded();
+ return getActiveEngineService() === liteRTService;
}
-/** True when generation is served by a remote provider (no on-device native context). */
+/** True when generation is served by a remote provider (no on-device native context). Delegates to the
+ * engines.ts owner (isRemoteTextModelActive) — the single source for the activeServer+hasProvider+!localLoaded
+ * rule — plus the explicit caller override. */
function isUsingRemote(forceRemote?: boolean): boolean {
- if (forceRemote) return true;
- const activeServerId = useRemoteServerStore.getState().activeServerId;
- return !!activeServerId && providerRegistry.hasProvider(activeServerId) && !llmService.isModelLoaded();
+ return forceRemote || isRemoteTextModelActive();
}
/** On first iteration: last user message. On tool-result iterations: formatted tool results. */
@@ -670,8 +676,7 @@ async function callLLMWithRetry(
// We shallow-copy messages to avoid mutating the caller's array.
const exts = getToolExtensions();
const extCount = exts.reduce((n, e) => n + e.enabledToolCount(), 0);
- const activeServerId = useRemoteServerStore.getState().activeServerId;
- const useRemote = forceRemote || (!!activeServerId && providerRegistry.hasProvider(activeServerId) && !llmService.isModelLoaded());
+ const useRemote = isUsingRemote(forceRemote);
// LiteRT (OpenApiTool), remote providers, and llama with a Jinja tool template all do
// native tool calling — the text hint must be suppressed for them (see augmentSystemPromptForTools).
const nativeToolCalling = (isLiteRTActive() && !!conversationId) || useRemote || llmService.supportsToolCalling();
@@ -801,8 +806,7 @@ async function selectEffectiveSchemas(ctx: ToolLoopContext, builtInSchemas: any[
const all = [...builtInSchemas, ...extSchemas];
const litertActive = isLiteRTActive();
const llamaIosNative = !litertActive && Platform.OS === 'ios' && llmService.supportsToolCalling();
- const activeServerId = useRemoteServerStore.getState().activeServerId;
- const usingRemote = !!activeServerId && providerRegistry.hasProvider(activeServerId) && !llmService.isModelLoaded();
+ const usingRemote = isUsingRemote();
// MCP enabled on-device: route the many MCP/ext tools down with the embedding model
// BEFORE generating, so the big model only prefills the relevant handful instead of
diff --git a/src/services/huggingface.ts b/src/services/huggingface.ts
index 1b3f9abf5..62d75f943 100644
--- a/src/services/huggingface.ts
+++ b/src/services/huggingface.ts
@@ -1,7 +1,7 @@
import { HFModelSearchResult, ModelInfo, ModelFile, ModelCredibility } from '../types';
import { HF_API, QUANTIZATION_INFO, LMSTUDIO_AUTHORS, OFFICIAL_MODEL_AUTHORS, VERIFIED_QUANTIZERS } from '../constants';
import { looksLikeVisionModel } from '../utils/visionModel';
-import { isMMProjFile } from './mmproj';
+import { isMMProjFile, pickMmProjForDownload } from './mmproj';
class HuggingFaceService {
private baseUrl = HF_API.baseUrl;
@@ -141,35 +141,28 @@ class HuggingFaceService {
return isMMProjFile(fileName);
}
+ // Routes through the single projector-rule owner (src/services/mmproj.ts). Quant is NOT a matching
+ // signal (one projector serves every quant of its model); a projector whose filename names a DIFFERENT
+ // model+variant is the wrong architecture and is REFUSED, so the model downloads with its correct
+ // projector or text-only rather than being mispaired (#510). See pickMmProjForDownload for the rule.
private findMatchingMMProj(
modelFileName: string,
mmProjFiles: Array<{ path: string; size?: number; lfs?: { size: number } }>,
modelId: string
): { name: string; size: number; downloadUrl: string } | undefined {
- if (mmProjFiles.length === 0) {
- return undefined;
- }
-
- const toResult = (f: { path: string; size?: number; lfs?: { size: number } }) => ({
- name: f.path,
- size: f.lfs?.size || f.size || 0,
- downloadUrl: this.getDownloadUrl(modelId, f.path),
- });
-
- // Exact symmetric match: model quant === mmproj quant
- const modelQuant = this.extractQuantization(modelFileName);
- if (modelQuant !== 'Unknown') {
- const exactMatch = mmProjFiles.find(f => this.extractQuantization(f.path) === modelQuant);
- if (exactMatch) return toResult(exactMatch);
- }
-
- // Fallback: prefer F16/FP16, exclude BF16 (can be incompatible with some runtimes)
- const f16 = mmProjFiles.find(f => {
- const lower = f.path.toLowerCase();
- return (lower.includes('f16') || lower.includes('fp16')) && !lower.includes('bf16');
- });
-
- return toResult(f16 ?? mmProjFiles[0]);
+ const chosen = pickMmProjForDownload(
+ modelFileName,
+ mmProjFiles.map(f => f.path)
+ );
+ if (!chosen) return undefined;
+
+ const file = mmProjFiles.find(f => f.path === chosen);
+ if (!file) return undefined;
+ return {
+ name: file.path,
+ size: file.lfs?.size || file.size || 0,
+ downloadUrl: this.getDownloadUrl(modelId, file.path),
+ };
}
private detectModelType(name: string, tags: string[]): string {
diff --git a/src/services/llm.ts b/src/services/llm.ts
index f6c932324..35e95103d 100644
--- a/src/services/llm.ts
+++ b/src/services/llm.ts
@@ -13,7 +13,8 @@ import {
validateModelFile, checkMemoryForModel, safeCompletion, resolveSafeContext,
describeGpuFallback, isTruncatedResult,
} from './llmHelpers';
-import { awaitMemoryReclaim } from './memoryBudget';
+import { awaitMemoryReclaim, effectiveAvailableMB } from './memoryBudget';
+import { modelResidencyManager } from './modelResidency';
import { hardwareService } from './hardware';
import { formatLlamaMessages, buildOAIMessages } from './llmMessages';
import { generateWithToolsImpl } from './llmToolGeneration';
@@ -79,7 +80,19 @@ class LLMService {
// to f16 (see buildModelParams), so keying off settings.cacheType alone would let the
// guard use the cheaper quantized estimate and approve a context that then OOMs.
const quantizedCache = !params.usesF16Cache;
- const getMem = () => hardwareService.getAppMemoryUsage();
+ // Feed the pre-load gate the SAME reclaim-aware available RAM the residency gate uses (the single owner,
+ // effectiveAvailableMB) so the two can never disagree. On Android the raw os_proc snapshot under-counts a
+ // foreground load (the LMK hands background apps' physical pages to us), so a raw gate REFUSED a model
+ // residency ADMITTED — 12GB Android Aggressive, device qwythos. iOS returns raw unchanged (no reclaim —
+ // jetsam kills us), so iOS is untouched. Policy comes from the residency manager (the authoritative owner).
+ const getMem = async (): Promise<{ available: number; total: number; used: number }> => {
+ const raw = await hardwareService.getAppMemoryUsage();
+ const availableMB = effectiveAvailableMB(raw.available / (1024 * 1024), raw.total / (1024 * 1024), {
+ platform: Platform.OS,
+ policy: modelResidencyManager.getLoadPolicy(),
+ });
+ return { ...raw, available: availableMB * 1024 * 1024 };
+ };
let memCheck = await checkMemoryForModel({ modelFileSize: fileSize, contextLength: params.ctxLen, getAvailableMemory: getMem, quantizedCache });
if (!memCheck.safe) {
// Don't just warn and load into a near-certain native allocator crash (the iOS
diff --git a/src/services/llmSafetyChecks.ts b/src/services/llmSafetyChecks.ts
index 9df3181d7..dfeba2a8c 100644
--- a/src/services/llmSafetyChecks.ts
+++ b/src/services/llmSafetyChecks.ts
@@ -1,6 +1,7 @@
import { LlamaContext } from 'llama.rn';
import RNFS from 'react-native-fs';
import logger from '../utils/logger';
+import { OverridableMemoryError } from '../utils/modelLoadErrors';
/**
* GGUF magic number — first 4 bytes of every valid GGUF file.
@@ -108,6 +109,9 @@ export async function checkMemoryForModel(
// Require at least 200MB headroom after model load for OS and app
const MIN_HEADROOM_MB = 200;
const safe = availableMB > estimatedMB + MIN_HEADROOM_MB;
+ // [MEM-SM] the pre-load fit decision — kept (surfaces the exact "it needs ~X but only Y" call
+ // on-device AND in tests via DEBUG_LOGS=1). This is the gate the qwythos refusal came from.
+ logger.log(`[MEM-SM] checkMemoryForModel modelMB=${Math.round(modelMB)} kvMB=${Math.round(kvCacheMB)} estMB=${Math.round(estimatedMB)} availMB=${Math.round(availableMB)} ctx=${contextLength} safe=${safe}`);
if (!safe) {
return {
safe: false,
@@ -157,8 +161,16 @@ export async function resolveSafeContext(args: {
const minCtx = fallbacks.length ? fallbacks[fallbacks.length - 1] : requestedCtx;
const finalCheck = await checkMemoryForModel({ modelFileSize: fileSize, contextLength: minCtx, getAvailableMemory: getMem, quantizedCache });
const modelMB = (fileSize * 1.2) / (1024 * 1024);
+ // [MEM-SM] the weights-alone refusal decision — kept. weightsExceedAvail && !override is the
+ // dead-end that used to throw a plain Error; it now throws OverridableMemoryError (Load Anyway).
+ logger.log(`[MEM-SM] resolveSafeContext gate modelMB=${Math.round(modelMB)} availMB=${Math.round(finalCheck.availableMB)} override=${override} weightsExceedAvail=${finalCheck.availableMB > 0 && modelMB > finalCheck.availableMB}`);
if (finalCheck.availableMB > 0 && modelMB > finalCheck.availableMB && !override) {
- throw new Error(`Not enough memory to load this model: it needs ~${Math.round(modelMB)}MB but only ${Math.round(finalCheck.availableMB)}MB is available. Close other apps or choose a smaller model.`);
+ // OVERRIDABLE, always: a budget refusal in ANY mode must offer "Load Anyway" — never a
+ // dead-end. This is the single behavior the image path already had (makeRoomFor →
+ // OverridableMemoryError); the text pre-load gate used to throw a plain Error here, which
+ // surfaced as an OK-only alert with no override (the 12GB-Aggressive-refused-with-no-Load-
+ // Anyway bug). OverridableMemoryError is pure, so this stays layering-clean.
+ throw new OverridableMemoryError(`Not enough memory to load this model: it needs ~${Math.round(modelMB)}MB but only ${Math.round(finalCheck.availableMB)}MB is available. Close other apps or choose a smaller model.`);
}
if (override && finalCheck.availableMB > 0 && modelMB > finalCheck.availableMB) {
// User forced the load ("Load Anyway" / continue). Skip the hard block and let the
diff --git a/src/services/llmToolGeneration.ts b/src/services/llmToolGeneration.ts
index 5440bf7b3..1f85da96f 100644
--- a/src/services/llmToolGeneration.ts
+++ b/src/services/llmToolGeneration.ts
@@ -91,11 +91,32 @@ export class ToolCallTokenFilter {
}
}
+/**
+ * Parse a tool call's `arguments` JSON string into an object, tolerating the smart/curly quotes some
+ * local models emit as string delimiters. Plain JSON.parse rejects `{"expression":"7 * 7"}` (curly
+ * double quotes) → args became `{}` → the tool got an EMPTY call → schema validation failed → the
+ * model retried the same call in a loop until it happened to emit straight quotes (device 2026-07-15).
+ * We try strict JSON first (unchanged for the common case), then retry once with curly double quotes
+ * normalized to straight. Zero-IO; exercised through generateWithToolsImpl in tests.
+ */
+function parseToolArguments(raw: string): Record {
+ const tryParse = (s: string): Record | undefined => {
+ try {
+ const v = JSON.parse(s || '{}');
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : undefined;
+ } catch {
+ return undefined;
+ }
+ };
+ // Normalize only the JSON string DELIMITERS (curly → straight double quotes); leave content alone.
+ return tryParse(raw) ?? tryParse(raw.replace(/[“”]/g, '"')) ?? {};
+}
+
function parseToolCall(tc: any): ToolCall {
const fn = tc.function || {};
let args = fn.arguments || {};
if (typeof args === 'string') {
- try { args = JSON.parse(args || '{}'); } catch { args = {}; }
+ args = parseToolArguments(args);
}
return { id: tc.id, name: fn.name || '', arguments: args };
}
diff --git a/src/services/mmproj.ts b/src/services/mmproj.ts
index 99b63602f..11064bb22 100644
--- a/src/services/mmproj.ts
+++ b/src/services/mmproj.ts
@@ -31,8 +31,8 @@ function modelIdentityStem(fileName: string): string {
.toLowerCase()
.replace(/\.gguf$/, '')
.replace(/[-_.]?mmproj/g, '')
- // quant tokens: Q4_K_M, Q8_0, Q5_K_S, Q6_K, IQ4_XS, F16, F32, BF16, …
- .replace(/[-_.]?(iq\d+[a-z0-9_]*|q\d+[a-z0-9_]*|f16|f32|bf16)/gi, '')
+ // quant / precision tokens: Q4_K_M, Q8_0, Q5_K_S, Q6_K, IQ4_XS, F16, FP16, F32, BF16, …
+ .replace(/[-_.]?(iq\d+[a-z0-9_]*|q\d+[a-z0-9_]*|fp16|f16|f32|bf16)/gi, '')
.replace(/[^a-z0-9]+/g, '');
}
@@ -46,8 +46,57 @@ export function mmProjBelongsToModel(modelFileName: string, mmProjFileName: stri
* NEVER falls back to "closest" or "the only one" — a non-belonging projector is the wrong architecture and
* would crash the native completion with "Multimodal support not enabled"; undefined lets the model load
* clean as text-only (and surfaces the "needs repair" path) instead.
+ *
+ * This is the ON-DISK matcher: by the time files are on disk they've been renamed to the model's own stem
+ * (see modelManager download.mmProjLocalName), so a belonging projector shares the model's exact stem.
*/
export function pickMmProjForModel(modelFileName: string, candidateNames: string[]): string | undefined {
const modelStem = modelIdentityStem(modelFileName);
return candidateNames.find(name => modelIdentityStem(name) === modelStem);
}
+
+/**
+ * Pick the projector to PAIR with a model from a RAW HuggingFace repo file listing (the download-time
+ * matcher). Repos name projectors two ways and we must honour both without ever mispairing:
+ *
+ * (A) GENERIC projector — the projector filename carries NO model-name token (e.g. a bare
+ * `mmproj-F16.gguf` in ggml-org/gemma-3-*-GGUF, whose identity stem is empty). It serves the repo's
+ * single model, so pair it. This is the case the on-disk strict matcher would wrongly reject (empty
+ * stem ≠ model stem), which is why the download listing needs its own owner rather than reusing
+ * pickMmProjForModel. When a repo ships several generic projectors at different precisions, prefer
+ * F16 (excluding BF16, which some runtimes reject), else take the first.
+ * (B) MODEL-NAMED projector — the filename names a model base+variant (e.g. `gemma-4-E4B-it-mmproj-F16`).
+ * It belongs ONLY to that model. For a DIFFERENT model (an E2B), it is the wrong architecture and
+ * must be REFUSED even at the same quant — the E2B downloads with its correct projector or text-only,
+ * never mispaired.
+ *
+ * Preference: an exact model-name+variant match wins when present (a repo that ships several named
+ * projectors). Otherwise a generic (no-model-token) projector pairs. A candidate that names a DIFFERENT
+ * model+variant is never paired; if every candidate does, none pairs (undefined).
+ */
+export function pickMmProjForDownload(
+ modelFileName: string,
+ candidateNames: string[]
+): string | undefined {
+ if (candidateNames.length === 0) return undefined;
+ const modelStem = modelIdentityStem(modelFileName);
+
+ // (B, positive) Exact name+variant match — always correct, always preferred.
+ const exact = candidateNames.find(name => modelIdentityStem(name) === modelStem);
+ if (exact) return exact;
+
+ // (A) Projectors with NO model-name token (empty identity stem) are generic to the repo's single model.
+ // Prefer F16 (not BF16), else the first. A NAMED-but-different projector is excluded here, so it can
+ // never be chosen as the generic fallback (case B refusal).
+ const generic = candidateNames.filter(name => modelIdentityStem(name) === '');
+ if (generic.length > 0) {
+ const f16 = generic.find(name => {
+ const lower = name.toLowerCase();
+ return (lower.includes('f16') || lower.includes('fp16')) && !lower.includes('bf16');
+ });
+ return f16 ?? generic[0];
+ }
+
+ // (B, negative) Every candidate names a DIFFERENT model+variant → wrong architecture, refuse.
+ return undefined;
+}
diff --git a/src/stores/remoteModelCapabilities.ts b/src/stores/remoteModelCapabilities.ts
index ae3508c29..d8fddb922 100644
--- a/src/stores/remoteModelCapabilities.ts
+++ b/src/stores/remoteModelCapabilities.ts
@@ -7,7 +7,7 @@
*/
import logger from '../utils/logger';
-import { templateEmitsReasoning } from '../utils/messageContent';
+import { templateEmitsReasoning, REASONING_DELIMITERS } from '../utils/messageContent';
export interface RemoteModelInfo {
contextLength: number;
@@ -200,7 +200,15 @@ export async function fetchLmStudioModelInfo(
* - Separate message.reasoning_content field
*/
function deltaHasThinking(delta: Record): boolean {
- if (typeof delta.content === 'string' && delta.content.includes('')) return true;
+ // Inline reasoning emitted in `content` (no reasoning_content field) is detected through the
+ // SHARED reasoning grammar (REASONING_DELIMITERS) — not a hardcoded `` — so this agrees
+ // with the rest of the reasoning parsers and catches Gemma/Qwen channel reasoning too.
+ if (
+ typeof delta.content === 'string' &&
+ REASONING_DELIMITERS.some((d) => (delta.content as string).includes(d.open))
+ ) {
+ return true;
+ }
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length > 0) return true;
if (typeof delta.reasoning === 'string' && delta.reasoning.length > 0) return true;
if (typeof delta.thinking === 'string' && delta.thinking.length > 0) return true;
diff --git a/src/utils/messageContent.ts b/src/utils/messageContent.ts
index 98e4349df..2bcaf7fe7 100644
--- a/src/utils/messageContent.ts
+++ b/src/utils/messageContent.ts
@@ -22,6 +22,18 @@ export interface ParsedContent {
export const TOOL_CALL_OPENERS: string[] = ['<|tool_call>', ''];
export const TOOL_CALL_CLOSERS: string[] = ['', ''];
+/**
+ * THE single source of truth for the XML-style tool-call markup grammar
+ * (`……`) some models emit. Both the tool-loop
+ * EXTRACTOR (parseXmlStyleToolCall in generationToolLoop) and the display stripper (below)
+ * derive their patterns from THESE sources so a form the extractor accepts cannot be one the
+ * stripper misses — the DR7 promise applied to this second grammar. `\w+` after the `=` is the
+ * tool/param name; the block closes with ``.
+ */
+export const XML_TOOL_CALL_FUNCTION_MARKER = String.raw``;
+export const XML_TOOL_CALL_PARAMETER_MARKER = String.raw``;
+const XML_TOOL_CALL_FUNCTION_CLOSER = '';
+
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
const CLOSERS_ALT = TOOL_CALL_CLOSERS.map(escapeRegExp).join('|');
// One closed-block pattern per opener, built from the grammar so parser and stripper cannot drift.
@@ -33,6 +45,14 @@ const TOOL_CALL_UNCLOSED_PATTERNS: RegExp[] = TOOL_CALL_OPENERS.map(
(open) => new RegExp(String.raw`${escapeRegExp(open)}[\s\S]*$`),
);
+// XML-style tool-call block (`…`) and its unclosed-at-EOS tail, built from
+// the shared XML_TOOL_CALL_* markers so the stripper and the extractor cannot drift on this form.
+const XML_TOOL_CALL_BLOCK_PATTERN = new RegExp(
+ String.raw`${XML_TOOL_CALL_FUNCTION_MARKER}[\s\S]*?${escapeRegExp(XML_TOOL_CALL_FUNCTION_CLOSER)}\s*`,
+ 'gi',
+);
+const XML_TOOL_CALL_UNCLOSED_PATTERN = new RegExp(String.raw`${XML_TOOL_CALL_FUNCTION_MARKER}[\s\S]*$`, 'i');
+
/**
* Length of the longest suffix of `text` that is a PREFIX of `tag` — i.e. how much of a possibly-
* incomplete tag is dangling at the end of a stream chunk, so the incremental parsers can hold it
@@ -59,6 +79,8 @@ const CONTROL_TOKEN_PATTERNS: RegExp[] = [
// Gemma-native tool-call blocks (all openers × all closers), from the shared grammar above.
// The streaming filter suppresses these live; this catches any that reach stored content.
...TOOL_CALL_BLOCK_PATTERNS,
+ // XML-style `…` tool-call blocks (the extractor's second grammar).
+ XML_TOOL_CALL_BLOCK_PATTERN,
// Gemma 4 string-delimiter token that may appear outside a tool block
/<\|">/g,
];
@@ -162,6 +184,8 @@ export function stripControlTokens(content: string): string {
// Unclosed Gemma-native tool-call opener at end (EOS mid-call) — the closed forms above are
// handled by CONTROL_TOKEN_PATTERNS; this catches the truncated tail in stored content.
result = TOOL_CALL_UNCLOSED_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, ''), result);
+ // Unclosed XML-style `` opener at end (EOS mid-call).
+ result = result.replace(XML_TOOL_CALL_UNCLOSED_PATTERN, '');
// ── Thinking blocks ─────────────────────────────────────────────────────
// Complete ... blocks (Qwen 3.5, DeepSeek, etc.)
@@ -187,7 +211,7 @@ export function stripStreamingControlTokens(content: string): string {
* Strip markdown formatting for TTS speech. Preserves the readable text
* but removes syntax that Kokoro would read aloud as literal characters.
*/
-export function stripMarkdownForSpeech(content: string): string {
+function stripMarkdownForSpeech(content: string): string {
let result = content;
// Headers: ### Title → Title
result = result.replace(/^#{1,6}\s+/gm, '');