Skip to content

fix(#510 audit): Load-Anyway everywhere, reclaim-aware gate, single-owner dedup + rendered tests#558

Merged
alichherawalla merged 77 commits into
mainfrom
fix/510-load-anyway-memory
Jul 15, 2026
Merged

fix(#510 audit): Load-Anyway everywhere, reclaim-aware gate, single-owner dedup + rendered tests#558
alichherawalla merged 77 commits into
mainfrom
fix/510-load-anyway-memory

Conversation

@alichherawalla

@alichherawalla alichherawalla commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Closes the confirmed gaps from the deep audit of #510 (refactor/parse-once-boundary, 527 commits) — where several commit-message guarantees ("Load-Anyway-always", "unify reclaim across BOTH paths", "single owner / DRY") were not fully delivered by the code. Every fix is test-first, red-verified at the rendered layer where a user-visible surface exists.

Fixes (each with a rendered red-flow test unless noted)

  • Load-Anyway on every memory refusal: text pre-load gate now throws OverridableMemoryError (was plain Error → dead-end OK alert); TTS speak refusal surfaces the failure card with Load Anyway; STT realtime dictation recovers via the shared free-then-retry owner.
  • Reclaim-aware pre-load gate: llm.ts feeds effectiveAvailableMB (Android LMK reclaim credit) instead of the raw snapshot — a big model that residency admits no longer false-refuses. iOS stays raw (jetsam guard untouched). Device-verify on 12GB Android before merge.
  • Single-owner / DRY: engine-active check → engines.ts owner; projector predicate → canonical isMMProjFile; download-time projector matcher → new pickMmProjForDownload (keeps generic, refuses wrong-arch); text retry → provider contract (fixes iOS lost-downloadId no-op); device-fit hints → fileExceedsBudget; picker RAM → textOverheadMultiplier (matches residency chip); speech-text → prepareMessageForSpeech.
  • send/resend share one post-decision dispatch; queued force-image carries imageMode; remote reasoning uses shared REASONING_DELIMITERS; onboarding litert warning reachable (mockist test deleted); projector-sidecar phantom no longer hydrates as a model.

Honesty

  • Deleted mockist tests (jest.mock of our own stores) rather than repair them.
  • STT-terminal + embedding-failure verified WORKING-AS-DESIGNED (graceful degradation / hard-limit message) — not "fixed" with theater. See docs/GAPS_BACKLOG.md.
  • The fake tests prove the JS decisions; native residue (real OOM/jetsam, ANE spike) is device-verified — checklist provided.
  • An independent adversarial verifier is mutation-testing every test in this branch; results to follow.

Summary by CodeRabbit

  • Bug Fixes
    • iOS downloads now use persistent staging to prevent loss after relaunch and recover from purged temp artifacts.
    • Queued sends/resends now preserve forced image mode and correctly fall back to text when needed.
    • Whisper dictation and TTS “Load Anyway”/failure flows are more resilient under memory pressure and superseded loads.
    • Download retries recover from lost iOS IDs; orphan projector sidecars no longer appear in the UI.
  • Improvements
    • Device-fit checks and LiteRT “Download anyway” warnings are more accurate (with reclaim-aware behavior on Android).
    • Inline reasoning thinking-toggle detection, speech/tool token cleanup, and “slide to cancel” hint rendering are improved.
    • RAM display for loaded models is backend-aware.
  • Tests
    • Expanded integration/rendered coverage across audio, downloads, generation queue behavior, and memory gating.

…se 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.
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.
…STemporaryDirectory

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).
…ble 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.
…uard 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).
…rd, assert recovery

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).
…ny mode

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.
…uses shared REASONING_DELIMITERS

Defect 1: stripControlTokens now strips the XML-style <function=…>…</function>
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 <think>, so a remote model emitting Gemma/Qwen channel
reasoning inline (no reasoning_content field) is no longer misdetected as non-thinking.
…erForTranscription so a blocked load recovers

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.
- stripControlTokens strips <function=…>…</function> via shared markers so the
  extractor (parseXmlStyleToolCall) and display stripper key on ONE grammar (DR7).
- deltaHasThinking probes via shared REASONING_DELIMITERS, not a hardcoded <think>.
Both red→green, test-first.
…th recovery

Route realtime hold-to-talk through ensureWhisperForTranscription (free the
generation model, retry the whisper load) — the SAME owner the Audio-mode file
path already uses. A memory-blocked dictation now recovers instead of dead-ending
with an error haptic + empty composer. One recovery path, no parallel branch.
Red->green, test-first.
…ines.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.
…al 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.
… 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.
…-owner

