From 6477754d071961cff7d3bafee3a48d1f25fa6897 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 12:00:46 +0530 Subject: [PATCH 01/66] chore(release): announce beta + prod releases in Slack from the release scripts uat.sh (beta) and release.sh (production/local) now post the release notes to Slack via scripts/notify-slack-release.mjs after the GitHub release is cut, before the notes tempfile is removed. Fail-soft (|| true) so a chat post can never fail a shipped build. The webhook is read from the gitignored .env.keygen (the local secret store that already holds the RevenueCat/Resend/Keygen/Cloudflare tokens), mirroring the SLACK_WEBHOOK_URL repo secret the CI release workflows use. Extracted with a scoped sed for that one key, not a full source, so the other secrets in .env.keygen stay out of the script env. notify-slack-release.mjs no-ops when the webhook is unset, so this is a no-op on machines without it. --- scripts/release.sh | 8 ++++++++ scripts/uat.sh | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/scripts/release.sh b/scripts/release.sh index dc8dd3fe9..be1f6267a 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" \ + 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}" From 57913c54967f922da13924582d8ee588a248de6b Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 12:13:10 +0530 Subject: [PATCH 02/66] chore(release): tag production Slack announcements as `stable` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.sh omitted CHANNEL_LABEL, so prod announcements rendered with no channel tag while uat.sh betas showed `beta`. Pass CHANNEL_LABEL=stable for parity — the notify script's documented values are beta | stable. --- scripts/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index be1f6267a..c3ffda041 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -130,7 +130,7 @@ gh release create "v${NEW_VERSION}" \ # 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" \ + 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 From 7c369aec3f52ab26f654eb7d61af7f1d3fc728b5 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 14:10:16 +0530 Subject: [PATCH 03/66] fix(downloads): stage completed iOS downloads in a durable dir, not NSTemporaryDirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the SDXL (Core ML) image-model download failing on iOS with "...coreml_apple_...xl-base-ios couldn't be opened because there is no such file": a completed single-file download was staged in NSTemporaryDirectory(), which iOS reaps across app relaunches and under memory pressure. The staged zip's path is persisted (localUri) and finalize (moveCompletedDownload -> unzip) can now run AFTER a relaunch (the queued-survival rework), by which point the OS has already deleted the temp file -> unzip opens a zip that no longer exists. A multi-GB image zip is the worst case: it commonly spans a backgrounding/ relaunch before its unzip finishes. Since CoreML is iOS-only, the shared download-infra change surfaced it only on iOS. Stage completed downloads (both the URLSession delegate hand-off copy and the handleSingleFileCompletion move) in a DURABLE Documents/.download-staging dir that survives relaunch + memory pressure, excluded from iCloud backup (dir flag is not inherited, so the staged file is excluded per-file too). Native change — device-verified separately (jest fakes the native module). --- ios/DownloadManagerModule.swift | 35 +++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) 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 { From 4fe2e8125f307145f8ce8f93ef7bad3ceb6ae597 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 14:20:18 +0530 Subject: [PATCH 04/66] fix(downloads): re-download when completed image bytes are unrecoverable on retry When an image zip download completed but its staged bytes are gone at finalize (iOS temp-purge on pre-durable-staging builds, or the user cleared storage), resumeZipDownload rethrew moveCompletedDownload's "no such file" and the row stuck at 'failed'. Retry re-ran the same finalize on the same dead download, so tapping Retry did nothing (retryImageDownload's tryResumeImageFinalization handles it and never falls through to the fresh-download path). Now, when neither a valid zip nor an extracted dir survives, resumeZipDownload re-downloads from scratch via proceedWithDownload (descriptor reconstructed from the entry's persisted metadata) instead of dead-ending. The user recovers by re-downloading rather than being stuck on a permanently-failed card. Red-flow test drives the real retry-finalize path over a faked native boundary where moveCompletedDownload rejects (staged source purged) and no artifact survives; asserts a fresh native download is issued + the row goes active again. Verified red (reverted fallback -> stuck 'failed', no new row) then green. --- ...geStagingPurgedRedownloads.redflow.test.ts | 94 +++++++++++++++++++ .../ModelsScreen/imageDownloadResume.ts | 43 ++++++++- 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 __tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts diff --git a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts new file mode 100644 index 000000000..f3c43d35e --- /dev/null +++ b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts @@ -0,0 +1,94 @@ +/** + * RED-FLOW (integration) — iOS image-model download stuck "failed" after the staged bytes are purged. + * + * DEVICE GROUND TRUTH: on a 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 test guards the 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 on the same error + * every tap. Before the fix, resumeZipDownload rethrew and the row stuck at 'failed' with no fresh + * download issued. Integration boundary: only the native download module + filesystem are faked; + * the real resume/finalize/proceedWithDownload/store all run. + * + * Falsifier: revert the reDownloadFromMetadata fallback in imageDownloadResume → the entry stays + * 'failed' and no new native download row appears → RED. + */ +import { installNativeBoundary } from '../../harness/nativeBoundary'; + +describe('iOS image staging purged — retry re-downloads instead of dead-ending (red-flow)', () => { + it('re-issues a fresh download when the completed bytes are gone at finalize', async () => { + const boundary = installNativeBoundary({ download: true, fs: 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 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, marked 'failed'. + // metadata carries a real download URL (the zip download type) so a re-download is possible. + 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: '4-bit quantized, 768x768, ANE-optimized', + 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 device fact this reproduces): the completed file was staged in NSTemporaryDirectory + // and iOS purged it, so the native move of the staged source now fails "no such file". Nothing + // valid survives on disk (no zip at zipPath, no extracted model dir) — this is the unrecoverable case. + boundary.download!.module.moveCompletedDownload.mockRejectedValue( + new Error(`The file "download_dl-sdxl_${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-finalize path the Download Manager runs for an all-bytes-present image download. + await resumeImageDownload(entry, deps); + + // The user's 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:${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/src/screens/ModelsScreen/imageDownloadResume.ts b/src/screens/ModelsScreen/imageDownloadResume.ts index 7203a8f99..45a5705ca 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 { ImageModelDescriptor } from './types'; import { validateImageModelDir, ensureImageExtractionComplete } from '../../utils/imageModelIntegrity'; import { makeImageModelKey } from '../../utils/modelKey'; import logger from '../../utils/logger'; @@ -78,6 +79,36 @@ 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; + const downloadUrl = metadata.imageModelDownloadUrl; + if (!downloadUrl) { + // 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 expired and could not be re-downloaded — remove and download again.', + }); + return; + } + const descriptor: ImageModelDescriptor = { + id: modelId, + name: metadata.imageModelName, + description: metadata.imageModelDescription ?? '', + downloadUrl, + size: metadata.imageModelSize ?? 0, + style: metadata.imageModelStyle ?? '', + backend: metadata.imageModelBackend ?? 'coreml', + attentionVariant: metadata.imageModelAttentionVariant, + repo: metadata.imageModelRepo, + }; + logger.log(`[ImageDownload] resumeImageDownload zip - staged bytes gone, re-downloading ${modelId}`); + await proceedWithDownload(descriptor, deps); +} + async function resumeZipDownload(ctx: ResumeCtx): Promise { const { entry, modelId, metadata, deps } = ctx; const imageModelsDir = modelManager.getImageModelsDirectory(); @@ -142,14 +173,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(() => {}); From 7fb6b069245b1f55c40a2fbedf37adb02e093379 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 14:34:01 +0530 Subject: [PATCH 05/66] refactor(downloads): DRY the image re-download descriptor + run the guard on both platforms Addresses hygiene/tests self-audit on the retry-recovery fix: - DRY: extract imageDescriptorFromMetadata into a pure zero-IO module (imageDescriptor.ts), used by BOTH the iOS retry path (retryHandlers) and resume's re-download fallback, instead of two hand-built descriptors that could drift. - Copy: drop the em dash from the user-facing re-download failure message (brand guide). - Tests: resumeImageDownload is shared (iOS retry + Android app-open resume), so the red-flow now runs the purged-artifact recovery on BOTH ios and android with a device-faithful fixture each (CoreML zip / MNN zip). Red-verified on both (reverted fallback -> stuck 'failed', no new row). --- ...geStagingPurgedRedownloads.redflow.test.ts | 175 ++++++++++-------- .../DownloadManagerScreen/retryHandlers.ts | 16 +- src/screens/ModelsScreen/imageDescriptor.ts | 23 +++ .../ModelsScreen/imageDownloadResume.ts | 24 +-- 4 files changed, 133 insertions(+), 105 deletions(-) create mode 100644 src/screens/ModelsScreen/imageDescriptor.ts diff --git a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts index f3c43d35e..5a6e1c71b 100644 --- a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts +++ b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts @@ -1,94 +1,121 @@ /** - * RED-FLOW (integration) — iOS image-model download stuck "failed" after the staged bytes are purged. + * RED-FLOW (integration) — image-model download stuck "failed" after the completed bytes are lost. * - * DEVICE GROUND TRUTH: on a production build, SDXL (Core ML) completed (2.8/2.8 GB) then showed + * 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 test guards the JS half: when the completed bytes are unrecoverable (native + * 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 on the same error - * every tap. Before the fix, resumeZipDownload rethrew and the row stuck at 'failed' with no fresh - * download issued. Integration boundary: only the native download module + filesystem are faked; - * the real resume/finalize/proceedWithDownload/store all run. + * 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. + * 'failed' and no new native download row appears → RED (verified on both platforms). */ import { installNativeBoundary } from '../../harness/nativeBoundary'; -describe('iOS image staging purged — retry re-downloads instead of dead-ending (red-flow)', () => { - it('re-issues a fresh download when the completed bytes are gone at finalize', async () => { - const boundary = installNativeBoundary({ download: true, fs: 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 modelId = 'coreml_apple_coreml-stable-diffusion-xl-base-ios'; - const fileName = `${modelId}.zip`; - const modelKey = makeImageModelKey(modelId); - const total = 2.8 * 1024 * 1024 * 1024; +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; - // A completed CoreML 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-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: '4-bit quantized, 768x768, ANE-optimized', - 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', - }), +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 }); }); - // BOUNDARY (the device fact this reproduces): the completed file was staged in NSTemporaryDirectory - // and iOS purged it, so the native move of the staged source now fails "no such file". Nothing - // valid survives on disk (no zip at zipPath, no extracted model dir) — this is the unrecoverable case. - boundary.download!.module.moveCompletedDownload.mockRejectedValue( - new Error(`The file "download_dl-sdxl_${fileName}" couldn't be opened because there is no such file.`), - ); + 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; - const entry = useDownloadStore.getState().downloads[modelKey]; - const appState = useAppStore.getState(); - const deps = { - addDownloadedImageModel: appState.addDownloadedImageModel, - activeImageModelId: appState.activeImageModelId, - setActiveImageModelId: appState.setActiveImageModelId, - setAlertState: () => {}, - triedImageGen: false, - }; + // 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, + }), + }); - // 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'); + // 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.`), + ); - // ACT: the retry-finalize path the Download Manager runs for an all-bytes-present image download. - await resumeImageDownload(entry, deps); + const entry = useDownloadStore.getState().downloads[modelKey]; + const appState = useAppStore.getState(); + const deps = { + addDownloadedImageModel: appState.addDownloadedImageModel, + activeImageModelId: appState.activeImageModelId, + setActiveImageModelId: appState.setActiveImageModelId, + setAlertState: () => {}, + triedImageGen: false, + }; - // The user's 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:${modelId}` || r.fileName === fileName)).toBe(true); - expect(isActiveStatus(useDownloadStore.getState().downloads[modelKey].status)).toBe(true); - expect(useDownloadStore.getState().downloads[modelKey].status).not.toBe('failed'); - }); -}); + // 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/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/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 45a5705ca..4792a56b5 100644 --- a/src/screens/ModelsScreen/imageDownloadResume.ts +++ b/src/screens/ModelsScreen/imageDownloadResume.ts @@ -5,7 +5,7 @@ import { resolveCoreMLModelDir } from '../../utils/coreMLModelUtils'; import { ONNXImageModel } from '../../types'; import { useDownloadStore, DownloadEntry } from '../../stores/downloadStore'; import { ImageDownloadDeps, registerAndNotify, proceedWithDownload } from './imageDownloadActions'; -import { ImageModelDescriptor } from './types'; +import { imageDescriptorFromMetadata } from './imageDescriptor'; import { validateImageModelDir, ensureImageExtractionComplete } from '../../utils/imageModelIntegrity'; import { makeImageModelKey } from '../../utils/modelKey'; import logger from '../../utils/logger'; @@ -86,27 +86,15 @@ async function cleanupInvalidArtifact(path: string): Promise { * "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; - const downloadUrl = metadata.imageModelDownloadUrl; - if (!downloadUrl) { - // No URL to re-fetch from — surface a clear, honest failure rather than a stale native error. + 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 expired and could not be re-downloaded — remove and download again.', + message: 'Download could not be re-downloaded. Remove it and download again.', }); return; } - const descriptor: ImageModelDescriptor = { - id: modelId, - name: metadata.imageModelName, - description: metadata.imageModelDescription ?? '', - downloadUrl, - size: metadata.imageModelSize ?? 0, - style: metadata.imageModelStyle ?? '', - backend: metadata.imageModelBackend ?? 'coreml', - attentionVariant: metadata.imageModelAttentionVariant, - repo: metadata.imageModelRepo, - }; logger.log(`[ImageDownload] resumeImageDownload zip - staged bytes gone, re-downloading ${modelId}`); - await proceedWithDownload(descriptor, deps); + await proceedWithDownload(imageDescriptorFromMetadata(modelId, metadata), deps); } async function resumeZipDownload(ctx: ResumeCtx): Promise { @@ -182,6 +170,8 @@ async function resumeZipDownload(ctx: ResumeCtx): Promise { } // 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. + // 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); From 07e66a46fde2ab034d64138ac7e99b2c38d634fc Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 14:46:49 +0530 Subject: [PATCH 06/66] =?UTF-8?q?test(downloads):=20rendered=20red-flow=20?= =?UTF-8?q?=E2=80=94=20tap=20Retry=20on=20the=20failed=20image=20card,=20a?= =?UTF-8?q?ssert=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts the real DownloadManagerScreen over the native+fs fakes, taps the actual failed-retry-button on the failed SDXL card, and asserts the card recovers (row no longer 'failed', Retry gone, a fresh native download in flight). Closes the /tests gap: the sibling .redflow.test.ts drives the service directly; this drives the real UI gesture through the full retry wiring. Pins Platform.OS=ios (the device bug) so imageProvider.retry takes the iOS re-download path, not Android's native-resume branch. Red-verified (reverted the reDownloadFromMetadata fallback -> card stuck 'failed'). registerCoreDownloadProviders() before mount, else the service silently refuses retry (no owning provider). --- ...urgedRedownloads.rendered.redflow.test.tsx | 84 +++++++++++++++++++ .../ModelsScreen/imageDownloadResume.ts | 2 - 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 __tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx 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/src/screens/ModelsScreen/imageDownloadResume.ts b/src/screens/ModelsScreen/imageDownloadResume.ts index 4792a56b5..183a6f33a 100644 --- a/src/screens/ModelsScreen/imageDownloadResume.ts +++ b/src/screens/ModelsScreen/imageDownloadResume.ts @@ -170,8 +170,6 @@ async function resumeZipDownload(ctx: ResumeCtx): Promise { } // 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. - // 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); From 6146638e5069e0e39cb9ac02d73cb52ff1bf0d3c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 18:59:54 +0530 Subject: [PATCH 07/66] fix(memory): text load memory-refusal is overridable (Load Anyway), any mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-load gate (llmSafetyChecks.resolveSafeContext) threw a plain Error when weights exceed available RAM, so loadModelWithOverride fell to the dead-end OK-only alert with no Load Anyway (device: 12GB, Aggressive, ~6.7GB model refused with no override). Throw OverridableMemoryError instead — the pure typed discriminant loadModelWithOverride already keys the Load Anyway branch on. Any mode, guaranteed override. Red-flow test drives the real resolveSafeContext over an injected memory sensor; red on HEAD (plain Error → isOverridableMemoryError false → dead-end), green after. --- ...oadAnywayAlwaysOverridable.redflow.test.ts | 58 +++++++++++++++++++ src/services/llmSafetyChecks.ts | 8 ++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 __tests__/integration/memory/loadAnywayAlwaysOverridable.redflow.test.ts diff --git a/__tests__/integration/memory/loadAnywayAlwaysOverridable.redflow.test.ts b/__tests__/integration/memory/loadAnywayAlwaysOverridable.redflow.test.ts new file mode 100644 index 000000000..872c833d9 --- /dev/null +++ b/__tests__/integration/memory/loadAnywayAlwaysOverridable.redflow.test.ts @@ -0,0 +1,58 @@ +/** + * RED-FLOW (integration) — a memory refusal on model load must ALWAYS be overridable ("Load Anyway"), + * in every mode, on every path. Never a dead-end. + * + * DEVICE (2026-07-15, 12 GB iPhone 17 Pro Max, Aggressive): loading a ~6.7 GB text model ("qwythos") + * failed with a plain "Error / Failed to load model: … it needs ~6738MB but only 5030MB is available" + * alert that had ONLY an OK button — NO Load Anyway. Root cause: the pre-load memory gate + * (llmSafetyChecks.resolveSafeContext) threw a plain `Error` when the weights exceed available RAM. + * loadModelWithOverride only offers Load Anyway for an OverridableMemoryError; a plain Error falls to + * the dead-end "Failed to load model" alert. So the whole Load-Anyway-always guarantee was bypassed on + * the text pre-load path (the image path already routes through makeRoomFor → OverridableMemoryError). + * + * This drives the REAL gate over the injected memory sensor (the device boundary): weights bigger than + * available, no override → it must throw an OVERRIDABLE error so the UI shows Load Anyway. Then override + * → it must proceed. RED on HEAD: the throw is a plain Error → isOverridableMemoryError === false. + */ +import { resolveSafeContext } from '../../../src/services/llmSafetyChecks'; +import { isOverridableMemoryError } from '../../../src/utils/modelLoadErrors'; + +const MB = 1024 * 1024; +const GB = 1024 * MB; + +// Device boundary: the memory sensor. 12 GB device, but only ~5030 MB free right now (the exact +// device number) — reclaim-aware or not, this is what the gate saw when it refused with no Load Anyway. +const memSensor = (availableMB: number, totalMB = 12 * 1024) => + async () => ({ available: availableMB * MB, total: totalMB * MB }); + +describe('memory refusal is always overridable (Load Anyway), any mode (red-flow)', () => { + it('throws an OVERRIDABLE error when model weights exceed available RAM (drives Load Anyway)', async () => { + let thrown: unknown; + try { + // ~5.6 GB file → ~6.7 GB weights estimate (fileSize * 1.2), only 5030 MB free → must refuse. + await resolveSafeContext({ + fileSize: Math.round(5.6 * GB), + requestedCtx: 4096, + quantizedCache: false, + override: false, + getAvailableMemory: memSensor(5030), + }); + } catch (e) { + thrown = e; + } + // It must refuse — and the refusal MUST be overridable so the UI offers "Load Anyway". + expect(thrown).toBeInstanceOf(Error); + expect(isOverridableMemoryError(thrown)).toBe(true); // RED on HEAD: plain Error → false → dead-end alert + }); + + it('proceeds (no throw) once the user overrides — Load Anyway actually loads', async () => { + const res = await resolveSafeContext({ + fileSize: Math.round(5.6 * GB), + requestedCtx: 4096, + quantizedCache: false, + override: true, + getAvailableMemory: memSensor(5030), + }); + expect(res.ctxLen).toBeGreaterThan(0); // override skips the hard block and returns a context to load at + }); +}); diff --git a/src/services/llmSafetyChecks.ts b/src/services/llmSafetyChecks.ts index 9df3181d7..1b40d18ce 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. @@ -158,7 +159,12 @@ export async function resolveSafeContext(args: { const finalCheck = await checkMemoryForModel({ modelFileSize: fileSize, contextLength: minCtx, getAvailableMemory: getMem, quantizedCache }); const modelMB = (fileSize * 1.2) / (1024 * 1024); 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 From c8a29b7be071e89f58d07c89af9877b3fcf166b1 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:03:12 +0530 Subject: [PATCH 08/66] =?UTF-8?q?test(audio):=20red=20flow=20=E2=80=94=20r?= =?UTF-8?q?ealtime=20hold-to-talk=20dictation=20must=20recover=20from=20a?= =?UTF-8?q?=20blocked=20whisper=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erRealtimeBlockedRecovers.redflow.test.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 __tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx diff --git a/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx new file mode 100644 index 000000000..2b6a824ae --- /dev/null +++ b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx @@ -0,0 +1,83 @@ +/** + * 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 }); + /* eslint-disable @typescript-eslint/no-var-requires */ + const { useWhisperStore } = require('../../../src/stores/whisperStore'); + const { modelResidencyManager } = require('../../../src/services/modelResidency'); + /* eslint-enable @typescript-eslint/no-var-requires */ + + // 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 }); + }); +}); From 233e9a72e337506af4a06890c5b32da882b5b7d1 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:04:39 +0530 Subject: [PATCH 09/66] =?UTF-8?q?test:=20red=20=E2=80=94=20stripper=20leak?= =?UTF-8?q?s=20=20markup;=20deltaHasThinking=20misse?= =?UTF-8?q?s=20inline=20channel=20reasoning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stores/remoteModelCapabilities.test.ts | 25 +++++++++++++++++++ __tests__/unit/utils/messageContent.test.ts | 21 ++++++++++++++++ 2 files changed, 46 insertions(+) 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.'); + }); }); From ec1fcfb97cf16da8547f432834b818e44d499f52 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:08:59 +0530 Subject: [PATCH 10/66] =?UTF-8?q?fix:=20stripper=20strips=20=20tool-call=20markup;=20deltaHasThinking=20uses=20sh?= =?UTF-8?q?ared=20REASONING=5FDELIMITERS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defect 1: stripControlTokens now strips the XML-style … tool-call block (plus its unclosed-at-EOS tail) the tool-loop extractor recognizes, via new shared XML_TOOL_CALL_* marker sources in messageContent that BOTH the extractor (parseXmlStyleToolCall) and the stripper key on — so they cannot drift. Defect 2: deltaHasThinking probes inline content through the shared REASONING_DELIMITERS grammar instead of a hardcoded , so a remote model emitting Gemma/Qwen channel reasoning inline (no reasoning_content field) is no longer misdetected as non-thinking. --- src/services/generationToolLoop.ts | 7 +++++-- src/stores/remoteModelCapabilities.ts | 12 ++++++++++-- src/utils/messageContent.ts | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index a1eec092f..3a928a782 100644 --- a/src/services/generationToolLoop.ts +++ b/src/services/generationToolLoop.ts @@ -17,6 +17,7 @@ import { selectToolsByEmbedding } from './toolEmbeddingRouter'; import { providerRegistry } from './providers'; 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 +33,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]*?)(?=): 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..0330cdf22 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``; +export 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.) From 550ad8ce0837b18c81891bc1613c376e74de6318 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:09:17 +0530 Subject: [PATCH 11/66] test(generation): guard tool-loop engine routing against the engines.ts owner (red) --- .../toolLoopEngineOwnerRouting.test.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 __tests__/integration/generation/toolLoopEngineOwnerRouting.test.ts 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()); + }); +}); From 91077e501cf72671ab1f98d70c50b1fb16e22ae4 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:10:24 +0530 Subject: [PATCH 12/66] fix(audio): route realtime hold-to-talk dictation through ensureWhisperForTranscription so a blocked load recovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The realtime path called whisperStore.loadModel() directly; a 'blocked' single-model memory refusal returns (does not throw), so execution fell through to startRealtimeTranscription → 'No Whisper model loaded' — a silent dead-end with no recovery. Inject the SAME free-generation-then-retry recovery the file path already uses (ensureWhisperForTranscription) into useWhisperTranscription, so both dictation modes share ONE recovery owner and a memory-blocked dictation recovers instead of dead-ending. --- ...erRealtimeBlockedRecovers.redflow.test.tsx | 2 - src/components/ChatInput/Voice.ts | 28 +++++++------ src/hooks/useWhisperTranscription.ts | 41 ++++++++++++------- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx index 2b6a824ae..1effa4f4b 100644 --- a/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx +++ b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx @@ -39,10 +39,8 @@ jest.mock('@react-navigation/native', () => ({ 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 }); - /* eslint-disable @typescript-eslint/no-var-requires */ const { useWhisperStore } = require('../../../src/stores/whisperStore'); const { modelResidencyManager } = require('../../../src/services/modelResidency'); - /* eslint-enable @typescript-eslint/no-var-requires */ // 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). 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/hooks/useWhisperTranscription.ts b/src/hooks/useWhisperTranscription.ts index 51d11a545..414dbee3b 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(''); @@ -37,7 +49,7 @@ export const useWhisperTranscription = (): UseWhisperTranscriptionResult => { 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 @@ -172,17 +184,18 @@ export const useWhisperTranscription = (): UseWhisperTranscriptionResult => { } 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 (!ready) { + setError("Couldn't load the voice model — free some memory and try again"); return; } } @@ -245,7 +258,7 @@ export const useWhisperTranscription = (): UseWhisperTranscriptionResult => { Vibration.vibrate([0, 50, 50, 50]); } } - }, [downloadedModelId, loadModel, isRecording, stopRecording, finalizeTranscription]); + }, [ensureModelReady, isRecording, stopRecording, finalizeTranscription]); return { isRecording, From 474cbf9191a9b8ca123e16c17593faef60a4cd1c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:12:32 +0530 Subject: [PATCH 13/66] fix(generation): route tool-loop engine/remote checks through the engines.ts owner (DRY) isLiteRTActive/isUsingRemote and the two re-inlined activeServer+hasProvider+!localLoaded checks (callLLMWithRetry, selectEffectiveSchemas) now delegate to getActiveEngineService() and isRemoteTextModelActive() so 'which engine is active' lives in one place. Behavior-neutral in the loop's runtime context (readiness is gated before the loop); aligns with the sibling generationServiceHelpers.isLiteRTActive. --- src/services/generationToolLoop.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index a1eec092f..331207e66 100644 --- a/src/services/generationToolLoop.ts +++ b/src/services/generationToolLoop.ts @@ -15,6 +15,7 @@ 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'; const MAX_TOOL_ITERATIONS = 3; @@ -420,16 +421,18 @@ async function callLocalWithRetry( throw new Error(lastError?.message || String(lastError) || 'Unknown LLM error after tool execution'); } +/** Is the active text engine LiteRT? Delegates to the engines.ts owner (getActiveEngineService) so + * "which engine is active" is defined ONCE — never re-derived from the store here. Loaded-readiness is a + * separate concern the caller (checkProviderReadiness) already gates before the loop runs. */ function isLiteRTActive(): boolean { - const { downloadedModels, activeModelId } = useAppStore.getState(); - return downloadedModels.find((m: any) => 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 +673,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 +803,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 From aeeaa780fc614c25960b09e2eac9bef46c913801 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:13:22 +0530 Subject: [PATCH 14/66] test(models): projector/clip download sidecars must classify via canonical isMMProjFile (red) --- .../models/mmProjHydrationClassify.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 __tests__/integration/models/mmProjHydrationClassify.test.ts 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(); + }); +}); From f065b295f3d61c14e8e8d0c566dd0f2bcd173060 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:14:56 +0530 Subject: [PATCH 15/66] fix(models): route downloadHydration.isMmProjFileName through canonical isMMProjFile (DRY) The local copy matched only 'mmproj' and missed 'projector'/'clip', so an orphaned projector sidecar with those names slipped past getParentRows and hydrated as a phantom standalone model. Delegates to src/services/mmproj.ts (the single source), still exported for restore.ts. --- src/services/downloadHydration.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/services/downloadHydration.ts b/src/services/downloadHydration.ts index d39cea685..091fc341e 100644 --- a/src/services/downloadHydration.ts +++ b/src/services/downloadHydration.ts @@ -2,6 +2,7 @@ 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 logger from '../utils/logger'; type NativeDownloadRow = { @@ -22,9 +23,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 { From 45e8549a37f457d1a8a6ca363ce3e3036b515425 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:16:37 +0530 Subject: [PATCH 16/66] test(models): document that download-time projector matching is still the loose rule (defect 2a) it.failing evidence: findMatchingMMProj pairs a wrong-arch E4B projector to an E2B model at the same quant. NOT migrated to the strict pickMmProjForModel because that would regress the generic single-projector download path (bare mmproj-F16.gguf) the existing getModelFiles test relies on. --- .../huggingfaceProjectorStrictness.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 __tests__/unit/services/huggingfaceProjectorStrictness.test.ts diff --git a/__tests__/unit/services/huggingfaceProjectorStrictness.test.ts b/__tests__/unit/services/huggingfaceProjectorStrictness.test.ts new file mode 100644 index 000000000..bb7155673 --- /dev/null +++ b/__tests__/unit/services/huggingfaceProjectorStrictness.test.ts @@ -0,0 +1,44 @@ +/** + * DEFECT 2a — DOCUMENTED RED (intentionally NOT fixed; see the agent report). + * + * huggingface.findMatchingMMProj (the DOWNLOAD-time projector matcher, reached via getModelFiles) is still + * the LOOSE quant-based matcher with the "closest/only one" fallback. #510 c815752f claimed to replace every + * projector matcher with the ONE strict rule (mmproj.pickMmProjForModel — name+variant stem, quant ignored, + * NO "closest" fallback), but it only migrated isMMProjFile here and left findMatchingMMProj on the loose + * path. So the download-time matcher will pair a WRONG-ARCHITECTURE projector when it shares the model's + * quant (E2B model Q4 ↔ E4B projector Q4) — the exact E2B↔E4B mispairing the strict rule exists to refuse. + * + * This test asserts the STRICT-correct product outcome (the E4B projector must NOT be paired to an E2B model) + * and is RED on HEAD because the loose matcher accepts it via the exact-quant branch. It is filed as + * evidence of the drift, NOT wired to a fix: migrating findMatchingMMProj to the strict rule would REGRESS a + * legitimate, currently-working path — the majority repo shape where a single GENERIC projector (bare + * `mmproj-F16.gguf`, no model name) is the only projector. The strict stem match returns undefined for that + * (no name overlap), so vision models with a generic projector would download text-only. The existing test + * `huggingface.test.ts › getModelFiles › separates mmproj files from model files` (model-Q4_K_M.gguf paired + * with mmproj-f16.gguf) encodes that working path and would break. See the report for the recommended fix + * (a download-time matcher that keeps the generic-single-projector case AND refuses a name-mismatched one). + */ +import { huggingFaceService } from '../../../src/services/huggingface'; + +describe('DEFECT 2a (documented, unfixed): download-time projector matching is still the loose rule', () => { + const originalFetch = global.fetch; + afterEach(() => { global.fetch = originalFetch; }); + + it.failing('refuses to pair an E4B projector with an E2B model even at the same quant (strict rule)', async () => { + // Raw HF listing: an E2B model and a WRONG-ARCH E4B projector that shares the model's Q4_K_M quant. + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([ + { type: 'file', path: 'gemma-4-E2B-it-Q4_K_M.gguf', size: 2_000_000_000 }, + { type: 'file', path: 'gemma-4-E4B-it-Q4_K_M-mmproj.gguf', size: 800_000_000 }, + ]), + }) as unknown as typeof fetch; + + const files = await huggingFaceService.getModelFiles('google/gemma'); + const e2b = files.find(f => f.name === 'gemma-4-E2B-it-Q4_K_M.gguf')!; + + // STRICT/product-correct: an E4B projector is the wrong architecture for an E2B model → refuse it (undefined). + // HEAD (loose): the exact-quant branch accepts 'gemma-4-E4B-it-Q4_K_M-mmproj.gguf' → this fails (RED). + expect(e2b.mmProjFile?.name).toBeUndefined(); + }); +}); From 62a04710d02d8b94a2966fba242b55f6fc84b939 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:28:15 +0530 Subject: [PATCH 17/66] =?UTF-8?q?test(models):=20red-flow=20=E2=80=94=20pi?= =?UTF-8?q?cker=20RAM=20label=20diverges=20from=20residency=20chip=20on=20?= =?UTF-8?q?GPU=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...hesResidencyChip.rendered.redflow.test.tsx | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 __tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx 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..c0d456734 --- /dev/null +++ b/__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx @@ -0,0 +1,138 @@ +/** + * 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: () => {} }; + + /* eslint-disable @typescript-eslint/no-var-requires */ + 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'); + /* eslint-enable @typescript-eslint/no-var-requires */ + + // 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); +}); From 4ac158a8944124fd9b0e26437012ab17376634b9 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:30:55 +0530 Subject: [PATCH 18/66] =?UTF-8?q?test(models):=20red=20=E2=80=94=20iOS=20t?= =?UTF-8?q?ext=20retry=20on=20a=20lost-downloadId=20failed=20card=20is=20a?= =?UTF-8?q?=20no-op=20(no=20Retry=20affordance)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...osLostDownloadId.rendered.redflow.test.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 __tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx 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); +}); From 10d26ad068d76a36d46293f26328a3a05b7953ab Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:32:43 +0530 Subject: [PATCH 19/66] test(audio): TTS speak-path memory refusal surfaces Load Anyway (red-flow) Drives the real store speak() -> ttsPlayback.speakMessage -> initializeEngine -> modelResidencyManager over the device-memory harness. A resident peer sidecar whose native unload rejects makes the override force refuse (fits:false); asserts a tts failure lands in the real failure store, overridable, with an onLoadAnyway - the user-facing Load Anyway affordance, not a silent bail. --- ...eakMemoryRefusalLoadAnyway.redflow.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 __tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts diff --git a/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts b/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts new file mode 100644 index 000000000..3b31a487e --- /dev/null +++ b/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts @@ -0,0 +1,95 @@ +/** + * 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 outcome: a tts failure card exists, overridable, with a Load Anyway action. + const failure = useModelFailureStore.getState().failures.find((f) => f.modelType === 'tts'); + expect(failure).toBeDefined(); // RED on HEAD: undefined (silent bail, only state.error set) + expect(failure!.overridable).toBe(true); // RED on HEAD: plain Error → not overridable + expect(typeof failure!.onLoadAnyway).toBe('function'); // the "Load Anyway" affordance + }); +}); From 741e7a2d3eb1d1589dffafbbde085be5303aceb3 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:32:46 +0530 Subject: [PATCH 20/66] fix(models): route picker RAM label through backend-aware textOverheadMultiplier so it matches the residency chip --- ...ckerRamMatchesResidencyChip.rendered.redflow.test.tsx | 2 -- src/components/ModelSelectorModal/TextTab.tsx | 9 ++++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx b/__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx index c0d456734..9eab0b5ab 100644 --- a/__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx +++ b/__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx @@ -69,7 +69,6 @@ describe('RAM display agreement — picker label matches the residency chip for const g = globalThis as unknown as { window?: Record }; if (!g.window) g.window = { dispatchEvent: () => true, addEventListener: () => {}, removeEventListener: () => {} }; - /* eslint-disable @typescript-eslint/no-var-requires */ const React = require('react'); const rtl = requireRTL(); const { hardwareService } = require('../../../src/services/hardware'); @@ -81,7 +80,6 @@ describe('RAM display agreement — picker label matches the residency chip for const { BackendSelector } = require('../../../src/components/settings/textGenAdvancedSections'); const { ModelSelectorModal } = require('../../../src/components/ModelSelectorModal'); const { llmService } = require('../../../src/services/llm'); - /* eslint-enable @typescript-eslint/no-var-requires */ // 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. 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'}`} From 8d8d09e908e864f8d6a85b9fab7f9be3d8773e2d Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:33:07 +0530 Subject: [PATCH 21/66] fix(models): route text download retry through the single owner (modelDownloadService.retry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the inline Platform.OS retry state machine in TextModelsTab (a second live copy of textProvider.retry's Android branch) and the iOS proceedDownload() no-op. Retry now routes through modelDownloadService.retry(uniformDownloadId('text', modelKey)) — the same owner the Download Manager screen uses — which owns the platform decision AND the lost-downloadId re-issue case. The failed card no longer gates its Retry on a downloadId, so a rehydrated app-killed iOS entry that lost its downloadId is retriable, not a no-op. --- src/screens/ModelsScreen/TextModelsTab.tsx | 50 ++++------------------ 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx index 189c59293..d9b6b2fac 100644 --- a/src/screens/ModelsScreen/TextModelsTab.tsx +++ b/src/screens/ModelsScreen/TextModelsTab.tsx @@ -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; From ddaf905fd4fb9fcec69589feff223238657f4cb6 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:34:34 +0530 Subject: [PATCH 22/66] test(models): pin the detail Available-Files fit hint to the owned fileExceedsBudget verdict Guards the DRY collapse: the list filter must offer exactly the files fileExceedsBudget says fit. Proven red by drifting the inline formula (over-budget quant then leaks in). --- ...esFitHintUsesOwnedBudget.rendered.test.tsx | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 __tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx diff --git a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx new file mode 100644 index 000000000..1b50445d0 --- /dev/null +++ b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx @@ -0,0 +1,77 @@ +/** + * 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. + const boundary = 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); +}); From 748c5479d48ef2f2314b991f44d6aa164cdf9baf Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:34:52 +0530 Subject: [PATCH 23/66] test(audio): audio bubble Play speaks single-source prepareMessageForSpeech (red-flow) Mounts the real AudioMessageBubble, taps Play, asserts the dispatched speech text equals prepareMessageForSpeech(transcript) for a markdown + control-token input. RED: the bubble uses stripMarkdownForSpeech only, leaving the block in. --- ...bleSpeechTextSingleSource.redflow.test.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 __tests__/integration/audio/audioBubbleSpeechTextSingleSource.redflow.test.tsx 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 + }); +}); From 3d069abe83ab3f8c9e3c67848e470c500bf09b33 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:36:05 +0530 Subject: [PATCH 24/66] fix(models): route all text device-fit hints through the single owner fileExceedsBudget Replace the three hand-rolled 'size/GB < ramGB*modelBudgetFraction' copies (the file-card isCompatible badge + the Available-Files list filter in TextModelsTab, and the browse-list RAM filter in useTextModels) with the ONE owned device-budget primitive fileExceedsBudget, so no fit verdict can drift from the others (or from the download-warning/picker verdicts). --- src/screens/ModelsScreen/TextModelsTab.tsx | 6 +++--- src/screens/ModelsScreen/useTextModels.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx index d9b6b2fac..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'; @@ -190,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} @@ -255,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/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 => { From f851aea70e836db5e9be7f4bf5744e9ef6c9e639 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:37:15 +0530 Subject: [PATCH 25/66] =?UTF-8?q?test(generation):=20red-flow=20=E2=80=94?= =?UTF-8?q?=20resend=20of=20an=20image=20turn=20diverges=20from=20send=20w?= =?UTF-8?q?hen=20no=20image=20model=20is=20loaded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nFallsBackToText.rendered.redflow.test.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 __tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx 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); +}); From 8d40d4e7fd7f9045ff1578af002862488facd123 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:37:15 +0530 Subject: [PATCH 26/66] fix(generation): share one post-decision dispatch seam between send and resend Extract dispatchResolvedTurn as the single owner of the post-decision routing (image-model guard + text-model provisioning + image-fallback note). Both handleSendFn/dispatchGenerationFn and regenerateResponseFn now route through it, so resend can no longer fire the image pipeline unconditionally (it falls back to text like send when no image model is loaded) nor skip the text-provision path on an image-only device. The normal model-loaded case is behavior-identical. --- .../ChatScreen/useChatGenerationActions.ts | 99 ++++++++++++++----- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index b3dec9e1f..7fe9e7dcd 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. */ +export 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. + */ +export 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,30 +519,20 @@ 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 { @@ -541,15 +583,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; From be38296ccf257f74a8565c2b1c4309ff70568990 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:38:23 +0530 Subject: [PATCH 27/66] =?UTF-8?q?test(onboarding):=20red=20=E2=80=94=20ove?= =?UTF-8?q?r-budget=20curated=20LiteRT=20E4B=20is=20filtered=20out=20so=20?= =?UTF-8?q?its=20download=20warning=20is=20unreachable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...WarningReachable.rendered.redflow.test.tsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 __tests__/integration/onboarding/litertOverBudgetWarningReachable.rendered.redflow.test.tsx diff --git a/__tests__/integration/onboarding/litertOverBudgetWarningReachable.rendered.redflow.test.tsx b/__tests__/integration/onboarding/litertOverBudgetWarningReachable.rendered.redflow.test.tsx new file mode 100644 index 000000000..e2f164087 --- /dev/null +++ b/__tests__/integration/onboarding/litertOverBudgetWarningReachable.rendered.redflow.test.tsx @@ -0,0 +1,75 @@ +/** + * RED-FLOW (UI, rendered) — the onboarding ModelDownloadScreen's curated-LiteRT memory warning must be + * REACHABLE for an over-budget model. + * + * SPEC (the OGAM user's view): the curated Gemma 4 E4B carries a `confirmDownload` warning ("may exceed + * your device's memory ... Download anyway"). On a device where E4B genuinely exceeds the RAM budget, the + * onboarding screen must OFFER E4B with that warning — tapping Download shows the confirm sheet, and the + * user can proceed with "Download anyway". The warning is the owned device-aware decision + * (curatedLiteRTDownloadWarning → fileExceedsBudget). + * + * The bug (device gap): the screen pre-filters `liteRTFiles` to only files that FIT the budget, so the + * over-budget E4B — the only file for which the warning would fire — is never rendered, and + * handleLiteRTDownload's warning branch is dead code. The warning can NEVER show. + * + * This mounts the REAL ModelDownloadScreen on a low-RAM Android device (E4B over budget), and asserts the + * E4B card renders and tapping its Download shows the confirm sheet with "Download anyway". Boundary fakes + * only: native download + fs + RAM (installNativeBoundary), the HuggingFace network transport + * (getModelFiles → [] so the only card is the curated LiteRT one), and navigation (a fake prop). + */ +import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; + +const WARNING_MESSAGE = /may exceed your device's memory/; + +describe('onboarding curated-LiteRT over-budget warning is reachable (red-flow)', () => { + it('offers the over-budget E4B and its Download shows the confirm sheet with Download anyway', async () => { + // 5GB Android → budget = 5 * modelBudgetFraction(5)=0.60 = 3.0GB. E2B (2.41GB) FITS, E4B (3.41GB) + // EXCEEDS — the exact "one fits, one warns" split, proving the warning is reachable specifically for + // the over-budget file. + installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 5 * GB, availBytes: 3 * GB } }); + + jest.doMock('../../../src/services/huggingface', () => ({ + huggingFaceService: { + // No recommended HF files → the only cards are the curated LiteRT ones. + getModelFiles: jest.fn(async () => []), + searchModels: jest.fn(async () => []), + getModelDetails: jest.fn(async () => null), + getDownloadUrl: (m: string, f: string, r = 'main') => `https://hf.co/${m}/resolve/${r}/${f}`, + formatModelSize: jest.fn(() => '3.4 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 { ModelDownloadScreen } = require('../../../src/screens/ModelDownloadScreen'); + /* eslint-enable @typescript-eslint/no-var-requires */ + + await hardwareService.refreshMemoryInfo(); + const ramGB = hardwareService.getTotalMemoryGB(); + // Owner verdict on this device: E2B fits, E4B exceeds → E4B is the warning case. + expect(fileExceedsBudget(2588147712, ramGB)).toBe(false); // E2B fits + expect(fileExceedsBudget(3659530240, ramGB)).toBe(true); // E4B exceeds + + const navigation = { navigate: () => {}, goBack: () => {}, replace: () => {}, setOptions: () => {}, addListener: () => () => {} } as any; + const utils = render(React.createElement(ModelDownloadScreen, { navigation })); + const { getByText, queryByText, getByTestId } = utils; + + // The over-budget E4B card must be OFFERED (it carries the warning affordance), not hidden. + await waitFor(() => expect(getByText('Gemma 4 E4B')).toBeTruthy(), { timeout: 6000 }); + + // Precondition: the confirm sheet is NOT already on screen. + expect(queryByText(WARNING_MESSAGE)).toBeNull(); + expect(queryByText('Download anyway')).toBeNull(); + + // Tap the E4B card's Download control. The cards render smallest-first: E2B (index 0), E4B (index 1). + await act(async () => { fireEvent.press(getByTestId('litert-model-1-download')); }); + + // TERMINAL artifact: the device-aware warning sheet appears with a "Download anyway" escape hatch. + await waitFor(() => expect(queryByText(WARNING_MESSAGE)).not.toBeNull(), { timeout: 4000 }); + expect(queryByText('Download anyway')).not.toBeNull(); + }, 30000); +}); From f8a8ef8bc80b188982b95a7be0cf20f83a172443 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:40:38 +0530 Subject: [PATCH 28/66] =?UTF-8?q?test(onboarding):=20revert=20unlanded=20L?= =?UTF-8?q?iteRT=20over-budget=20red=20test=20(Defect=20C=20blocked=20?= =?UTF-8?q?=E2=80=94=20see=20report)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix for the unreachable onboarding LiteRT warning regresses an existing mockist test (__tests__/rntl/screens/ModelDownloadScreen.test.tsx 'filters out LiteRT models that exceed RAM headroom') that encodes the pre-fix behavior; that file is outside this task's allowed edit set, so per the STOP rule the fix is not shipped. Removing the red test to keep the suite green; the gap is reported for a follow-up that may edit that test file. --- ...WarningReachable.rendered.redflow.test.tsx | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 __tests__/integration/onboarding/litertOverBudgetWarningReachable.rendered.redflow.test.tsx diff --git a/__tests__/integration/onboarding/litertOverBudgetWarningReachable.rendered.redflow.test.tsx b/__tests__/integration/onboarding/litertOverBudgetWarningReachable.rendered.redflow.test.tsx deleted file mode 100644 index e2f164087..000000000 --- a/__tests__/integration/onboarding/litertOverBudgetWarningReachable.rendered.redflow.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/** - * RED-FLOW (UI, rendered) — the onboarding ModelDownloadScreen's curated-LiteRT memory warning must be - * REACHABLE for an over-budget model. - * - * SPEC (the OGAM user's view): the curated Gemma 4 E4B carries a `confirmDownload` warning ("may exceed - * your device's memory ... Download anyway"). On a device where E4B genuinely exceeds the RAM budget, the - * onboarding screen must OFFER E4B with that warning — tapping Download shows the confirm sheet, and the - * user can proceed with "Download anyway". The warning is the owned device-aware decision - * (curatedLiteRTDownloadWarning → fileExceedsBudget). - * - * The bug (device gap): the screen pre-filters `liteRTFiles` to only files that FIT the budget, so the - * over-budget E4B — the only file for which the warning would fire — is never rendered, and - * handleLiteRTDownload's warning branch is dead code. The warning can NEVER show. - * - * This mounts the REAL ModelDownloadScreen on a low-RAM Android device (E4B over budget), and asserts the - * E4B card renders and tapping its Download shows the confirm sheet with "Download anyway". Boundary fakes - * only: native download + fs + RAM (installNativeBoundary), the HuggingFace network transport - * (getModelFiles → [] so the only card is the curated LiteRT one), and navigation (a fake prop). - */ -import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; - -const WARNING_MESSAGE = /may exceed your device's memory/; - -describe('onboarding curated-LiteRT over-budget warning is reachable (red-flow)', () => { - it('offers the over-budget E4B and its Download shows the confirm sheet with Download anyway', async () => { - // 5GB Android → budget = 5 * modelBudgetFraction(5)=0.60 = 3.0GB. E2B (2.41GB) FITS, E4B (3.41GB) - // EXCEEDS — the exact "one fits, one warns" split, proving the warning is reachable specifically for - // the over-budget file. - installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 5 * GB, availBytes: 3 * GB } }); - - jest.doMock('../../../src/services/huggingface', () => ({ - huggingFaceService: { - // No recommended HF files → the only cards are the curated LiteRT ones. - getModelFiles: jest.fn(async () => []), - searchModels: jest.fn(async () => []), - getModelDetails: jest.fn(async () => null), - getDownloadUrl: (m: string, f: string, r = 'main') => `https://hf.co/${m}/resolve/${r}/${f}`, - formatModelSize: jest.fn(() => '3.4 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 { ModelDownloadScreen } = require('../../../src/screens/ModelDownloadScreen'); - /* eslint-enable @typescript-eslint/no-var-requires */ - - await hardwareService.refreshMemoryInfo(); - const ramGB = hardwareService.getTotalMemoryGB(); - // Owner verdict on this device: E2B fits, E4B exceeds → E4B is the warning case. - expect(fileExceedsBudget(2588147712, ramGB)).toBe(false); // E2B fits - expect(fileExceedsBudget(3659530240, ramGB)).toBe(true); // E4B exceeds - - const navigation = { navigate: () => {}, goBack: () => {}, replace: () => {}, setOptions: () => {}, addListener: () => () => {} } as any; - const utils = render(React.createElement(ModelDownloadScreen, { navigation })); - const { getByText, queryByText, getByTestId } = utils; - - // The over-budget E4B card must be OFFERED (it carries the warning affordance), not hidden. - await waitFor(() => expect(getByText('Gemma 4 E4B')).toBeTruthy(), { timeout: 6000 }); - - // Precondition: the confirm sheet is NOT already on screen. - expect(queryByText(WARNING_MESSAGE)).toBeNull(); - expect(queryByText('Download anyway')).toBeNull(); - - // Tap the E4B card's Download control. The cards render smallest-first: E2B (index 0), E4B (index 1). - await act(async () => { fireEvent.press(getByTestId('litert-model-1-download')); }); - - // TERMINAL artifact: the device-aware warning sheet appears with a "Download anyway" escape hatch. - await waitFor(() => expect(queryByText(WARNING_MESSAGE)).not.toBeNull(), { timeout: 4000 }); - expect(queryByText('Download anyway')).not.toBeNull(); - }, 30000); -}); From 48d23ee6b3da1da6ba4a95e10b80b92cc2752b73 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:41:06 +0530 Subject: [PATCH 29/66] test(memory): rendered red-flow for text Load-Anyway + kept [MEM-SM] gate traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the service-level loadAnywayAlwaysOverridable test with a RENDERED one (mount ChatScreen → real send gesture → assert the user SEES 'Load Anyway' on the refusal alert). It pins the resolveSafeContext gate via the 'it needs ~' signature (re-seeds a 5GB on-disk file so that gate — which sizes from RNFS.stat — refuses, while residency reads the small record and admits). Red-verified at the correct gate: reverting OverridableMemoryError→Error keeps the same [MEM-SM] gate firing but drops the Load Anyway button → red. Add KEPT [MEM-SM] traces at checkMemoryForModel + resolveSafeContext (permanent state-machine logs, on-device via the debug sink + test-visible via DEBUG_LOGS=1) so the fit decision is never guessed again. --- ...oadAnywayAlwaysOverridable.redflow.test.ts | 58 ------------- .../loadAnywayCardRendered.redflow.test.tsx | 81 +++++++++++++++++++ src/services/llmSafetyChecks.ts | 6 ++ 3 files changed, 87 insertions(+), 58 deletions(-) delete mode 100644 __tests__/integration/memory/loadAnywayAlwaysOverridable.redflow.test.ts create mode 100644 __tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx diff --git a/__tests__/integration/memory/loadAnywayAlwaysOverridable.redflow.test.ts b/__tests__/integration/memory/loadAnywayAlwaysOverridable.redflow.test.ts deleted file mode 100644 index 872c833d9..000000000 --- a/__tests__/integration/memory/loadAnywayAlwaysOverridable.redflow.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * RED-FLOW (integration) — a memory refusal on model load must ALWAYS be overridable ("Load Anyway"), - * in every mode, on every path. Never a dead-end. - * - * DEVICE (2026-07-15, 12 GB iPhone 17 Pro Max, Aggressive): loading a ~6.7 GB text model ("qwythos") - * failed with a plain "Error / Failed to load model: … it needs ~6738MB but only 5030MB is available" - * alert that had ONLY an OK button — NO Load Anyway. Root cause: the pre-load memory gate - * (llmSafetyChecks.resolveSafeContext) threw a plain `Error` when the weights exceed available RAM. - * loadModelWithOverride only offers Load Anyway for an OverridableMemoryError; a plain Error falls to - * the dead-end "Failed to load model" alert. So the whole Load-Anyway-always guarantee was bypassed on - * the text pre-load path (the image path already routes through makeRoomFor → OverridableMemoryError). - * - * This drives the REAL gate over the injected memory sensor (the device boundary): weights bigger than - * available, no override → it must throw an OVERRIDABLE error so the UI shows Load Anyway. Then override - * → it must proceed. RED on HEAD: the throw is a plain Error → isOverridableMemoryError === false. - */ -import { resolveSafeContext } from '../../../src/services/llmSafetyChecks'; -import { isOverridableMemoryError } from '../../../src/utils/modelLoadErrors'; - -const MB = 1024 * 1024; -const GB = 1024 * MB; - -// Device boundary: the memory sensor. 12 GB device, but only ~5030 MB free right now (the exact -// device number) — reclaim-aware or not, this is what the gate saw when it refused with no Load Anyway. -const memSensor = (availableMB: number, totalMB = 12 * 1024) => - async () => ({ available: availableMB * MB, total: totalMB * MB }); - -describe('memory refusal is always overridable (Load Anyway), any mode (red-flow)', () => { - it('throws an OVERRIDABLE error when model weights exceed available RAM (drives Load Anyway)', async () => { - let thrown: unknown; - try { - // ~5.6 GB file → ~6.7 GB weights estimate (fileSize * 1.2), only 5030 MB free → must refuse. - await resolveSafeContext({ - fileSize: Math.round(5.6 * GB), - requestedCtx: 4096, - quantizedCache: false, - override: false, - getAvailableMemory: memSensor(5030), - }); - } catch (e) { - thrown = e; - } - // It must refuse — and the refusal MUST be overridable so the UI offers "Load Anyway". - expect(thrown).toBeInstanceOf(Error); - expect(isOverridableMemoryError(thrown)).toBe(true); // RED on HEAD: plain Error → false → dead-end alert - }); - - it('proceeds (no throw) once the user overrides — Load Anyway actually loads', async () => { - const res = await resolveSafeContext({ - fileSize: Math.round(5.6 * GB), - requestedCtx: 4096, - quantizedCache: false, - override: true, - getAvailableMemory: memSensor(5030), - }); - expect(res.ctxLen).toBeGreaterThan(0); // override skips the hard block and returns a context to load at - }); -}); diff --git a/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx b/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx new file mode 100644 index 000000000..28e613731 --- /dev/null +++ b/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx @@ -0,0 +1,81 @@ +/** + * 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 () => { + // declared 3.5GB → residency (aggressive) admits (sizeMB 5376 < budget 10813). deferInitialLoad → the + // first send triggers the real lazy load. platform android + 12GB/5GB-avail = the device profile. + const h = await setupChatScreen({ + engine: 'llama', + platform: 'android', + modelFileSizeBytes: 3.5 * GB, + ram: { platform: 'android', 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/src/services/llmSafetyChecks.ts b/src/services/llmSafetyChecks.ts index 1b40d18ce..dfeba2a8c 100644 --- a/src/services/llmSafetyChecks.ts +++ b/src/services/llmSafetyChecks.ts @@ -109,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, @@ -158,6 +161,9 @@ 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) { // 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 → From 7b138f57e49615a63656ca9ca6f4411e37760d17 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 19:58:51 +0530 Subject: [PATCH 30/66] =?UTF-8?q?test(models):=20delete=20mockist=20ModelS?= =?UTF-8?q?electorModal=20test=20(jest.mocks=20our=20stores=20=E2=80=94=20?= =?UTF-8?q?fake=20coverage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per doctrine + standing rule: a test that jest.mocks our own stores/services/hardware proves nothing (passes with the impl deleted). The RAM-parity behavior is really covered by pickerRamMatchesResidencyChip.rendered.redflow.test.tsx. The source s.settings?.inferenceBackend is REAL defensive access (settings is undefined pre-store- hydration — 5 rendered tests break without it), not a mock accommodation. --- .../components/ModelSelectorModal.test.tsx | 1106 ----------------- 1 file changed, 1106 deletions(-) delete mode 100644 __tests__/rntl/components/ModelSelectorModal.test.tsx 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(); - }); - }); -}); From 6a64c0ba267fc211592974bfc22ff283d5d77129 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:01:00 +0530 Subject: [PATCH 31/66] test(memory): rendered red-flow for text pre-load gate reclaim-aware availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the qwythos intersection (12GB Android, Aggressive, 5GB raw-available, 5GB on-disk model): residency ADMITS (reclaim-aware budget 10813MB) but the text pre-load gate refused on the RAW snapshot (5120MB) — two sources of truth. Android case asserts the model LOADS + generates (reclaim credit); iOS case asserts it STAYS refused (raw, no reclaim — jetsam-safe, unchanged). --- ...GateReclaimAware.rendered.redflow.test.tsx | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 __tests__/integration/memory/textPreloadGateReclaimAware.rendered.redflow.test.tsx 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); +}); From b3ecf30e7dc01724581c79e36c2f9b62c53adf7c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:01:07 +0530 Subject: [PATCH 32/66] fix(memory): feed the text pre-load gate reclaim-aware available RAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llm.ts getMem fed checkMemoryForModel/resolveSafeContext the RAW os_proc snapshot (hardwareService.getAppMemoryUsage, total-used), so the pre-load gate disagreed with the residency gate — the third path ea877f57 never reached. Wrap the raw available through memoryBudget.effectiveAvailableMB (the single reclaim-aware owner) with the residency manager's policy, exactly as residency does. On Android this credits the LMK reclaim (residency-ADMITTED models no longer refused at ~5030MB raw, device qwythos); on iOS effectiveAvailableMB returns raw unchanged (jetsam-safe, untouched). No change to the residency budget or the makeRoomFor jetsam guard. --- src/services/llm.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 From 3d66c9969dda8dd03742889d8510ae0b9b4349cb Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:07:27 +0530 Subject: [PATCH 33/66] test(memory): re-pin crux Load-Anyway to iOS so it coexists with the reclaim fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reclaim-aware gate fix makes the Android-Aggressive cell ADMIT (LMK credit), so the refusal the crux test needs only survives on iOS (no reclaim → raw gate). residency still admits (3.5GB record); resolveSafeContext refuses on the raw 5GB on-disk estimate → Load Anyway. Both memory tests now green together; crux red-verified on iOS. --- .../memory/loadAnywayCardRendered.redflow.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx b/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx index 28e613731..1e045c76c 100644 --- a/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx +++ b/__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx @@ -31,13 +31,16 @@ jest.mock('@react-navigation/native', () => ({ 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 () => { - // declared 3.5GB → residency (aggressive) admits (sizeMB 5376 < budget 10813). deferInitialLoad → the - // first send triggers the real lazy load. platform android + 12GB/5GB-avail = the device profile. + // 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: 'android', + platform: 'ios', modelFileSizeBytes: 3.5 * GB, - ram: { platform: 'android', totalBytes: 12 * GB, availBytes: 5 * GB }, + ram: { platform: 'ios', totalBytes: 12 * GB, availBytes: 5 * GB }, deferInitialLoad: true, }); From 3daf83f9b9a4eec6ead8a7f0906f9a538f4ebdcb Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:05:20 +0530 Subject: [PATCH 34/66] =?UTF-8?q?test(downloads):=20rendered=20red-flow=20?= =?UTF-8?q?=E2=80=94=20orphaned=20projector=20sidecar=20must=20not=20hydra?= =?UTF-8?q?te=20as=20a=20phantom=20model=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts the real DownloadManagerScreen over the download-native + fs harness, seeds an orphaned '*-projector.gguf' native row (no parent back-link), calls the real hydrateDownloadStore, and asserts NO card shows the projector filename. Reverting isMmProjFileName to 'mmproj'-only makes the phantom card reappear (red-verified). No mocks of our own code. (cherry picked from commit ef48a4b4aa02b7f697730193d8955fe1f6a8c305) --- ...PhantomHydration.rendered.redflow.test.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 __tests__/integration/downloads/orphanProjectorPhantomHydration.rendered.redflow.test.tsx 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(); + }); +}); From 175123f945c92da3bd9c8476635e46464f6e0cff Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:17:09 +0530 Subject: [PATCH 35/66] test(audio): assert the RENDERED Load Anyway button on the tts failure card (not the store) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the TTS speak-refusal test to mount the real ModelFailureCard (reads the real failure store the speak refusal populated — no mocks) and assert the user SEES model-failure-load-anyway-tts. Red-verified: revert the pro OverridableMemoryError fix → no tts failure → no card → red. --- ...SpeakMemoryRefusalLoadAnyway.redflow.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts b/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts index 3b31a487e..24ba39cac 100644 --- a/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts +++ b/__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts @@ -86,10 +86,17 @@ describe('TTS speak-path memory refusal is overridable (Load Anyway) — red-flo // The user taps the speaker on an assistant message (the real store speak action). await useTTSStore.getState().speak('hello there', 'msg-1'); - // USER-FACING outcome: a tts failure card exists, overridable, with a Load Anyway action. - const failure = useModelFailureStore.getState().failures.find((f) => f.modelType === 'tts'); - expect(failure).toBeDefined(); // RED on HEAD: undefined (silent bail, only state.error set) - expect(failure!.overridable).toBe(true); // RED on HEAD: plain Error → not overridable - expect(typeof failure!.onLoadAnyway).toBe('function'); // the "Load Anyway" affordance + // 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 }); }); From d34d6f6928fc6f9ff20c8ce2ab05fcd6076ef8b0 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:15:51 +0530 Subject: [PATCH 36/66] test(remote): Gemma-channel inline reasoning surfaces the Thinking toggle (DEV-B16/B17) Rendered integration test: drives the REAL remote-server discovery path (discoverModels -> fetchModelCapabilities -> probeLmStudioThinking -> deltaHasThinking) over a faked network transport that replays a device-shaped LM Studio probe whose delta.content carries the bare Gemma opener <|channel>thought. Selects the discovered remote model via the real setActiveRemoteTextModel action, mounts ChatScreen, opens quick-settings, and asserts the user-visible quick-thinking-toggle renders. RED (hardcoded ): the Gemma opener is missed -> supportsThinking=false -> no toggle. (cherry picked from commit b7c3944e6db9792f2cb493600592fe3a89ad444e) --- ...mmaChannelThinkingToggle.rendered.test.tsx | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 __tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx diff --git a/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx b/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx new file mode 100644 index 000000000..89b30998e --- /dev/null +++ b/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx @@ -0,0 +1,133 @@ +/** + * 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'; + +/** 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(): () => 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(GEMMA_CHANNEL_PROBE_SSE); // the thinking probe + // /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(); + 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(); + }); +}); From b5ce91ab36da2debfc5b5626b6c6195e0576732f Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:21:18 +0530 Subject: [PATCH 37/66] chore: bump pro pointer to fix/tts-load-anyway; log audit follow-ups + image-gen stale-lock finding --- ...esFitHintUsesOwnedBudget.rendered.test.tsx | 2 +- docs/GAPS_BACKLOG.md | 51 +++++++++++++++++++ pro | 2 +- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx index 1b50445d0..b1fd5a102 100644 --- a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx +++ b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx @@ -22,7 +22,7 @@ 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. - const boundary = installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 6 * GB, availBytes: 4 * GB } }); + 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` }; diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md index 7bb025331..c8c3f1f68 100644 --- a/docs/GAPS_BACKLOG.md +++ b/docs/GAPS_BACKLOG.md @@ -274,3 +274,54 @@ 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. diff --git a/pro b/pro index 3599b5b13..ff0d87423 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 3599b5b132119c9b485c355c7fcb089a6fe1802f +Subproject commit ff0d874234c23d3dd2a781b77baafe8102c3fad7 From 797419b1ab1f267de6d360fcf599c132703308f4 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:26:42 +0530 Subject: [PATCH 38/66] docs(gaps): log STT-terminal/embedding partial fixes + picker display note (honest audit) --- docs/GAPS_BACKLOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md index c8c3f1f68..3b2a7fad7 100644 --- a/docs/GAPS_BACKLOG.md +++ b/docs/GAPS_BACKLOG.md @@ -325,3 +325,23 @@ stuck transition (a `phase X → ` with no following reset) names the 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. From 2e567df329f38315da5f02fec246c1f0525b7225 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:30:48 +0530 Subject: [PATCH 39/66] =?UTF-8?q?test(#510):=20red=20=E2=80=94=20queued=20?= =?UTF-8?q?force-image=20send=20loses=20force=20flag,=20generates=20as=20t?= =?UTF-8?q?ext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendered ChatScreen flow: turn #1 holds in prefill (in-flight), turn on force image mode, send turn #2 (non-draw prompt) which queues behind #1, release #1 so the queue drains. On HEAD the queued message dispatches at imageMode='auto' (force lost through QueuedMessage) and the classifier routes it to TEXT — no image drawn. Trace confirms: [ROUTE-SM] dispatch imageMode=auto force=false. --- ...agePreservesMode.rendered.redflow.test.tsx | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 __tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx 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..a1ce82ccd --- /dev/null +++ b/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx @@ -0,0 +1,81 @@ +/** + * #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); + }); +}); From ac0e91558cbbd6b1da55348d4323b7d68218f9ac Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:40:21 +0530 Subject: [PATCH 40/66] =?UTF-8?q?test(vision):=20download-time=20projector?= =?UTF-8?q?=20strictness=20=E2=80=94=20E4B=20projector=20must=20not=20pair?= =?UTF-8?q?=20to=20E2B=20(#510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration test over the REAL huggingFaceService.getModelFiles with only the HF network (global.fetch) faked. Asserts the projector paired onto the downloadable ModelFile: (A) a generic single projector (bare mmproj-F16.gguf, no model-name token) still pairs — the anti-regression guard for ggml-org/gemma-3-* and unsloth gemma-4; (B) a projector whose filename names a DIFFERENT model+variant (E4B projector, E2B model) is REFUSED even at the same quant, so the E2B is never mispaired to the wrong architecture. Red on HEAD: the loose quant matcher accepts the E4B projector for the E2B model. --- .../huggingfaceProjectorStrictness.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 __tests__/unit/services/huggingfaceProjectorStrictness.test.ts 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'); + }); +}); From 4b34e7396ecf845ff438eeb3e1218399793fabc6 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:40:31 +0530 Subject: [PATCH 41/66] fix(vision): route download-time projector matching through mmproj rule owner (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit huggingface.findMatchingMMProj was still the loose quant matcher with a closest/only-one fallback, so a wrong-architecture projector (E4B named projector for an E2B model) got paired at download time and later crashed initMultimodal (Multimodal support not enabled). Add pickMmProjForDownload to src/services/mmproj.ts (the single projector-rule owner) as the download-listing matcher, and route findMatchingMMProj through it — one owner, both call sites (download-listing + on-disk) now share mmproj.ts. Rule: - exact model name+variant match wins (quant is NOT a signal — one projector serves every quant); - a projector with NO model-name token is generic to the repo's single model → pair it (prefer F16, else first) — keeps ggml-org/gemma-3-* and unsloth gemma-4 vision working; - a projector that names a DIFFERENT model+variant is the wrong architecture → refused. Also normalize the fp16 precision token in modelIdentityStem so a bare mmproj-fp16.gguf reads as a generic projector (empty stem) rather than a named one. Updates the huggingface unit test that asserted the removed quant-matching behavior to the new quant-independent F16-preference rule. --- __tests__/unit/services/huggingface.test.ts | 7 ++- src/services/huggingface.ts | 43 +++++++---------- src/services/mmproj.ts | 53 ++++++++++++++++++++- 3 files changed, 74 insertions(+), 29 deletions(-) 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/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/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; +} From 10d1359c5fe3c9399617c6746a50e0f9d42736f1 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:40:51 +0530 Subject: [PATCH 42/66] fix(#510): carry imageMode through the queue so a queued force-image send draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A force-image send that queued behind an in-flight generation lost its force flag: QueuedMessage carried no imageMode, so on drain handleQueuedSend → dispatchGenerationFn re-decided modality at imageMode='auto' → resolveTurnKind classified the (non-draw) text as TEXT. A message the user explicitly forced to image generated as a text reply. Carry imageMode end-to-end: add it to QueuedMessage, set it when handleSendFn enqueues, preserve it in the processNextInQueue merge (any coalesced force wins), and pass it through handleQueuedSend to dispatchGenerationFn. A queued force-image send now dispatches as image (trace: [ROUTE-SM] dispatch imageMode=force → IMAGE pipeline), not re-classified at 'auto'. --- src/screens/ChatScreen/useChatGenerationActions.ts | 6 ++++-- src/screens/ChatScreen/useChatScreen.ts | 4 +++- src/services/generationService.ts | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index b3dec9e1f..fc51e5abf 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -494,7 +494,7 @@ export async function dispatchGenerationFn( } 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 +510,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); 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/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); }); } From 63633c2bf7fcd4994761b8712b5622fe214bb180 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:41:07 +0530 Subject: [PATCH 43/66] test(model-download): delete mockist LiteRT RAM-filter test (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'filters out LiteRT models that exceed RAM headroom' test 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 encodes the #510 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; deleted rather than repaired. Correct behavior is asserted at the rendered layer in the follow-up commit. --- .../rntl/screens/ModelDownloadScreen.test.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) 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'; From d2f4629b43d71d9967aa5c801b01491f38ab9e8e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:41:14 +0530 Subject: [PATCH 44/66] test(model-download): rendered red-flow for over-budget-but-warnable LiteRT (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts the REAL ModelDownloadScreen on a 4GB Android device profile (fakes only the native RAM sensor via installNativeBoundary; no mocks of our own code). At 4GB the safe budget is 2.0GB and both curated LiteRT files exceed it, but only Gemma 4 E4B carries a memory warning. Asserts the user-visible outcome: the over-budget-but-warnable E4B card IS offered (litert-model-0 = 'Gemma 4 E4B'), its download button is enabled, and tapping it surfaces the 'may exceed your device's memory' / 'Download anyway' sheet — while the over-budget no-warning E2B stays hidden (no litert-model-1). RED on HEAD: the pre-filter drops both files, so litert-model-0 is absent and the warning is unreachable. --- ...verBudgetWarning.rendered.redflow.test.tsx | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 __tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx 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); +}); From a73346cf404a86c3c432692f414eb9ea45250fbd Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:41:22 +0530 Subject: [PATCH 45/66] fix(model-download): offer over-budget-but-warnable curated LiteRT models (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding screen pre-filtered the curated LiteRT list to only files fitting the RAM budget, so an over-budget-but-warnable model (Gemma 4 E4B ~3.4GB) was never rendered — making handleLiteRTDownload's memory-warning branch dead code. On a device where E4B exceeds the budget the user saw no card, no warning, and could not start the download at all. The card's isCompatible also re-inlined the budget math and was false for the over-budget card, so even a rendered card's download button would have been disabled (warning still unreachable). Route both the list filter and the card's isCompatible through the single owners (fileExceedsBudget + curatedLiteRTDownloadWarning) instead of a re-inlined budget calc: offer a curated file when it fits the budget OR carries a device-aware warning (guarded by the 'Download anyway' sheet); keep an over-budget file with NO warning hidden. No budget arithmetic is duplicated. --- src/screens/ModelDownloadScreen.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) 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], ); From 7ee6facacde5e62b131dd9a233ec3512c05d90ee Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:51:20 +0530 Subject: [PATCH 46/66] chore(ios): Debug builds display as 'Off Grid AI Debug' (Release stays 'Off Grid AI') The Debug config carries the .dev bundle id and installs alongside the release app; give it a distinct home-screen name too. Point Info.plist CFBundleDisplayName at the per-config INFOPLIST_KEY_CFBundleDisplayName build setting (Debug='Off Grid AI Debug', Release='Off Grid AI') so the name is config-specific instead of a shared literal. --- ios/OffgridMobile.xcodeproj/project.pbxproj | 2 +- ios/OffgridMobile/Info.plist | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From 50fc8cf41a9381befcdaf691f17afe8bbca775ad Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 20:51:41 +0530 Subject: [PATCH 47/66] docs: device checklist for PR #558 (native residue: reclaim/OOM, load-anyway, SDXL, debug name) --- docs/RELEASE_DEVICE_CHECKLIST_pr558.md | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/RELEASE_DEVICE_CHECKLIST_pr558.md diff --git a/docs/RELEASE_DEVICE_CHECKLIST_pr558.md b/docs/RELEASE_DEVICE_CHECKLIST_pr558.md new file mode 100644 index 000000000..ce753f0c3 --- /dev/null +++ b/docs/RELEASE_DEVICE_CHECKLIST_pr558.md @@ -0,0 +1,61 @@ +# 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). + +## 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. From bd34f2e7604acfe2d02f4df77337994c3fb319d2 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 21:05:47 +0530 Subject: [PATCH 48/66] chore(knip): de-export 4 now-internal symbols (dispatchResolvedTurn, ResolvedDispatch, XML_TOOL_CALL_FUNCTION_CLOSER, stripMarkdownForSpeech) CI 'architecture' job failed on knip (unused exports), not depcruise. The refactors left these exported but only used within their own file. De-export (keep the decls). No external importer (verified across src/pro/tests). knip + tsc clean. --- src/screens/ChatScreen/useChatGenerationActions.ts | 4 ++-- src/utils/messageContent.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index eb852065c..971222567 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -458,7 +458,7 @@ let _msgIdSeq = 0; const nextMsgId = () => `${Date.now()}-${(++_msgIdSeq).toStri /** 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. */ -export type ResolvedDispatch = { handled: true } | { handled: false; messageText: string }; +type ResolvedDispatch = { handled: true } | { handled: false; messageText: string }; /** * THE single post-decision dispatch seam — shared by send (dispatchGenerationFn) AND resend @@ -470,7 +470,7 @@ export type ResolvedDispatch = { handled: true } | { handled: false; messageText * 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. */ -export async function dispatchResolvedTurn( +async function dispatchResolvedTurn( deps: GenerationDeps, kind: TurnKind, opts: { diff --git a/src/utils/messageContent.ts b/src/utils/messageContent.ts index 0330cdf22..2bcaf7fe7 100644 --- a/src/utils/messageContent.ts +++ b/src/utils/messageContent.ts @@ -32,7 +32,7 @@ export const TOOL_CALL_CLOSERS: string[] = ['', '']; */ export const XML_TOOL_CALL_FUNCTION_MARKER = String.raw``; export const XML_TOOL_CALL_PARAMETER_MARKER = String.raw``; -export const XML_TOOL_CALL_FUNCTION_CLOSER = ''; +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('|'); @@ -211,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, ''); From 6cd5346f81c4dff946a6feb9082391b352f1c557 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 21:08:51 +0530 Subject: [PATCH 49/66] test(reasoning): add negative discriminator so the Thinking-toggle test pins deltaHasThinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier's mutation (M10) survived: an always-true deltaHasThinking passed the positive-only test (inverting .some(includes) is trivially true over the multi- delimiter list). Add a PLAIN-content probe case (no channel opener, no reasoning field) asserting the toggle is ABSENT — now a broken/always-true deltaHasThinking fails the negative case. Verified: mutating the channel branch to always-true now KILLS the discriminator (was surviving). Payload shapes match the real wire capture (docs/wire-captures part4/part8: gemma emits '<|channel>thought' inline, no reasoning_content). --- ...mmaChannelThinkingToggle.rendered.test.tsx | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx b/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx index 89b30998e..100b45e58 100644 --- a/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx +++ b/__tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx @@ -61,9 +61,21 @@ const GEMMA_CHANNEL_PROBE_SSE = '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(): () => void { +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; @@ -76,7 +88,7 @@ function installDiscoveryFetch(): () => void { // 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(GEMMA_CHANNEL_PROBE_SSE); // the thinking probe + 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(); @@ -99,7 +111,7 @@ describe('remote Gemma-channel inline reasoning → Thinking toggle appears (DEV await llmService.unloadModel(); useAppStore.getState().setActiveModelId(null); - const restoreFetch = installDiscoveryFetch(); + 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. @@ -130,4 +142,35 @@ describe('remote Gemma-channel inline reasoning → Thinking toggle appears (DEV // 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(); + }); }); From bc33700229c67c0fb89c854c1c7605f82ec4e07c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 21:12:38 +0530 Subject: [PATCH 50/66] fix(#26): image RAM hint in ModelPickerSheet uses the estimateImageModelRam owner (was a 1.8x copy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker's image RAM number was a local 1.8x estimate, divergent from every other image-RAM surface. Route it through hardwareService.estimateImageModelRam (single owner) so surfaces agree. Verdict (fileExceedsBudget, 'may not fit') unchanged. Text-half backend-aware unification (needs a settings dep on this HomeScreen picker) deferred as low/cosmetic — verdict is authoritative; logged. --- src/screens/HomeScreen/components/ModelPickerSheet.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/screens/HomeScreen/components/ModelPickerSheet.tsx b/src/screens/HomeScreen/components/ModelPickerSheet.tsx index 02f4cd7cb..91c73f750 100644 --- a/src/screens/HomeScreen/components/ModelPickerSheet.tsx +++ b/src/screens/HomeScreen/components/ModelPickerSheet.tsx @@ -61,7 +61,9 @@ const ImageTabContent: React.FC = ({ downloadedImageModels, activ )} {downloadedImageModels.map((model) => { - const estimatedMemoryGB = (model.size * 1.8) / (1024 * 1024 * 1024); + // RAM hint via the SINGLE owner (hardwareService.estimateImageModelRam) instead of a local + // 1.8x — so this picker's number agrees with every other image-RAM surface (was a divergent copy). + const estimatedMemoryGB = hardwareService.estimateImageModelRam(model) / (1024 * 1024 * 1024); // Owned verdict (DR3 fix): file vs the device-tier budget of TOTAL RAM — never instantaneous free RAM. const memoryFits = memoryInfo ? !fileExceedsBudget(model.size, memoryInfo.memoryTotal / (1024 * 1024 * 1024)) : true; return ( From b7c5a3df0f934134bd80d54bd22f1f4c8688ca4e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 21:13:03 +0530 Subject: [PATCH 51/66] Revert "fix(#26): image RAM hint in ModelPickerSheet uses the estimateImageModelRam owner (was a 1.8x copy)" This reverts commit bc33700229c67c0fb89c854c1c7605f82ec4e07c. --- src/screens/HomeScreen/components/ModelPickerSheet.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/screens/HomeScreen/components/ModelPickerSheet.tsx b/src/screens/HomeScreen/components/ModelPickerSheet.tsx index 91c73f750..02f4cd7cb 100644 --- a/src/screens/HomeScreen/components/ModelPickerSheet.tsx +++ b/src/screens/HomeScreen/components/ModelPickerSheet.tsx @@ -61,9 +61,7 @@ const ImageTabContent: React.FC = ({ downloadedImageModels, activ )} {downloadedImageModels.map((model) => { - // RAM hint via the SINGLE owner (hardwareService.estimateImageModelRam) instead of a local - // 1.8x — so this picker's number agrees with every other image-RAM surface (was a divergent copy). - const estimatedMemoryGB = hardwareService.estimateImageModelRam(model) / (1024 * 1024 * 1024); + const estimatedMemoryGB = (model.size * 1.8) / (1024 * 1024 * 1024); // Owned verdict (DR3 fix): file vs the device-tier budget of TOTAL RAM — never instantaneous free RAM. const memoryFits = memoryInfo ? !fileExceedsBudget(model.size, memoryInfo.memoryTotal / (1024 * 1024 * 1024)) : true; return ( From ab5b4960704cc2794439501d20db1c358ca7b688 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 21:16:56 +0530 Subject: [PATCH 52/66] test(#510): cover the queued multi-message coalesce force-merge (M16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-message test only hit the all[0] shortcut; the coalesce branch (all.length>1 → imageMode = all.some(force)) was untested (verifier M16 surviving mutant). Add a case that queues two sends (one forced) behind an in-flight turn and asserts the merged dispatch draws. Verified: dropping the .some(force) now KILLS this case (was surviving). --- ...agePreservesMode.rendered.redflow.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx b/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx index a1ce82ccd..5c36e517b 100644 --- a/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx @@ -78,4 +78,35 @@ describe('#510 (rendered) — a queued force-image send preserves its force flag // 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(); + }); }); From 5b8a4090e2a0eacc5ac541ab787976934e4828ee Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 21:16:56 +0530 Subject: [PATCH 53/66] docs(gaps): log M5a marginal budget-boundary coverage gap --- docs/GAPS_BACKLOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md index 3b2a7fad7..83d1db41c 100644 --- a/docs/GAPS_BACKLOG.md +++ b/docs/GAPS_BACKLOG.md @@ -345,3 +345,31 @@ These are honestly NOT fully closed by the load-anyway batch — logged so they - **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). From ab3cc0d74b4f479823507effe15ecc4c87d68334 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 21:21:00 +0530 Subject: [PATCH 54/66] =?UTF-8?q?test(memory):=20pin=20fileExceedsBudget's?= =?UTF-8?q?=20exact-budget=20boundary=20(M5a)=20=E2=80=94=20kills=20the=20?= =?UTF-8?q?>=3D/>=20mutant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier's >=→> mutant survived (no test straddled exact equality). Add a BOUNDARY case on a 4GB device where 0.50 fraction = EXACTLY 2.0GB (integer bytes, the only tier where equality is reachable — 0.60×6GB=3.6GB isn't integer). A file exactly at 2.0GB must be hidden (>=), 1 byte under must show. Verified: >=→> now KILLS this case. M5a closed. --- ...esFitHintUsesOwnedBudget.rendered.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx index b1fd5a102..a362b87de 100644 --- a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx +++ b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx @@ -74,4 +74,58 @@ describe('detail Available Files fit hint matches the owned fileExceedsBudget ve 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); }); From 266fd3489ca590b06b79f646a75310e66c092298 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 22:45:49 +0530 Subject: [PATCH 55/66] fix(voice): keep the mic gesturable through a cold model load + un-clip the cancel pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled defects in the hold-to-record mic (chat/asSendButton mode), root-caused from a [VoiceButton-SM] trace of press → cold-load → release: 1. Slide-to-cancel severed on cold load. The instant a press triggered a model load, deriveVoiceButtonState flipped to 'loading' and the component EARLY-RETURNED a bare spinner, UNMOUNTING the hold-to-record view and its PanResponder while the finger was still down. The gesture (hold, slide-to-cancel, release) had nothing left to act on, and no responderRelease/terminate reached a handler (the ghost-recording window). Fix: the loading/transcribing spinner may only REPLACE the button in audio mode (tap-to-toggle, no hold). Chat mode keeps ONE gesturable wrapper mounted across ready/loading/transcribing and swaps only the inner face (ChatButtonFace). The 'Slide to cancel' hint now also shows during the load, since the finger is down. 2. Cancel pill text clipped. styles.cancelHint was position:absolute with only left:-100 and NO width; as an absolute child of the ~44px button container it shrink-fit to that width and wrapped/clipped 'Slide to cancel'. Fix: explicit width, anchor by right (just left of the mic), single-line text, weight 400 (was 500, over the design ceiling). Adds a permanent [VoiceButton-SM] render-state log (the crux trace). Extracts AudioBusyFace + ChatButtonFace to module scope to hold the component under the complexity gate. --- src/components/VoiceRecordButton/index.tsx | 67 ++++++++++++++++------ src/components/VoiceRecordButton/styles.ts | 12 +++- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/components/VoiceRecordButton/index.tsx b/src/components/VoiceRecordButton/index.tsx index 0995389d0..ae298c566 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; @@ -218,19 +251,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,13 +324,19 @@ export const VoiceRecordButton: React.FC = ({ } // ── Chat mode: hold-to-record with slide-to-cancel ───────────────────────── + // The finger is down for the whole hold — a cold model load happens DURING the hold. + // So the cancel hint shows while loading too (you can slide to bail before it's ready), + // and the button face swaps between the spinner and the mic WITHOUT the gesturable + // wrapper ever unmounting, so the PanResponder (hold + slide-to-cancel + release) is + // continuous across the load. + const holding = isRecording || buttonState.kind === 'loading'; return ( - {isRecording && ( + {holding && ( - Slide to cancel + Slide to cancel )} {isRecording && partialResult && ( @@ -313,9 +350,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..ace455c50 100644 --- a/src/components/VoiceRecordButton/styles.ts +++ b/src/components/VoiceRecordButton/styles.ts @@ -168,18 +168,26 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ backgroundColor: colors.textMuted, transform: [{ rotate: '-45deg' }], }, + // A pill hint that sits to the LEFT of the mic while holding to record. It MUST + // carry an explicit width: as an absolute child of the ~44px button container, an + // unsized box shrink-fits to that container width, squeezing "Slide to cancel" into + // a wrapped/clipped smear (the reported "cut off" bug). Anchored by `right` (just + // past the mic) so it always lays out fully into the input row, never off-screen. cancelHint: { position: 'absolute' as const, - left: -100, + right: 52, + width: 132, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: `${colors.primary}40`, borderRadius: 12, + alignItems: 'center' as const, }, cancelHintText: { color: colors.primary, fontSize: 12, - fontWeight: '500' as const, + fontWeight: '400' as const, + textAlign: 'center' as const, }, partialResultContainer: { position: 'absolute' as const, From b27913cd7d4e92cbc8395a2cc1000b5f219bbeb5 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 22:45:49 +0530 Subject: [PATCH 56/66] fix(whisper): nonce-guard the superseded start so release-during-load starts no ghost (#558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startRecording now awaits ensureModelReady() (which can free the generation model and reload whisper — seconds on device). If the mic is released/cancelled during that await, the stop already ran, so proceeding into startRealtimeTranscription activated a session no stop event could reach — a ghost recording holding the mic + model resident forever (#558 CodeRabbit). Fix: a session-intent nonce captured before the await; stopRecording/clearResult bump it; the start aborts if it changed on resume. Paired with the VoiceRecordButton gesture-continuity fix, the release now actually reaches stopRecording during the load. Rendered red-flow test: mount ChatScreen, hold the whisper load open (device-boundary fake), release the mic mid-load, resolve the load — assert NO realtime session is active. Verified by trace: release lands → nonce bumped → 'Start superseded during model load — aborting'. Red-check confirmed the test guards the nonce specifically (neutralize the guard → session activates → red). --- ...perStartSupersededNoGhost.redflow.test.tsx | 59 +++++++++++++++++++ src/hooks/useWhisperTranscription.ts | 16 +++++ 2 files changed, 75 insertions(+) create mode 100644 __tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx 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/src/hooks/useWhisperTranscription.ts b/src/hooks/useWhisperTranscription.ts index 414dbee3b..5e1966216 100644 --- a/src/hooks/useWhisperTranscription.ts +++ b/src/hooks/useWhisperTranscription.ts @@ -45,6 +45,11 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri 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); @@ -121,6 +126,8 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri 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); @@ -161,6 +168,7 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri 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 @@ -183,6 +191,10 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri 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, ensuring readiness (blocked → free generation model → retry)...'); // Route through the SAME recovery the file path uses: a 'blocked' single-model refusal frees the @@ -194,6 +206,10 @@ export const useWhisperTranscription = ({ ensureModelReady }: UseWhisperTranscri } 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; From cd7fcda18b286728a81767f8af64c239a0dc9e74 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 22:53:03 +0530 Subject: [PATCH 57/66] test(voice): guard the cancel-pill layout fix (width + single-line + shows during load) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsdom runs no real Yoga layout, so the pixel clip itself can't be measured — but the two rendered properties that caused vs cure it can: an explicit numeric width on the cancel pill (before: none → shrink-fit to the ~44px button → wrapped/clipped) and numberOfLines=1 on the label. Adds a voice-cancel-hint testID and asserts both, plus that the pill is present during a cold model load (the gesture-continuity fix keeps the hold cancellable while the model warms). Red-verified all three: remove width → red, remove numberOfLines → red, revert the load guard so chat-mode loading early-returns a bare spinner → pill absent → red. --- .../components/VoiceRecordButton.test.tsx | 39 +++++++++++++++++++ src/components/VoiceRecordButton/index.tsx | 1 + 2 files changed, 40 insertions(+) diff --git a/__tests__/rntl/components/VoiceRecordButton.test.tsx b/__tests__/rntl/components/VoiceRecordButton.test.tsx index 1c4e74117..41d0259c2 100644 --- a/__tests__/rntl/components/VoiceRecordButton.test.tsx +++ b/__tests__/rntl/components/VoiceRecordButton.test.tsx @@ -481,4 +481,43 @@ describe('VoiceRecordButton', () => { expect(getByTestId('voice-loading')).toBeTruthy(); }); }); + + // ============================================================================ + // Slide-to-cancel hint layout — regression guard + // + // The reported bug: the "Slide to cancel" pill "doesn't form properly / is cut off". + // Root cause: styles.cancelHint was position:absolute with NO width, so as an absolute + // child of the ~44px button container it shrink-fit to that width and the label wrapped + // into a clipped multi-line smear. jsdom runs no real Yoga layout, so we can't measure + // pixels — but we CAN assert the two rendered properties that caused vs cure the clip: + // an explicit numeric width, and single-line text. Reverting either flips these red. + // ============================================================================ + describe('slide-to-cancel hint layout', () => { + const { StyleSheet } = require('react-native'); + + it('gives the cancel pill an explicit width and single-line text while recording (chat mode)', () => { + const { getByText, getByTestId } = render( + + ); + + // The pill carries a real numeric width (before the fix: undefined → shrink-fit → wrap). + const flat = StyleSheet.flatten(getByTestId('voice-cancel-hint').props.style); + expect(typeof flat.width).toBe('number'); + expect(flat.width).toBeGreaterThan(100); // wide enough for "Slide to cancel" at 12px + + // The label can never wrap into a multi-line smear (before the fix: numberOfLines undefined). + expect(getByText('Slide to cancel').props.numberOfLines).toBe(1); + }); + + it('shows the cancel pill during a cold model load too — the hold stays cancellable', () => { + // Chat mode + loading must keep the gesturable wrapper (and its hint) mounted, not swap + // to a bare spinner. If the load-time render reverted to the old early-return, the pill + // would be absent here. + const { getByTestId } = render( + + ); + expect(getByTestId('voice-cancel-hint')).toBeTruthy(); + expect(getByTestId('voice-record-button')).toBeTruthy(); + }); + }); }); diff --git a/src/components/VoiceRecordButton/index.tsx b/src/components/VoiceRecordButton/index.tsx index ae298c566..61bcd03f9 100644 --- a/src/components/VoiceRecordButton/index.tsx +++ b/src/components/VoiceRecordButton/index.tsx @@ -334,6 +334,7 @@ export const VoiceRecordButton: React.FC = ({ {holding && ( Slide to cancel From cec0db30ecfcf6ea79f7213623699570c3a6c5a3 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 23:14:46 +0530 Subject: [PATCH 58/66] docs(checklist): add the voice/mic gesture fixes (slide-to-cancel pill, cold-load continuity, no ghost) --- docs/RELEASE_DEVICE_CHECKLIST_pr558.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/RELEASE_DEVICE_CHECKLIST_pr558.md b/docs/RELEASE_DEVICE_CHECKLIST_pr558.md index ce753f0c3..cb5084f60 100644 --- a/docs/RELEASE_DEVICE_CHECKLIST_pr558.md +++ b/docs/RELEASE_DEVICE_CHECKLIST_pr558.md @@ -22,6 +22,24 @@ where noted. Mark P/F. 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 From d5f77b6a333ea590c0231a418a464ad51b08f615 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 23:31:42 +0530 Subject: [PATCH 59/66] fix(stt): Home Speech picker shows canonical STT download progress (single owner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device 2026-07-15: with a voice model downloading, Home → Models → Speech showed the plain download icon with no progress, while the Transcription tab showed the live bar. The picker read ONLY whisperStore.downloadProgressById; the tab read the canonical downloadStore (+ whisper fallback). A download tracked canonically (started elsewhere / rehydrated after relaunch) was invisible to the picker — a DRY/SOLID break (the rule lived inline in two places and diverged). Fix: extract the ONE owner useSttDownloadState (pure deriveSttDownloadState + a thin hook merging the canonical tracker with the whisper-store fallback). Both surfaces consume it, so they can never disagree. Picker now renders the transferring % and a queued clock. Rendered test seeds the canonical store (native boundary output) and asserts the row; red-verified by neutralizing the canonical read. --- ...anonicalDownloadProgress.rendered.test.tsx | 69 ++++++++++++++++ src/components/models/WhisperPickerSheet.tsx | 19 +++-- src/hooks/useSttDownloadState.ts | 79 +++++++++++++++++++ .../ModelsScreen/TranscriptionModelsTab.tsx | 54 +++---------- 4 files changed, 168 insertions(+), 53 deletions(-) create mode 100644 __tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx create mode 100644 src/hooks/useSttDownloadState.ts diff --git a/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx b/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx new file mode 100644 index 000000000..a670b0468 --- /dev/null +++ b/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx @@ -0,0 +1,69 @@ +/** + * 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(); + }); +}); 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..738f59f30 --- /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'; + +export 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"). + */ +export 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/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. From 76bd09f56f4ff2bfa08ed313c9eb0b75884df763 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 23:40:44 +0530 Subject: [PATCH 60/66] fix(voice): make the slide-to-cancel hint visible and clear of the mic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device 2026-07-15: while holding to record, the 'Slide to cancel' pill was barely visible and overlapped the mic/+ (the thumb sits on the mic). Two causes: - opacity was tied to slide distance (outputRange [1,0]) → ~invisible at rest, only appearing once you started sliding; now baseline 0.85, brightening to 1 as you approach the cancel zone. - it sat BESIDE the mic (right:52), landing under the thumb; now floats ABOVE the mic (bottom:54, right:0) with a solid surface fill + emerald border so it reads against the chat background. Visual polish — needs an on-device eyeball; the layout guard test (width + single-line + shown during load) still holds. --- src/components/VoiceRecordButton/index.tsx | 4 +++- src/components/VoiceRecordButton/styles.ts | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/VoiceRecordButton/index.tsx b/src/components/VoiceRecordButton/index.tsx index 61bcd03f9..a4edf1ab0 100644 --- a/src/components/VoiceRecordButton/index.tsx +++ b/src/components/VoiceRecordButton/index.tsx @@ -335,7 +335,9 @@ export const VoiceRecordButton: React.FC = ({ {holding && ( Slide to cancel diff --git a/src/components/VoiceRecordButton/styles.ts b/src/components/VoiceRecordButton/styles.ts index ace455c50..0081e55d1 100644 --- a/src/components/VoiceRecordButton/styles.ts +++ b/src/components/VoiceRecordButton/styles.ts @@ -168,18 +168,22 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ backgroundColor: colors.textMuted, transform: [{ rotate: '-45deg' }], }, - // A pill hint that sits to the LEFT of the mic while holding to record. It MUST - // carry an explicit width: as an absolute child of the ~44px button container, an - // unsized box shrink-fits to that container width, squeezing "Slide to cancel" into - // a wrapped/clipped smear (the reported "cut off" bug). Anchored by `right` (just - // past the mic) so it always lays out fully into the input row, never off-screen. + // A pill hint shown while holding to record. It floats ABOVE the mic (not beside it): + // beside-the-mic put it under the thumb and overlapping the mic/+ (device 2026-07-15). + // It carries an explicit width because an unsized absolute child shrink-fits to the + // ~44px button container and wraps/clips "Slide to cancel". Solid surface + emerald + // border so it reads clearly against the chat background (the faint tinted fill was + // barely visible). cancelHint: { position: 'absolute' as const, - right: 52, - width: 132, + bottom: 54, + right: 0, + width: 140, paddingHorizontal: 12, paddingVertical: 6, - backgroundColor: `${colors.primary}40`, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.primary, borderRadius: 12, alignItems: 'center' as const, }, From 819db25f90af4663cff906ef8c8a5be67baa0ad1 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 23:53:33 +0530 Subject: [PATCH 61/66] fix(voice): inline push-to-talk hint + scaled mic (WhatsApp pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the floating corner 'Slide to cancel' pill (barely visible, overlapped the mic/thumb — device 2026-07-15) with the standard push-to-talk affordance, verified on Android + iOS: - While holding, the composer pill shows an inline hint: a recording dot + '‹ Slide to cancel' centred, with the mic to its right where the thumb is (ChatInput.RecordingHint). Always visible, never overlapping the mic. - The mic scales up on press (jumps to 1.4x + gentle breathe) so it's obvious the hold registered. - The corner pill (cancelHint/cancelHintText) is removed from VoiceRecordButton; the PanResponder (hold + slide-to-cancel + release) and the gesture-continuity-through-load fix are unchanged. Extracted RecordingHint + ComposerIconsRow to keep ChatInput's render under the line-count gate. VoiceRecordButton test now guards gesture continuity (mic stays mounted during a cold load) since the hint moved out of that component. --- .../components/VoiceRecordButton.test.tsx | 38 +++------ src/components/ChatInput/ComposerIconsRow.tsx | 67 ++++++++++++++++ src/components/ChatInput/RecordingHint.tsx | 25 ++++++ src/components/ChatInput/index.tsx | 80 ++++++++----------- src/components/ChatInput/styles.ts | 27 +++++++ src/components/VoiceRecordButton/index.tsx | 27 +++---- src/components/VoiceRecordButton/styles.ts | 25 ------ 7 files changed, 171 insertions(+), 118 deletions(-) create mode 100644 src/components/ChatInput/ComposerIconsRow.tsx create mode 100644 src/components/ChatInput/RecordingHint.tsx diff --git a/__tests__/rntl/components/VoiceRecordButton.test.tsx b/__tests__/rntl/components/VoiceRecordButton.test.tsx index 41d0259c2..9885bcd15 100644 --- a/__tests__/rntl/components/VoiceRecordButton.test.tsx +++ b/__tests__/rntl/components/VoiceRecordButton.test.tsx @@ -483,40 +483,20 @@ describe('VoiceRecordButton', () => { }); // ============================================================================ - // Slide-to-cancel hint layout — regression guard + // Gesture continuity through a cold model load // - // The reported bug: the "Slide to cancel" pill "doesn't form properly / is cut off". - // Root cause: styles.cancelHint was position:absolute with NO width, so as an absolute - // child of the ~44px button container it shrink-fit to that width and the label wrapped - // into a clipped multi-line smear. jsdom runs no real Yoga layout, so we can't measure - // pixels — but we CAN assert the two rendered properties that caused vs cure the clip: - // an explicit numeric width, and single-line text. Reverting either flips these red. + // 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('slide-to-cancel hint layout', () => { - const { StyleSheet } = require('react-native'); - - it('gives the cancel pill an explicit width and single-line text while recording (chat mode)', () => { - const { getByText, getByTestId } = render( - - ); - - // The pill carries a real numeric width (before the fix: undefined → shrink-fit → wrap). - const flat = StyleSheet.flatten(getByTestId('voice-cancel-hint').props.style); - expect(typeof flat.width).toBe('number'); - expect(flat.width).toBeGreaterThan(100); // wide enough for "Slide to cancel" at 12px - - // The label can never wrap into a multi-line smear (before the fix: numberOfLines undefined). - expect(getByText('Slide to cancel').props.numberOfLines).toBe(1); - }); - - it('shows the cancel pill during a cold model load too — the hold stays cancellable', () => { - // Chat mode + loading must keep the gesturable wrapper (and its hint) mounted, not swap - // to a bare spinner. If the load-time render reverted to the old early-return, the pill - // 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-cancel-hint')).toBeTruthy(); expect(getByTestId('voice-record-button')).toBeTruthy(); }); }); 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/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/VoiceRecordButton/index.tsx b/src/components/VoiceRecordButton/index.tsx index a4edf1ab0..8bbb604c6 100644 --- a/src/components/VoiceRecordButton/index.tsx +++ b/src/components/VoiceRecordButton/index.tsx @@ -208,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(); @@ -324,24 +327,12 @@ export const VoiceRecordButton: React.FC = ({ } // ── Chat mode: hold-to-record with slide-to-cancel ───────────────────────── - // The finger is down for the whole hold — a cold model load happens DURING the hold. - // So the cancel hint shows while loading too (you can slide to bail before it's ready), - // and the button face swaps between the spinner and the mic WITHOUT the gesturable - // wrapper ever unmounting, so the PanResponder (hold + slide-to-cancel + release) is - // continuous across the load. - const holding = isRecording || buttonState.kind === 'loading'; + // 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 ( - {holding && ( - - Slide to cancel - - )} {isRecording && partialResult && ( {partialResult} diff --git a/src/components/VoiceRecordButton/styles.ts b/src/components/VoiceRecordButton/styles.ts index 0081e55d1..77693b87a 100644 --- a/src/components/VoiceRecordButton/styles.ts +++ b/src/components/VoiceRecordButton/styles.ts @@ -168,31 +168,6 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ backgroundColor: colors.textMuted, transform: [{ rotate: '-45deg' }], }, - // A pill hint shown while holding to record. It floats ABOVE the mic (not beside it): - // beside-the-mic put it under the thumb and overlapping the mic/+ (device 2026-07-15). - // It carries an explicit width because an unsized absolute child shrink-fits to the - // ~44px button container and wraps/clips "Slide to cancel". Solid surface + emerald - // border so it reads clearly against the chat background (the faint tinted fill was - // barely visible). - cancelHint: { - position: 'absolute' as const, - bottom: 54, - right: 0, - width: 140, - paddingHorizontal: 12, - paddingVertical: 6, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.primary, - borderRadius: 12, - alignItems: 'center' as const, - }, - cancelHintText: { - color: colors.primary, - fontSize: 12, - fontWeight: '400' as const, - textAlign: 'center' as const, - }, partialResultContainer: { position: 'absolute' as const, right: 50, From a09fd40951e9b675d54da5fb23386c81cdf26264 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Wed, 15 Jul 2026 23:54:32 +0530 Subject: [PATCH 62/66] test(stt): move the whisper-store fallback coverage to a real render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The useSttDownloadState refactor made TranscriptionModelsTab read the derivation from the single owner (real stores), so the two mockist tab tests that seeded mockWhisperState.downloadProgressById proved nothing (mock of our own store). Deleted per test doctrine; the fallback path (whisper-store progress with no canonical entry → downloading/queued) is now covered by real renders of the picker. --- ...anonicalDownloadProgress.rendered.test.tsx | 20 +++++++++++++++++++ .../TranscriptionModelsTab.test.tsx | 20 +++++-------------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx b/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx index a670b0468..3cb63e324 100644 --- a/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx +++ b/__tests__/integration/models/whisperPickerCanonicalDownloadProgress.rendered.test.tsx @@ -66,4 +66,24 @@ describe('WhisperPickerSheet reflects a canonical-store STT download (device 202 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/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 From a71871b306c5866b08264076a93c615ef03a180c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 16 Jul 2026 00:03:16 +0530 Subject: [PATCH 63/66] fix(downloads): in-flight downloads survive a hard app-kill as failed/retriable cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device 2026-07-15: on iOS, a hard app-kill drops the URLSession task, so an in-flight download had no native row on relaunch AND no in-memory entry (downloadStore isn't persisted) — strandInterrupted Entries had nothing to carry forward, so the download vanished entirely from the Download Manager. The 'never let it silently vanish' rule only ever covered same-process (background→foreground) interruptions, not a cold kill. Fix (shared JS, platform-neutral — no Android/WorkManager touch): persist the IN-FLIGHT set (running/processing, NOT queued — the queue owns its own persistence) on status-change only, never on byte-progress ticks (activeDownloadPersistence). hydrateDownloadStore loads it and strands any entry whose native row is gone as failed/retriable. Android's WorkManager row survives a kill and reappears in the native snapshot, so it's in hydratedKeys and never stranded — zero Android behaviour change. Tests: cold-kill strand (persisted in-flight + empty native → failed card), red-verified by neutralizing the persisted-prior load; plus a guard that a still-live native row is never falsely stranded (the Android-no-regression case). --- App.tsx | 5 ++ .../unit/services/downloadHydration.test.ts | 51 ++++++++++++- src/services/activeDownloadPersistence.ts | 72 +++++++++++++++++++ src/services/downloadHydration.ts | 19 +++-- 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 src/services/activeDownloadPersistence.ts 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__/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/src/services/activeDownloadPersistence.ts b/src/services/activeDownloadPersistence.ts new file mode 100644 index 000000000..1b424c897 --- /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. */ +export 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 091fc341e..f7ec0f3b1 100644 --- a/src/services/downloadHydration.ts +++ b/src/services/downloadHydration.ts @@ -3,6 +3,7 @@ import { useDownloadStore, DownloadEntry, DownloadStatus, ModelType, isActiveSta 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 = { @@ -154,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`, @@ -197,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); } From d3c0278a831a5823d69d7dec728ab01d5f176b42 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 16 Jul 2026 00:08:58 +0530 Subject: [PATCH 64/66] fix(tools): tolerate smart/curly-quote JSON in tool-call arguments (no retry loop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device 2026-07-15: a local model (Qwythos GGUF) emitted tool-call arguments with CURLY double quotes — {“expression”:“7 * 7”} — which strict JSON.parse rejects. parseToolCall then fell back to args={}, so the calculator got an EMPTY call, schema validation failed, and the model retried the same call in a loop until it happened to emit straight quotes ('worked eventually'). Fix: parseToolArguments tries strict JSON first (unchanged for the common case), then retries once with curly double quotes normalized to straight — only the string DELIMITERS, never content. The real expression now reaches the tool on the first try. Tested through the real generateWithToolsImpl path (fake engine boundary emits the curly-quote tool_call), red-verified by reverting the normalization. NOTE: the raw / [Tool Result] text seen in the reply was the model QUOTING its own calls inside its reasoning (rendered as thinking text) — the display stripper already handles real tool-call blocks (messageContent.ts). That's the model narrating, not a strip miss. --- .../unit/services/llmToolGeneration.test.ts | 23 +++++++++++++++++++ src/services/llmToolGeneration.ts | 23 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) 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/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 }; } From 7de2fa8427ae216a8ba3b2aea8c3076ca16f51e5 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 16 Jul 2026 00:23:41 +0530 Subject: [PATCH 65/66] fix(image-gen): hide the placeholder glyph 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. Gate it on !imagePreviewPath so it only shows in the pre-preview 'Generating Image' phase. Rendered test asserts the placeholder tracks imagePreviewPath; red-verified by removing the gate. --- ...ImageProgressIndicatorPlaceholder.test.tsx | 40 +++++++++++++++++++ .../ChatScreen/ChatScreenComponents.tsx | 11 +++-- 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 __tests__/rntl/screens/ImageProgressIndicatorPlaceholder.test.tsx 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/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'} From 23a8afd38fadb853b22dc4bb0d0b9de3347e9f51 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 16 Jul 2026 00:31:12 +0530 Subject: [PATCH 66/66] chore(knip): de-export internal-only helpers + run knip in the pre-push gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's 'architecture' job runs knip as a hard 0-gate, but the local pre-push hook only ran dependency-cruiser — so unused-export failures slipped through to CI twice. Fix both: - de-export deriveSttDownloadState / serializeActiveDownloads / SttDownloadEntry (used only within their own module + exercised through the real render/hydration tests, never imported by symbol); - add 'npm run knip' to the pre-push architecture gate so this class of failure is caught locally. --- .husky/pre-push | 6 ++++++ src/hooks/useSttDownloadState.ts | 4 ++-- src/services/activeDownloadPersistence.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) 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/src/hooks/useSttDownloadState.ts b/src/hooks/useSttDownloadState.ts index 738f59f30..f390f2448 100644 --- a/src/hooks/useSttDownloadState.ts +++ b/src/hooks/useSttDownloadState.ts @@ -16,7 +16,7 @@ import { useWhisperStore } from '../stores/whisperStore'; import { useDownloadStore } from '../stores/downloadStore'; import { isActiveStatus, isQueuedStatus, isDownloadingStatus, type DownloadEntry } from '../utils/downloadStatus'; -export interface SttDownloadEntry { +interface SttDownloadEntry { /** 0..1 transfer progress. */ progress: number; /** In flight (downloading OR queued) — the row is busy and not tappable. */ @@ -38,7 +38,7 @@ const bareWhisperId = (modelId: string): string => * 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"). */ -export function deriveSttDownloadState( +function deriveSttDownloadState( downloads: Record, downloadProgressById: Record, ): { byId: Record; anyDownloading: boolean } { diff --git a/src/services/activeDownloadPersistence.ts b/src/services/activeDownloadPersistence.ts index 1b424c897..d57810126 100644 --- a/src/services/activeDownloadPersistence.ts +++ b/src/services/activeDownloadPersistence.ts @@ -23,7 +23,7 @@ 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. */ -export function serializeActiveDownloads(downloads: Record): DownloadEntry[] { +function serializeActiveDownloads(downloads: Record): DownloadEntry[] { return Object.values(downloads).filter((e) => isActiveStatus(e.status) && !isQueuedStatus(e.status)); }