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, '');