- generationToolLoop: isLiteRTActive/isUsingRemote route through the engines.ts
  owner (getActiveEngineService/isRemoteTextModelActive), no inline re-derivation.
  Auto-merged cleanly with the parsing fix (different functions); both verified.
- downloadHydration.isMmProjFileName delegates to canonical isMMProjFile (now
  catches projector/clip names).
- huggingface.findMatchingMMProj strict migration NOT shipped: would regress
  generic single-projector vision downloads to text-only. Documented as an
  it.failing + follow-up recommendation (own download-listing matcher in mmproj.ts).
All test-first, red->green; 1739-test blast radius green.
…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.
…dMultiplier so it matches the residency chip
…lDownloadService.retry)

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.
…leExceedsBudget 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).
…Speech (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 <think> block in.
… 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).
…nd 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.
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds extensive integration/unit test coverage for prior #510 audit findings and applies targeted fixes: strict projector-file pairing, reclaim-aware memory budgeting with overridable refusals, consistent RAM display, queued forced-image preservation, centralized engine routing, Whisper readiness recovery with supersession guards, XML tool-call/reasoning stripping, voice UI state handling, and iOS download staging/retry recovery. Also updates release scripts, iOS build naming, backlog docs, and a submodule pointer.

Changes

Release #510 audit fixes and tests

Layer / File(s) Summary
MMProj projector matching strictness
src/services/mmproj.ts, src/services/huggingface.ts, src/services/downloadHydration.ts, __tests__/unit/services/huggingface.test.ts, __tests__/unit/services/huggingfaceProjectorStrictness.test.ts, __tests__/integration/models/mmProjHydrationClassify.test.ts, __tests__/integration/downloads/orphanProjectorPhantomHydration.rendered.redflow.test.tsx
Adds pickMmProjForDownload strict pairing rules and FP16 stem handling; findMatchingMMProj/hydration delegation uses shared logic; tests cover generic/mismatched projector pairing and orphan sidecar filtering.
Memory budget gating and load-anyway overrides
src/screens/ModelDownloadScreen.tsx, src/screens/ModelsScreen/TextModelsTab.tsx, src/screens/ModelsScreen/useTextModels.ts, src/services/llm.ts, src/services/llmSafetyChecks.ts, __tests__/integration/memory/*, __tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts, __tests__/rntl/screens/ModelDownloadScreen.test.tsx
Switches to fileExceedsBudget; llm.ts computes reclaim-aware available memory via effectiveAvailableMB and modelResidencyManager; llmSafetyChecks.ts throws OverridableMemoryError for overridable refusals; tests cover curated LiteRT warnings, load-anyway cards, reclaim-aware gating, and TTS memory refusal.
RAM display multiplier consistency
src/components/ModelSelectorModal/TextTab.tsx, __tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx
Computes backend-aware ramMultiplier from inference backend for the loaded-model RAM label; test asserts picker and residency chip RAM values agree.
Queued image-mode preservation and resend routing
src/services/generationService.ts, src/screens/ChatScreen/useChatGenerationActions.ts, src/screens/ChatScreen/useChatScreen.ts, __tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx, __tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx
QueuedMessage.imageMode is preserved through queue coalescing; shared dispatchResolvedTurn seam unifies send/resend image-model guard and text fallback behavior.
Centralized engine routing for tool loop
src/services/generationToolLoop.ts, __tests__/integration/generation/toolLoopEngineOwnerRouting.test.ts
Tool loop delegates active-engine/remote determination to shared engines.ts helpers and reuses shared XML tool-call markers; test asserts routing matches the centralized owner verdict across LiteRT, llama, and remote scenarios.
Whisper readiness seam and recovery
src/hooks/useWhisperTranscription.ts, src/components/ChatInput/Voice.ts, __tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx, __tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx
Hook accepts injected ensureModelReady callback instead of loading directly; Voice.ts wires ensureWhisper into both file and realtime dictation paths; nonce-based supersession prevents ghost recordings; tests verify blocked load recovery and interrupted start guards.
Reasoning delimiter detection and XML tool-call stripping
src/utils/messageContent.ts, src/stores/remoteModelCapabilities.ts, __tests__/unit/utils/messageContent.test.ts, __tests__/unit/stores/remoteModelCapabilities.test.ts, __tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx, __tests__/integration/audio/audioBubbleSpeechTextSingleSource.redflow.test.tsx
Adds XML tool-call grammar constants/regexes for <function=...> markup; deltaHasThinking scans shared REASONING_DELIMITERS instead of only <think>; tests cover complete/unclosed tool-call stripping, thinking detection, remote Gemma channel delimiters, and TTS speech-text cleanliness.
Voice record button chat-mode UI loading states
src/components/VoiceRecordButton/index.tsx, src/components/VoiceRecordButton/styles.ts, __tests__/rntl/components/VoiceRecordButton.test.tsx
Chat mode refactored to render persistent hold-to-record wrapper with a swappable ChatButtonFace that shows loading/transcribing/ready states; slide-to-cancel hint repositioned to render in-bounds during cold-model loads; tests verify layout and mount persistence.
iOS durable staging and image re-download recovery
ios/DownloadManagerModule.swift, src/screens/ModelsScreen/imageDescriptor.ts, src/screens/ModelsScreen/imageDownloadResume.ts, src/screens/DownloadManagerScreen/retryHandlers.ts, __tests__/integration/downloads/iosImageStagingPurgedRedownloads*.redflow.test.tsx
Stages completed downloads under durable Documents-based .download-staging directory; reconstructs image descriptors from metadata to re-download when staged bytes are irrecoverably lost; includes unit and rendered integration tests.
Text download retry re-issue on lost downloadId
src/screens/ModelsScreen/TextModelsTab.tsx, __tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx
Retry now routes through modelDownloadService.retry(uniformDownloadId(...)) platform-agnostically, removing Android-specific retry handler; test verifies re-issue for hydrated failed entry that lost downloadId.
Backlog and release documentation
docs/GAPS_BACKLOG.md, docs/RELEASE_DEVICE_CHECKLIST_pr558.md
Adds deferred items, device findings (iPhone in-flight image generation lock), remaining partial fixes, and comprehensive device verification checklist.

Estimated code review effort: 4 (Complex) | ~60 minutes

Release infrastructure and packaging

Layer / File(s) Summary
iOS Debug build display name
ios/OffgridMobile.xcodeproj/project.pbxproj, ios/OffgridMobile/Info.plist
Debug build config's display name changed to "Off Grid AI Debug"; Info.plist references the build setting.
Release Slack notifications and submodule bump
scripts/release.sh, scripts/uat.sh, pro
Adds fail-soft Slack release/beta announcements and bumps the pro submodule pointer.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant GenerationService
  participant dispatchResolvedTurn
  participant ImagePipeline
  User->>GenerationService: send turn `#2` with imageMode=force (queued)
  GenerationService->>GenerationService: coalesce queue, preserve force imageMode
  GenerationService->>dispatchResolvedTurn: drain queue with imageMode
  dispatchResolvedTurn->>ImagePipeline: generate image
  ImagePipeline-->>dispatchResolvedTurn: handled: true
Loading
sequenceDiagram
  participant TTSStore
  participant ModelResidencyManager
  participant ModelFailureCard
  TTSStore->>ModelResidencyManager: speak() triggers eviction
  ModelResidencyManager-->>TTSStore: unload() rejects (memory refusal)
  TTSStore->>ModelFailureCard: register TTS failure
  ModelFailureCard-->>TTSStore: render "Load Anyway" affordance
Loading

Possibly related PRs

  • off-grid-ai/OGAM#557: Implements the iOS durable completed-staging download recovery path (completedStagingDirectory, resumeZipDownload/resumeImageDownload, retryIosImageDownload) directly exercised by this PR's new integration tests.
  • off-grid-ai/OGAM#548: Related iOS text download retry when downloadId is missing—this PR updates retry routing and integration test coverage for the same lost downloadId flow.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is off-template and missing required sections like Summary, Type of Change, Screenshots, Checklist, Related Issues, and Additional Notes. Rewrite the PR description using the repository template and add the missing sections, especially Type of Change, screenshots, checklist items, and related issues.
Docstring Coverage ⚠️ Warning Docstring coverage is 56.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: load-anyway behavior, reclaim-aware gating, ownership deduping, and added rendered tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/510-load-anyway-memory

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…s '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.
…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.
…st pins deltaHasThinking

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).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx (1)

126-129: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove unused currentModelPath prop.

The ModelSelectorModal component no longer accepts currentModelPath as a prop (as documented in its source code: "Callers no longer pass it"). You can safely remove it from the test.

♻️ Proposed refactor
-    const picker = rtl.render(React.createElement(ModelSelectorModal, {
-      visible: true, onClose: () => {}, onSelectModel: () => {}, onUnloadModel: () => {}, isLoading: false,
-      currentModelPath: llmService.getLoadedModelPath(),
-    }));
+    const picker = rtl.render(React.createElement(ModelSelectorModal, {
+      visible: true, onClose: () => {}, onSelectModel: () => {}, onUnloadModel: () => {}, isLoading: false,
+    }));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx`
around lines 126 - 129, Remove the unused currentModelPath prop and its
llmService.getLoadedModelPath() value from the ModelSelectorModal invocation in
the picker test, leaving the remaining props unchanged.
src/components/ChatInput/Voice.ts (1)

50-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Memoize ensureWhisper to prevent hook dependency churn.

ensureWhisper is passed into useWhisperTranscription, which uses it as a dependency for its internal startRecording callback. Since it is currently recreated on every render, it will cause startRecording to be unnecessarily redefined on each render.

Wrap it in useCallback to maintain a stable reference.

♻️ Proposed refactor
-  const ensureWhisper = (): Promise<boolean> => ensureWhisperForTranscription({
-    isLoaded: () => whisperService.isModelLoaded(),
-    hasDownloadedModel: () => !!downloadedModelId,
-    loadWhisper: () => useWhisperStore.getState().loadModel(),
-    // keepSelection=true so routing reloads the right generation model after the
-    // transcript decides text-vs-image.
-    freeGenerationModels: () => activeModelService.unloadAllModels(true).then(() => {}),
-  });
+  const ensureWhisper = useCallback((): Promise<boolean> => ensureWhisperForTranscription({
+    isLoaded: () => whisperService.isModelLoaded(),
+    hasDownloadedModel: () => !!downloadedModelId,
+    loadWhisper: () => useWhisperStore.getState().loadModel(),
+    // keepSelection=true so routing reloads the right generation model after the
+    // transcript decides text-vs-image.
+    freeGenerationModels: () => activeModelService.unloadAllModels(true).then(() => {}),
+  }), [downloadedModelId]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ChatInput/Voice.ts` around lines 50 - 57, Wrap the
ensureWhisper function passed to useWhisperTranscription in useCallback so its
reference remains stable across renders. Include the referenced services and
downloadedModelId in the dependency list, preserving the existing
ensureWhisperForTranscription behavior and callbacks.
__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts (1)

98-100: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the visible text to reflect what the user actually sees.

While checking for the test ID works, verifying the explicit visible text better aligns with the coding guidelines to "assert what the user sees" and standard @testing-library/react-native best practices.

Consider querying by the text output directly (as is done in the other integration test).

🛠️ Proposed refactor
-    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
+    expect(view.queryByText('Load Anyway')).not.toBeNull();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts`
around lines 98 - 100, Update the ModelFailureCard assertions in the test to
query for the explicit visible text rendered for the TTS failure card instead of
relying on the model-failure-tts test ID; retain the existing load-anyway
assertion unless the corresponding visible text is already established by the
neighboring integration test.

Source: Coding guidelines

__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx (1)

61-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ensure the store subscription is deterministically cleaned up.

If the test fails on an assertion before stopSync() is called, the subscription will leak into subsequent tests, potentially causing flaky cross-test contamination. Wrap the operations in a try/finally block to guarantee the cleanup always runs.

🧹 Proposed refactor to ensure cleanup
     // 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();
+    try {
+      // 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();
+    } finally {
+      stopSync();
+    }
   }, 30000);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx` around
lines 61 - 83, Wrap the test operations after startLoadPolicySync() in a
try/finally block so stopSync() is always invoked, including when an assertion
or interaction fails. Keep the existing gesture, assertions, and timeout
unchanged, and move cleanup into the finally block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/GAPS_BACKLOG.md`:
- Around line 286-287: Update the log-inspection instruction in GAPS_BACKLOG.md
to use a precise extended regular expression matching complete state-machine
tags such as [IMG-SM] or [MEM-SM], rather than the overbroad [*-SM] character
class. Preserve the surrounding debugging steps and scope.
- Line 282: Replace each em dash in the specified Markdown copy with commas,
colons, or parentheses: docs/GAPS_BACKLOG.md lines 282, 310, and 331, and
docs/RELEASE_DEVICE_CHECKLIST_pr558.md lines 7, 25, and 41. Preserve the wording
and remove any curly quotes if present in these changed sections.
- Around line 293-306: Update the listed deferred entries in GAPS_BACKLOG.md to
reflect their implementation in PR `#558`: remove them or mark them resolved,
including relevant test and commit references where available. Ensure the
onboarding warning, mockist-test removal, queued imageMode preservation, strict
mmproj matching, and reclaim-aware pre-load gating no longer appear as
outstanding work.
- Around line 335-341: The STT hard-failure backlog entry must explicitly define
whether the Load Anyway requirement applies after the final retry fails. Update
the objective/checklist around resolveTranscription and reportModelFailure to
either require an onLoadAnyway card for terminal STT failures or clearly
document the terminal-failure carve-out while retaining recovery behavior.

In `@src/hooks/useWhisperTranscription.ts`:
- Around line 187-200: Add a startNonce ref alongside isCancelled in the hook,
increment it in stopRecording and clearResult to invalidate pending starts, and
capture its value in startRecording before awaiting ensureModelReady(). After
the await and before resetting cancellation state or activating the microphone,
abort if the captured nonce differs from the current nonce.

In `@src/screens/ChatScreen/useChatGenerationActions.ts`:
- Around line 489-508: The text-route setup in the image fallback currently
excludes image requests because of the !shouldGenerateImage condition. Update
the guard in the routing function containing shouldGenerateImage and
ensureTextModelForChat so missing text models trigger ensureTextModelForChat()
whenever execution falls through to the text route, including image requests
without an active image model.

---

Nitpick comments:
In `@__tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts`:
- Around line 98-100: Update the ModelFailureCard assertions in the test to
query for the explicit visible text rendered for the TTS failure card instead of
relying on the model-failure-tts test ID; retain the existing load-anyway
assertion unless the corresponding visible text is already established by the
neighboring integration test.

In `@__tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx`:
- Around line 61-83: Wrap the test operations after startLoadPolicySync() in a
try/finally block so stopSync() is always invoked, including when an assertion
or interaction fails. Keep the existing gesture, assertions, and timeout
unchanged, and move cleanup into the finally block.

In
`@__tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx`:
- Around line 126-129: Remove the unused currentModelPath prop and its
llmService.getLoadedModelPath() value from the ModelSelectorModal invocation in
the picker test, leaving the remaining props unchanged.

In `@src/components/ChatInput/Voice.ts`:
- Around line 50-57: Wrap the ensureWhisper function passed to
useWhisperTranscription in useCallback so its reference remains stable across
renders. Include the referenced services and downloadedModelId in the dependency
list, preserving the existing ensureWhisperForTranscription behavior and
callbacks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 197e6058-3cce-452e-8671-8939e07b5934

📥 Commits

Reviewing files that changed from the base of the PR and between f916294 and 50fc8cf.

📒 Files selected for processing (51)
  • __tests__/integration/audio/audioBubbleSpeechTextSingleSource.redflow.test.tsx
  • __tests__/integration/audio/ttsSpeakMemoryRefusalLoadAnyway.redflow.test.ts
  • __tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx
  • __tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts
  • __tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx
  • __tests__/integration/downloads/orphanProjectorPhantomHydration.rendered.redflow.test.tsx
  • __tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx
  • __tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx
  • __tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx
  • __tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx
  • __tests__/integration/generation/toolLoopEngineOwnerRouting.test.ts
  • __tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx
  • __tests__/integration/memory/loadAnywayCardRendered.redflow.test.tsx
  • __tests__/integration/memory/pickerRamMatchesResidencyChip.rendered.redflow.test.tsx
  • __tests__/integration/memory/textPreloadGateReclaimAware.rendered.redflow.test.tsx
  • __tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx
  • __tests__/integration/models/mmProjHydrationClassify.test.ts
  • __tests__/rntl/components/ModelSelectorModal.test.tsx
  • __tests__/rntl/screens/ModelDownloadScreen.test.tsx
  • __tests__/unit/services/huggingface.test.ts
  • __tests__/unit/services/huggingfaceProjectorStrictness.test.ts
  • __tests__/unit/stores/remoteModelCapabilities.test.ts
  • __tests__/unit/utils/messageContent.test.ts
  • docs/GAPS_BACKLOG.md
  • docs/RELEASE_DEVICE_CHECKLIST_pr558.md
  • ios/DownloadManagerModule.swift
  • ios/OffgridMobile.xcodeproj/project.pbxproj
  • ios/OffgridMobile/Info.plist
  • pro
  • scripts/release.sh
  • scripts/uat.sh
  • src/components/ChatInput/Voice.ts
  • src/components/ModelSelectorModal/TextTab.tsx
  • src/hooks/useWhisperTranscription.ts
  • src/screens/ChatScreen/useChatGenerationActions.ts
  • src/screens/ChatScreen/useChatScreen.ts
  • src/screens/DownloadManagerScreen/retryHandlers.ts
  • src/screens/ModelDownloadScreen.tsx
  • src/screens/ModelsScreen/TextModelsTab.tsx
  • src/screens/ModelsScreen/imageDescriptor.ts
  • src/screens/ModelsScreen/imageDownloadResume.ts
  • src/screens/ModelsScreen/useTextModels.ts
  • src/services/downloadHydration.ts
  • src/services/generationService.ts
  • src/services/generationToolLoop.ts
  • src/services/huggingface.ts
  • src/services/llm.ts
  • src/services/llmSafetyChecks.ts
  • src/services/mmproj.ts
  • src/stores/remoteModelCapabilities.ts
  • src/utils/messageContent.ts
💤 Files with no reviewable changes (1)
  • tests/rntl/components/ModelSelectorModal.test.tsx

Comment thread docs/GAPS_BACKLOG.md

## 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace em dashes in the changed Markdown copy.

Use commas, colons, or parentheses instead. As per coding guidelines, content must avoid em dashes and curly quotes.

  • docs/GAPS_BACKLOG.md#L282-L282: replace the em dash introducing the cosmetic-label clause.
  • docs/GAPS_BACKLOG.md#L310-L310: replace the em dash in the device-finding heading.
  • docs/GAPS_BACKLOG.md#L331-L331: replace the em dash in the partial-fixes heading.
  • docs/RELEASE_DEVICE_CHECKLIST_pr558.md#L7-L7: replace the em dash in the HIGH-priority heading.
  • docs/RELEASE_DEVICE_CHECKLIST_pr558.md#L25-L25: replace the em dash in the MEDIUM heading.
  • docs/RELEASE_DEVICE_CHECKLIST_pr558.md#L41-L41: replace the em dash in the LOW heading.
📍 Affects 2 files
  • docs/GAPS_BACKLOG.md#L282-L282 (this comment)
  • docs/GAPS_BACKLOG.md#L310-L310
  • docs/GAPS_BACKLOG.md#L331-L331
  • docs/RELEASE_DEVICE_CHECKLIST_pr558.md#L7-L7
  • docs/RELEASE_DEVICE_CHECKLIST_pr558.md#L25-L25
  • docs/RELEASE_DEVICE_CHECKLIST_pr558.md#L41-L41
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/GAPS_BACKLOG.md` at line 282, Replace each em dash in the specified
Markdown copy with commas, colons, or parentheses: docs/GAPS_BACKLOG.md lines
282, 310, and 331, and docs/RELEASE_DEVICE_CHECKLIST_pr558.md lines 7, 25, and
41. Preserve the wording and remove any curly quotes if present in these changed
sections.

Source: Coding guidelines

Comment thread docs/GAPS_BACKLOG.md
Comment on lines +286 to +287
`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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a precise regex for state-machine traces.

[*-SM] is a character class, not a pattern for tags such as [IMG-SM] or [MEM-SM]; it can overmatch unrelated characters. Use a precise command, for example grep -E '\[[A-Z]+-SM\]'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/GAPS_BACKLOG.md` around lines 286 - 287, Update the log-inspection
instruction in GAPS_BACKLOG.md to use a precise extended regular expression
matching complete state-machine tags such as [IMG-SM] or [MEM-SM], rather than
the overbroad [*-SM] character class. Preserve the surrounding debugging steps
and scope.

Comment thread docs/GAPS_BACKLOG.md
Comment on lines +293 to +306
- **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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reconcile these deferred items with PR #558.

The supplied PR objectives state that the onboarding warning, mockist-test removal, queued imageMode preservation, strict mmproj matching, and reclaim-aware pre-load gating are implemented in this PR. Leaving them marked as deferred makes the backlog and release status contradictory; remove them or mark them resolved with the relevant test/commit references.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/GAPS_BACKLOG.md` around lines 293 - 306, Update the listed deferred
entries in GAPS_BACKLOG.md to reflect their implementation in PR `#558`: remove
them or mark them resolved, including relevant test and commit references where
available. Ensure the onboarding warning, mockist-test removal, queued imageMode
preservation, strict mmproj matching, and reclaim-aware pre-load gating no
longer appear as outstanding work.

Comment thread docs/GAPS_BACKLOG.md
Comment thread src/hooks/useWhisperTranscription.ts
Comment on lines +489 to +508
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 };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file and inspect the relevant range.
ast-grep outline src/screens/ChatScreen/useChatGenerationActions.ts --view expanded || true

echo '--- lines around dispatchResolvedTurn ---'
sed -n '440,535p' src/screens/ChatScreen/useChatGenerationActions.ts

echo '--- callers around dispatchGenerationFn / regenerateResponseFn ---'
sed -n '520,640p' src/screens/ChatScreen/useChatGenerationActions.ts

echo '--- search for related tests ---'
rg -n "resendImageTurnFallsBackToText|ensureTextModelForChat|hasTextModel|activeImageModel|shouldGenerateImage" src test __tests__ . || true

Repository: off-grid-ai/OGAM

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant test file(s) if found by the search.
for f in $(rg -l "resendImageTurnFallsBackToText|ensureTextModelForChat|hasTextModel" src test __tests__ . 2>/dev/null | head -n 20); do
  echo "--- FILE: $f ---"
  wc -l "$f"
  sed -n '1,260p' "$f"
done

Repository: off-grid-ai/OGAM

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Fallback: inspect the file directly if the previous AST map is unavailable.
sed -n '440,640p' src/screens/ChatScreen/useChatGenerationActions.ts

echo '--- test file candidates mentioning the route ---'
rg -n "resendImageTurnFallsBackToText|regenerateResponseFn|dispatchGenerationFn|ensureTextModelForChat|onTextModelUnavailable" src test __tests__ .

Repository: off-grid-ai/OGAM

Length of output: 35326


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the exact test file around the fallback case, then inspect the surrounding block.
test_file=$(rg -l "resendImageTurnFallsBackToText" src test __tests__ . | head -n 1 || true)
if [ -n "${test_file:-}" ]; then
  echo "--- FILE: $test_file ---"
  rg -n -C 8 "resendImageTurnFallsBackToText|ensureTextModelForChat|hasTextModel|activeImageModel" "$test_file"
fi

Repository: off-grid-ai/OGAM

Length of output: 199


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrow structural check: does the text-provisioning guard depend only on shouldGenerateImage?
python3 - <<'PY'
from pathlib import Path
p = Path('src/screens/ChatScreen/useChatGenerationActions.ts')
text = p.read_text()
needle = "if (!shouldGenerateImage && deps.hasTextModel === false && !deps.activeModelInfo?.isRemote)"
print("guard_present:", needle in text)
print("count_shouldGenerateImage:", text.count("shouldGenerateImage"))
PY

Repository: off-grid-ai/OGAM

Length of output: 203


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the exact surrounding lines around the alleged bug to inspect the control flow.
nl -ba src/screens/ChatScreen/useChatGenerationActions.ts | sed -n '486,510p'

Repository: off-grid-ai/OGAM

Length of output: 194


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo 'Inspecting target file and nearby tests'
nl -ba src/screens/ChatScreen/useChatGenerationActions.ts | sed -n '450,510p'

echo '---'
rg -n -C 6 "resendImageTurnFallsBackToText|ensureTextModelForChat|onTextModelUnavailable|hasTextModel|activeImageModel" src test __tests__ . || true

Repository: off-grid-ai/OGAM

Length of output: 234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('__tests__/unit/hooks/useChatGenerationActions.test.ts')
lines = p.read_text().splitlines()
for start, end, label in [
    (650, 710, 'image fallback / ensureTextModelForChat'),
    (790, 840, 'dispatchGenerationFn text routing'),
    (500, 545, 'regenerateResponseFn recordedKind=image'),
]:
    print(f'--- {label} ({start}-{end}) ---')
    for i in range(start, min(end, len(lines)) + 1):
        print(f'{i:4d}: {lines[i-1]}')
PY

Repository: off-grid-ai/OGAM

Length of output: 9896


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for path, spans in {
    'src/screens/ChatScreen/useChatScreen.ts': [(190, 240), (250, 270)],
    'src/screens/ChatScreen/useChatGenerationActions.ts': [(489, 506), (582, 604)],
}.items():
    print(f'=== {path} ===')
    lines = Path(path).read_text().splitlines()
    for start, end in spans:
        print(f'--- {start}-{end} ---')
        for i in range(start, min(end, len(lines)) + 1):
            print(f'{i:4d}: {lines[i-1]}')
PY

Repository: off-grid-ai/OGAM

Length of output: 8078


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Quick deterministic check of the bug condition from the source itself:
python3 - <<'PY'
from pathlib import Path
text = Path('src/screens/ChatScreen/useChatGenerationActions.ts').read_text()
print("provision_guard_requires_not_image =", "!shouldGenerateImage && deps.hasTextModel === false" in text)
print("fallback_note_present =", "no image model is loaded" in text)
PY

Repository: off-grid-ai/OGAM

Length of output: 225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('src/screens/ChatScreen/useChatGenerationActions.ts')
lines = p.read_text().splitlines()
for start, end, label in [
    (351, 455, 'startGenerationFn'),
    (274, 350, 'generateWithCompactionRetry / resolveToolsAndPrompt'),
]:
    print(f'--- {label} ({start}-{end}) ---')
    for i in range(start, min(end, len(lines)) + 1):
        print(f'{i:4d}: {lines[i-1]}')
PY

Repository: off-grid-ai/OGAM

Length of output: 12932


Image fallback still skips text-model provisioning. When kind === 'image' but activeImageModel is missing, this seam falls through to the text route with shouldGenerateImage still true, so deps.hasTextModel === false never reaches ensureTextModelForChat(). Both send and resend then go straight into the text executor without opening the selector or stashing a pending turn. Drop the !shouldGenerateImage guard so the text-route setup runs whenever this function resolves to text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/screens/ChatScreen/useChatGenerationActions.ts` around lines 489 - 508,
The text-route setup in the image fallback currently excludes image requests
because of the !shouldGenerateImage condition. Update the guard in the routing
function containing shouldGenerateImage and ensureTextModelForChat so missing
text models trigger ensureTextModelForChat() whenever execution falls through to
the text route, including image requests without an active image model.

…delRam owner (was a 1.8x copy)

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.
…eImageModelRam owner (was a 1.8x copy)"

This reverts commit bc33700.
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).
…ills the >=/> mutant

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx`:
- Around line 94-103: Replace the jest.doMock for huggingFaceService with a
network-boundary stub, such as intercepting fetch responses for the Hugging Face
API requests used by the test. Return equivalent model, file, and download data
through those responses while exercising the real huggingFaceService
implementation and preserving the existing assertions.

In `@docs/GAPS_BACKLOG.md`:
- Line 351: Update the headings in the `#510` audit section and the other
referenced headings to remove em dashes, replacing them with acceptable
punctuation or spacing while preserving the headings’ meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c283a2a-5b82-4da4-b663-770011f663b7

📥 Commits

Reviewing files that changed from the base of the PR and between 50fc8cf and ab3cc0d.

📒 Files selected for processing (6)
  • __tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx
  • __tests__/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx
  • __tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx
  • docs/GAPS_BACKLOG.md
  • src/screens/ChatScreen/useChatGenerationActions.ts
  • src/utils/messageContent.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx
  • tests/integration/generation/remoteGemmaChannelThinkingToggle.rendered.test.tsx

Comment on lines +94 to +103
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`),
},
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Do not mock application code in integration tests.

As per coding guidelines, integration tests must "fake only device boundaries and never mock application code." By mocking huggingFaceService, this test bypasses the real application logic.

Please remove jest.doMock and instead stub the network boundary (e.g., by intercepting fetch to return the appropriate JSON responses for the Hugging Face API) so the actual huggingFaceService logic is fully exercised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx`
around lines 94 - 103, Replace the jest.doMock for huggingFaceService with a
network-boundary stub, such as intercepting fetch responses for the Hugging Face
API requests used by the test. Return equivalent model, file, and download data
through those responses while exercising the real huggingFaceService
implementation and preserving the existing assertions.

Source: Coding guidelines

Comment thread docs/GAPS_BACKLOG.md

---

## #510 audit — STT-terminal + embedding: VERIFIED WORKING-AS-DESIGNED (not bugs, do NOT "fix")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the em dashes from these headings.

As per coding guidelines, Markdown content must avoid em dashes.

Also applies to: 364-364, 371-371

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/GAPS_BACKLOG.md` at line 351, Update the headings in the `#510` audit
section and the other referenced headings to remove em dashes, replacing them
with acceptable punctuation or spacing while preserving the headings’ meaning.

Source: Coding guidelines

…ip the cancel pill

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.
… starts no ghost (#558)

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).
…shows during load)

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/VoiceRecordButton/styles.ts`:
- Around line 176-191: Update the cancelHint and cancelHintText styles to
replace hardcoded spacing and typography values with the existing SPACING and
TYPOGRAPHY design tokens. Map right and paddingHorizontal/paddingVertical in
cancelHint, and fontSize in cancelHintText, while preserving the current layout
and text appearance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e61254e-cc78-4a27-98a5-430dfb4579a7

📥 Commits

Reviewing files that changed from the base of the PR and between ab3cc0d and b27913c.

📒 Files selected for processing (4)
  • __tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx
  • src/components/VoiceRecordButton/index.tsx
  • src/components/VoiceRecordButton/styles.ts
  • src/hooks/useWhisperTranscription.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/hooks/useWhisperTranscription.ts

Comment on lines 176 to 191
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,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Replace hardcoded typography and spacing values with design system tokens.

As per coding guidelines, UI changes must avoid hardcoded typography, margins, or padding, and instead use the provided TYPOGRAPHY and SPACING tokens. The newly added cancelHint and cancelHintText styles use hardcoded values (e.g., right: 52, paddingHorizontal: 12, paddingVertical: 6, fontSize: 12).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/VoiceRecordButton/styles.ts` around lines 176 - 191, Update
the cancelHint and cancelHintText styles to replace hardcoded spacing and
typography values with the existing SPACING and TYPOGRAPHY design tokens. Map
right and paddingHorizontal/paddingVertical in cancelHint, and fontSize in
cancelHintText, while preserving the current layout and text appearance.

Source: Coding guidelines

…ingle owner)

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.
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.
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.
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.
…/retriable cards

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).
…o retry loop)

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_call>…</tool_call> / [Tool Result] text seen in the reply was the model QUOTING
its own calls inside its <think> 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.
Device 2026-07-16: during 'Refining Image' the real preview thumbnail is shown, but the placeholder
<Icon name="image"> 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.
…sh gate

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.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
D Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

@alichherawalla alichherawalla merged commit a50f0f2 into main Jul 15, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant