Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
6477754
chore(release): announce beta + prod releases in Slack from the relea…
alichherawalla Jul 15, 2026
57913c5
chore(release): tag production Slack announcements as `stable`
alichherawalla Jul 15, 2026
7c369ae
fix(downloads): stage completed iOS downloads in a durable dir, not N…
alichherawalla Jul 15, 2026
4fe2e81
fix(downloads): re-download when completed image bytes are unrecovera…
alichherawalla Jul 15, 2026
7fb6b06
refactor(downloads): DRY the image re-download descriptor + run the g…
alichherawalla Jul 15, 2026
07e66a4
test(downloads): rendered red-flow — tap Retry on the failed image ca…
alichherawalla Jul 15, 2026
42c76c6
Merge remote-tracking branch 'origin/chore/slack-release-notify' into…
alichherawalla Jul 15, 2026
6146638
fix(memory): text load memory-refusal is overridable (Load Anyway), a…
alichherawalla Jul 15, 2026
c8a29b7
test(audio): red flow — realtime hold-to-talk dictation must recover …
alichherawalla Jul 15, 2026
233e9a7
test: red — stripper leaks <function=…> markup; deltaHasThinking miss…
alichherawalla Jul 15, 2026
ec1fcfb
fix: stripper strips <function=…> tool-call markup; deltaHasThinking …
alichherawalla Jul 15, 2026
550ad8c
test(generation): guard tool-loop engine routing against the engines.…
alichherawalla Jul 15, 2026
91077e5
fix(audio): route realtime hold-to-talk dictation through ensureWhisp…
alichherawalla Jul 15, 2026
2374c41
Merge parsing fixes: tool-call markup leak + remote reasoning misdetect
alichherawalla Jul 15, 2026
3332d59
Merge STT Load-Anyway recovery: realtime dictation shares the file-pa…
alichherawalla Jul 15, 2026
474cbf9
fix(generation): route tool-loop engine/remote checks through the eng…
alichherawalla Jul 15, 2026
aeeaa78
test(models): projector/clip download sidecars must classify via cano…
alichherawalla Jul 15, 2026
f065b29
fix(models): route downloadHydration.isMmProjFileName through canonic…
alichherawalla Jul 15, 2026
45e8549
test(models): document that download-time projector matching is still…
alichherawalla Jul 15, 2026
a6c2acc
Merge service-layer dedup: engine-active + projector-predicate single…
alichherawalla Jul 15, 2026
62a0471
test(models): red-flow — picker RAM label diverges from residency chi…
alichherawalla Jul 15, 2026
4ac158a
test(models): red — iOS text retry on a lost-downloadId failed card i…
alichherawalla Jul 15, 2026
10d26ad
test(audio): TTS speak-path memory refusal surfaces Load Anyway (red-…
alichherawalla Jul 15, 2026
741e7a2
fix(models): route picker RAM label through backend-aware textOverhea…
alichherawalla Jul 15, 2026
8d8d09e
fix(models): route text download retry through the single owner (mode…
alichherawalla Jul 15, 2026
ddaf905
test(models): pin the detail Available-Files fit hint to the owned fi…
alichherawalla Jul 15, 2026
748c547
test(audio): audio bubble Play speaks single-source prepareMessageFor…
alichherawalla Jul 15, 2026
3d069ab
fix(models): route all text device-fit hints through the single owner…
alichherawalla Jul 15, 2026
f851aea
test(generation): red-flow — resend of an image turn diverges from se…
alichherawalla Jul 15, 2026
8d40d4e
fix(generation): share one post-decision dispatch seam between send a…
alichherawalla Jul 15, 2026
be38296
test(onboarding): red — over-budget curated LiteRT E4B is filtered ou…
alichherawalla Jul 15, 2026
f8a8ef8
test(onboarding): revert unlanded LiteRT over-budget red test (Defect…
alichherawalla Jul 15, 2026
48d23ee
test(memory): rendered red-flow for text Load-Anyway + kept [MEM-SM] …
alichherawalla Jul 15, 2026
46c1c3a
Merge Models-UI single-owner: retry via provider + fit via fileExceed…
alichherawalla Jul 15, 2026
4351b2b
Merge chat send/resend + RAM parity (rendered, red-verified)
alichherawalla Jul 15, 2026
7b138f5
test(models): delete mockist ModelSelectorModal test (jest.mocks our …
alichherawalla Jul 15, 2026
6a64c0b
test(memory): rendered red-flow for text pre-load gate reclaim-aware …
alichherawalla Jul 15, 2026
b3ecf30
fix(memory): feed the text pre-load gate reclaim-aware available RAM
alichherawalla Jul 15, 2026
2a2ec8f
Merge reclaim-aware pre-load gate: llm.ts feeds effectiveAvailableMB,…
alichherawalla Jul 15, 2026
3d66c99
test(memory): re-pin crux Load-Anyway to iOS so it coexists with the …
alichherawalla Jul 15, 2026
3daf83f
test(downloads): rendered red-flow — orphaned projector sidecar must …
alichherawalla Jul 15, 2026
175123f
test(audio): assert the RENDERED Load Anyway button on the tts failur…
alichherawalla Jul 15, 2026
d34d6f6
test(remote): Gemma-channel inline reasoning surfaces the Thinking to…
alichherawalla Jul 15, 2026
b5ce91a
chore: bump pro pointer to fix/tts-load-anyway; log audit follow-ups …
alichherawalla Jul 15, 2026
797419b
docs(gaps): log STT-terminal/embedding partial fixes + picker display…
alichherawalla Jul 15, 2026
2e567df
test(#510): red — queued force-image send loses force flag, generates…
alichherawalla Jul 15, 2026
ac0e915
test(vision): download-time projector strictness — E4B projector must…
alichherawalla Jul 15, 2026
4b34e73
fix(vision): route download-time projector matching through mmproj ru…
alichherawalla Jul 15, 2026
10d1359
fix(#510): carry imageMode through the queue so a queued force-image …
alichherawalla Jul 15, 2026
63633c2
test(model-download): delete mockist LiteRT RAM-filter test (#510)
alichherawalla Jul 15, 2026
d2f4629
test(model-download): rendered red-flow for over-budget-but-warnable …
alichherawalla Jul 15, 2026
a73346c
fix(model-download): offer over-budget-but-warnable curated LiteRT mo…
alichherawalla Jul 15, 2026
d803b73
Merge branch 'worktree-agent-aa86afbafd635a084' into fix/510-load-any…
alichherawalla Jul 15, 2026
ac54d9f
Merge onboarding litert warning reachable + delete mockist ModelDownl…
alichherawalla Jul 15, 2026
9d3d4ce
Merge queued force-image mode carry
alichherawalla Jul 15, 2026
d920495
Merge remote-tracking branch 'origin/fix/ios-download-durable-staging…
alichherawalla Jul 15, 2026
7ee6fac
chore(ios): Debug builds display as 'Off Grid AI Debug' (Release stay…
alichherawalla Jul 15, 2026
50fc8cf
docs: device checklist for PR #558 (native residue: reclaim/OOM, load…
alichherawalla Jul 15, 2026
bd34f2e
chore(knip): de-export 4 now-internal symbols (dispatchResolvedTurn, …
alichherawalla Jul 15, 2026
6cd5346
test(reasoning): add negative discriminator so the Thinking-toggle te…
alichherawalla Jul 15, 2026
bc33700
fix(#26): image RAM hint in ModelPickerSheet uses the estimateImageMo…
alichherawalla Jul 15, 2026
b7c5a3d
Revert "fix(#26): image RAM hint in ModelPickerSheet uses the estimat…
alichherawalla Jul 15, 2026
ab5b496
test(#510): cover the queued multi-message coalesce force-merge (M16)
alichherawalla Jul 15, 2026
5b8a409
docs(gaps): log M5a marginal budget-boundary coverage gap
alichherawalla Jul 15, 2026
ab3cc0d
test(memory): pin fileExceedsBudget's exact-budget boundary (M5a) — k…
alichherawalla Jul 15, 2026
266fd34
fix(voice): keep the mic gesturable through a cold model load + un-cl…
alichherawalla Jul 15, 2026
b27913c
fix(whisper): nonce-guard the superseded start so release-during-load…
alichherawalla Jul 15, 2026
cd7fcda
test(voice): guard the cancel-pill layout fix (width + single-line + …
alichherawalla Jul 15, 2026
cec0db3
docs(checklist): add the voice/mic gesture fixes (slide-to-cancel pil…
alichherawalla Jul 15, 2026
d5f77b6
fix(stt): Home Speech picker shows canonical STT download progress (s…
alichherawalla Jul 15, 2026
76bd09f
fix(voice): make the slide-to-cancel hint visible and clear of the mic
alichherawalla Jul 15, 2026
819db25
fix(voice): inline push-to-talk hint + scaled mic (WhatsApp pattern)
alichherawalla Jul 15, 2026
a09fd40
test(stt): move the whisper-store fallback coverage to a real render
alichherawalla Jul 15, 2026
a71871b
fix(downloads): in-flight downloads survive a hard app-kill as failed…
alichherawalla Jul 15, 2026
d3c0278
fix(tools): tolerate smart/curly-quote JSON in tool-call arguments (n…
alichherawalla Jul 15, 2026
7de2fa8
fix(image-gen): hide the placeholder glyph once the live preview renders
alichherawalla Jul 15, 2026
23a8afd
chore(knip): de-export internal-only helpers + run knip in the pre-pu…
alichherawalla Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
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';
Expand Down Expand Up @@ -153,6 +154,10 @@
*/
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);
});
Expand Down Expand Up @@ -195,7 +200,7 @@
})().catch((error) => {
logger.error('[App] Download-state recovery failed:', error);
});
}, [setDownloadedModels, setDownloadedImageModels]);

Check warning on line 203 in App.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useCallback has a missing dependency: 'reattachTextDownloadRecovery'. Either include it or remove the dependency array

const initializeApp = useCallback(async () => {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* <think>…</think> 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 <think> 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 = '<think>internal reasoning the user must not hear</think>\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: <speech text> }).
const { getByLabelText } = render(
<AudioMessageBubble messageId="m1" audioPath="" waveformData={[]} durationSeconds={0} transcript={transcript} />,
);

fireEvent.press(getByLabelText('Play'));

// The bubble must speak the SINGLE-SOURCE output. RED on HEAD: it dispatched stripMarkdownForSpeech only,
// so the <think> block is still present → ≠ prepareMessageForSpeech(transcript).
expect(dispatchedText).toBe(prepareMessageForSpeech(transcript));
expect(dispatchedText).not.toMatch(/internal reasoning/); // the reasoning block must be gone
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* RED-FLOW (integration) — a TTS memory refusal on the SPEAK path must surface a dismissible failure
* card with a "Load Anyway" affordance, never a silent dead-end.
*
* DEVICE: in voice/chat mode, tapping the speaker triggers speakMessage → initializeEngine({override:true}).
* The override load evicts every evictable resident to free maximum RAM; if a native unload REJECTS, the
* residency manager returns { fits: false } even under override. Pre-fix, ttsStore.initializeEngine threw a
* PLAIN Error there, caught it into `state.error` (static red text on the Settings panel only), and the
* speak path then bailed silently (dispatchPlayback {t:'ended'}) — the speaker icon just stopped. No alert,
* no card, no Load Anyway. This violates "any memory refusal on any model type offers Load Anyway".
*
* This drives the REAL store speak() → REAL ttsPlayback.speakMessage → REAL initializeEngine → REAL
* modelResidencyManager over the device-memory harness. Fakes ONLY at the device boundary: a fake TTS
* engine (idle + downloaded), and a resident text model whose native unload() REJECTS (the reason override
* can't free room). Assert the USER-FACING outcome: a `tts` failure lands in the real failure store, marked
* overridable with an onLoadAnyway (what the ModelFailureCard renders as "Load Anyway").
*
* RED on HEAD: the refusal throws a plain Error → isOverridableMemoryError === false → the failure store is
* empty (speak bailed silently); no card, no Load Anyway.
*/
import { modelResidencyManager } from '../../../src/services/modelResidency';
import { setDeviceMemory, resetDeviceMemory } from '../../harness/deviceMemory';
import { ttsRegistry } from '../../../pro/audio/engine';
import { useTTSStore } from '../../../pro/audio/ttsStore';
import { useModelFailureStore } from '../../../src/stores/modelFailureStore';

// Device boundary: a minimal TTS engine that is idle + fully downloaded, and whose initialize() would
// succeed IF residency let it. The refusal happens in residency (before initialize), so initialize is
// never reached in the refusal case.
function makeFakeEngine() {
let phase: 'idle' | 'ready' = 'idle';
return {
id: 'faketts',
displayName: 'Fake TTS',
capabilities: { peakRamMB: 400, generateAndSave: false },
getPhase: () => phase,
isSupported: () => true,
isFullyDownloaded: () => true,
getRequiredAssets: () => [{ id: 'model', sizeBytes: 400 * 1024 * 1024 }],
checkAssetStatus: async () => [],
getOverallDownloadProgress: () => 1,
getVoices: () => [],
getActiveVoice: () => null,
setVoice: async () => {},
getLastDownloadError: () => null,
getBridgeComponent: () => null,
hydrateDownloaded: () => {},
on: () => () => {},
off: () => {},
once: () => () => {},
initialize: async () => { phase = 'ready'; },
release: async () => { phase = 'idle'; },
destroy: async () => { phase = 'idle'; },
speak: async () => {},
stop: () => {},
pause: () => {},
resume: () => {},
setSpeed: () => {},
} as unknown as never;
}

describe('TTS speak-path memory refusal is overridable (Load Anyway) — red-flow', () => {
afterEach(() => {
resetDeviceMemory();
useModelFailureStore.getState().clear();
});

it('surfaces a dismissible tts failure card with Load Anyway (not a silent bail)', async () => {
// 12GB device. The voice model is a sidecar; the evict-everything force (override → singleModel)
// evicts every evictable PEER sidecar to free maximum RAM before loading.
setDeviceMemory({ platform: 'ios', totalGB: 12, availGB: 0.2, policy: 'balanced' });
useModelFailureStore.getState().clear();

// A resident peer sidecar (whisper) whose NATIVE unload REJECTS — the override force tries to evict it
// to free room, the native unload fails, so makeRoomFor returns { fits:false } even under override.
// This is the reachable device refusal on the evict-everything speak-turn force.
modelResidencyManager.register(
{ key: 'whisper', type: 'whisper' as never, sizeMB: 1500, canEvict: () => true },
async () => { throw new Error('native unload rejected'); },
1,
);

ttsRegistry.register('faketts', makeFakeEngine);
await useTTSStore.getState().setEngine('faketts');

// The user taps the speaker on an assistant message (the real store speak action).
await useTTSStore.getState().speak('hello there', 'msg-1');

// USER-FACING artifact: mount the REAL ModelFailureCard (no props, no mocks — it reads the real
// failure store the speak refusal just populated) and assert the user SEES the "Load Anyway" button
// on the tts failure card, not a silent bail. RED on HEAD: plain Error → no tts failure in the store
// → the card renders nothing → the button is absent.
/* eslint-disable @typescript-eslint/no-var-requires */
const React = require('react');
const { render } = require('@testing-library/react-native');
const { ModelFailureCard } = require('../../../src/components/ModelFailureCard');
/* eslint-enable @typescript-eslint/no-var-requires */
const view = render(React.createElement(ModelFailureCard));
expect(view.queryByTestId('model-failure-load-anyway-tts')).not.toBeNull();
expect(view.queryByTestId('model-failure-tts')).not.toBeNull(); // the tts failure card itself is on screen
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* DEVICE-CONFIRMED (rendered UI) — the realtime hold-to-talk dictation path must RECOVER from a memory
* refusal, not dead-end. Product rule: ANY memory refusal on ANY model type offers recovery — never a
* silent dead-end.
*
* The defect: on a tight device a heavier generation (text) model owns RAM, so the whisper sidecar's
* load is BLOCKED by the single-model rule (makeRoomFor → fits=false → loadModel returns 'blocked', it
* does NOT throw). The realtime path called whisperStore.loadModel() DIRECTLY: a 'blocked' return is not
* a throw, so it fell through to whisperService.startRealtimeTranscription, which throws 'No Whisper model
* loaded' → the mic press failed with an error, no transcript, no recovery. The Audio-mode file path
* already recovers via ensureWhisperForTranscription (free the generation model, retry loadWhisper); the
* realtime path did not.
*
* Real stack: mount the REAL ChatScreen on a NON-audio (llama) model — so chat-mode hold-to-talk takes the
* whisper REALTIME path (Voice.ts startWhisperRecording), not the direct-audio or file path. The text model
* is resident, whisper is DOWNLOADED-not-loaded, and the budget is pinned tight so the whisper sidecar
* cannot co-reside → the first load blocks. Only device leaves are faked (whisper, llama, RAM, fs).
*
* Arrive via the REAL hold-to-talk gesture (tapMic grant → releaseMic release), then the (faked) realtime
* stream delivers the utterance. The USER-FACING outcome under test: the transcript lands in the composer
* (dictation recovered) — proving blocked → free generation model → retry → whisper loaded → transcript.
*
* RED before the fix: the realtime path called loadModel() directly; 'blocked' fell through to
* startRealtimeTranscription → 'No Whisper model loaded' → error state → the composer stays EMPTY.
* GREEN: routed through ensureWhisperForTranscription (the same recovery the file path uses) → transcript
* in the input.
*
* The discriminator: on a tight device, WITHOUT the free→retry the load stays blocked → no resident
* whisper → startRealtimeTranscription throws → the composer never receives text.
*/
import { setupChatScreen } from '../../harness/chatHarness';

jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
useRoute: () => require('../../harness/chatHarness').routeHolder,
useFocusEffect: () => {}, useIsFocused: () => true,
}));

describe('realtime hold-to-talk dictation recovers when whisper load is blocked (free→retry) — device', () => {
it('frees the generation model, loads whisper, and the transcript lands in the composer', async () => {
const h = await setupChatScreen({ engine: 'llama', platform: 'android', whisper: true });
const { useWhisperStore } = require('../../../src/stores/whisperStore');
const { modelResidencyManager } = require('../../../src/services/modelResidency');

// DOWNLOAD-ONLY whisper: the completed-download boundary artifact (file on disk + downloadedModelId) with
// NO resident load — so the realtime turn's first load attempt runs for real (and blocks on the tight budget).
const docs = h.boundary.fs!.DocumentDirectoryPath;
h.boundary.fs!.seedFile(`${docs}/whisper-models/ggml-tiny.en.bin`, 75 * 1024 * 1024);
await useWhisperStore.getState().refreshPresentModels();
useWhisperStore.setState({ downloadedModelId: 'tiny.en', isModelLoaded: false });

// Pin the budget tight: the resident text model fills it, so the whisper sidecar cannot co-reside →
// makeRoomFor returns fits=false → whisperStore.loadModel returns 'blocked'.
modelResidencyManager.setBudgetOverrideMB(700);

h.render();
const view = h.view!;

// Precondition (anti-false-green): the composer is empty.
const inputBefore = await h.rtl.waitFor(() => view.getByTestId('chat-input'));
expect(inputBefore.props.value ?? '').toBe('');

// REAL chat-mode hold-to-talk on a non-audio (llama) model → the whisper REALTIME path.
await h.tapMic(); // grant → onStartRecording → the whisper realtime dictation path
await h.settle(200); // let the (blocked→free→retry) load resolve and the realtime session start
await h.releaseMic(); // release → onStopRecording (whisper path finalizes)

// The realtime stream delivers the finished utterance (final event, isCapturing:false).
await h.rtl.act(async () => {
h.boundary.whisper!.emitRealtime({ text: 'take a note', isCapturing: false });
});
await h.settle(800); // MIN_TRANSCRIBING_TIME + the finalResult → onTranscript effect

// THE FIX — dictation RECOVERED: the transcript is in the INPUT BOX.
// RED before: the realtime path dead-ended on 'blocked' (startRealtimeTranscription threw
// 'No Whisper model loaded') → the composer stayed empty.
await h.rtl.waitFor(() => {
expect(view.getByTestId('chat-input').props.value ?? '').toContain('take a note');
}, { timeout: 4000 });
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading