Skip to content

Commit b95e43b

Browse files
committed
fix(attachments): abort embedding on cancel and cap oversized document
1 parent 51263dd commit b95e43b

10 files changed

Lines changed: 172 additions & 34 deletions

File tree

__tests__/ChatBar.test.tsx

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import React from 'react';
22
import { render, screen, fireEvent, act } from '@testing-library/react-native';
33
import type { LLMStore } from '../store/llmStore';
4+
import type { Attachment } from '../hooks/useAttachment';
5+
import type { PermissionStatus } from 'react-native-audio-api';
46

57
// ── mocks ─────────────────────────────────────────────────────────────────────
68

@@ -27,7 +29,7 @@ jest.mock('../store/llmStore', () => ({
2729
}));
2830

2931
const mockUseAttachment = {
30-
attachments: [] as any[],
32+
attachments: [] as Attachment[],
3133
sheetRef: { current: null },
3234
pickFromLibrary: jest.fn(),
3335
pickFromCamera: jest.fn(),
@@ -49,7 +51,12 @@ jest.mock('../components/bottomSheets/AttachmentSheet', () => {
4951
onPickFromCamera,
5052
onPickDocument,
5153
isVisionModel,
52-
}: any) => (
54+
}: {
55+
onPickFromLibrary: () => void;
56+
onPickFromCamera: () => void;
57+
onPickDocument: () => void;
58+
isVisionModel: boolean;
59+
}) => (
5360
<View testID="attachment-sheet">
5461
{isVisionModel && (
5562
<>
@@ -73,7 +80,13 @@ jest.mock('../components/bottomSheets/AttachmentSheet', () => {
7380

7481
jest.mock('../components/chat-screen/AttachmentThumbnail', () => {
7582
const { View, TouchableOpacity, Text } = require('react-native');
76-
return ({ attachment, onRemove }: any) => (
83+
return ({
84+
attachment,
85+
onRemove,
86+
}: {
87+
attachment: Attachment;
88+
onRemove: () => void;
89+
}) => (
7790
<View testID={`attachment-thumb-${attachment.id}`}>
7891
<Text>{attachment.name || attachment.uri}</Text>
7992
<TouchableOpacity
@@ -87,8 +100,14 @@ jest.mock('../components/chat-screen/AttachmentThumbnail', () => {
87100
});
88101

89102
jest.mock('../components/chat-screen/ChatSpeechInput', () => {
90-
const { View, TouchableOpacity, Text } = require('react-native');
91-
return ({ onSubmit, onCancel }: any) => (
103+
const { View, TouchableOpacity } = require('react-native');
104+
return ({
105+
onSubmit,
106+
onCancel,
107+
}: {
108+
onSubmit: (transcript: string) => void;
109+
onCancel: () => void;
110+
}) => (
92111
<View testID="speech-input">
93112
<TouchableOpacity
94113
testID="speech-submit"
@@ -101,7 +120,7 @@ jest.mock('../components/chat-screen/ChatSpeechInput', () => {
101120

102121
jest.mock('../components/chat-screen/PromptSuggestions', () => {
103122
const { TouchableOpacity, Text } = require('react-native');
104-
return ({ onSelectPrompt }: any) => (
123+
return ({ onSelectPrompt }: { onSelectPrompt: (prompt: string) => void }) => (
105124
<TouchableOpacity
106125
testID="prompt-suggestion"
107126
onPress={() => onSelectPrompt('Suggested prompt')}
@@ -124,7 +143,18 @@ jest.mock('../components/chat-screen/ChatBarActions', () => {
124143
onThinkingToggle,
125144
thinkingEnabled,
126145
onAttach,
127-
}: any) => (
146+
}: {
147+
userInput: string;
148+
hasAttachments: boolean;
149+
onSend: () => void;
150+
isGenerating: boolean;
151+
isProcessingPrompt: boolean;
152+
onInterrupt: () => void;
153+
onSpeechInput: () => void;
154+
onThinkingToggle: () => void;
155+
thinkingEnabled: boolean;
156+
onAttach: () => void;
157+
}) => (
128158
<View testID="chat-bar-actions">
129159
<TouchableOpacity testID="attach-btn" onPress={onAttach}>
130160
<Text>+</Text>
@@ -185,7 +215,7 @@ const defaultProps = {
185215
onSelectModel: jest.fn(),
186216
onSelectPrompt: jest.fn(),
187217
model: downloadedModel,
188-
scrollRef: { current: null } as any,
218+
scrollRef: { current: null },
189219
isAtBottom: true,
190220
isVisionModel: false,
191221
thinkingEnabled: false,
@@ -218,7 +248,7 @@ beforeEach(() => {
218248
jest.spyOn(console, 'warn').mockImplementation(() => {});
219249
// Default: permission granted
220250
mockAudioManager.requestRecordingPermissions.mockResolvedValue(
221-
'Granted' as any
251+
'Granted' as PermissionStatus
222252
);
223253
});
224254

@@ -357,7 +387,7 @@ describe('speech input', () => {
357387

358388
it('shows toast and stays in text mode when microphone permission is denied', async () => {
359389
mockAudioManager.requestRecordingPermissions.mockResolvedValue(
360-
'Denied' as any
390+
'Denied' as PermissionStatus
361391
);
362392
renderBar();
363393
await act(async () => {
@@ -430,7 +460,7 @@ describe('speech input', () => {
430460
// Override speech mock to submit empty string
431461
jest.mock('../components/chat-screen/ChatSpeechInput', () => {
432462
const { View, TouchableOpacity } = require('react-native');
433-
return ({ onSubmit }: any) => (
463+
return ({ onSubmit }: { onSubmit: (transcript: string) => void }) => (
434464
<View testID="speech-input">
435465
<TouchableOpacity
436466
testID="speech-submit"

__tests__/hybridRetrieval.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { hybridRetrieve } from '../utils/hybridRetrieval';
22
import * as keywordIndex from '../database/keywordIndex';
3+
import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite';
34

45
jest.mock('../database/keywordIndex', () => ({
56
keywordSearch: jest.fn(),
@@ -8,8 +9,8 @@ jest.mock('../database/keywordIndex', () => ({
89
const mockKeywordSearch = keywordIndex.keywordSearch as jest.Mock;
910

1011
const makeVectorStore = (
11-
queryResults: any[],
12-
vectorsById: Record<string, any>
12+
queryResults: unknown[],
13+
vectorsById: Record<string, unknown>
1314
) =>
1415
({
1516
query: jest.fn().mockResolvedValue(queryResults),
@@ -20,7 +21,7 @@ const makeVectorStore = (
2021
rows: ids.map((id) => vectorsById[id]).filter(Boolean),
2122
})),
2223
},
23-
}) as any;
24+
}) as unknown as OPSQLiteVectorStore;
2425

2526
describe('hybridRetrieve', () => {
2627
beforeEach(() => {

__tests__/llmStore.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { useLLMStore } from '../store/llmStore';
22
import { LLMModule } from 'react-native-executorch';
33
import * as chatRepository from '../database/chatRepository';
4+
import type { Message } from '../database/chatRepository';
5+
import type { Model } from '../database/modelRepository';
6+
import type { SQLiteDatabase } from 'expo-sqlite';
47
import * as Feedback from '../utils/Feedback';
58
import { prepareMessagesForLLM } from '../utils/promptUtils';
69

@@ -29,7 +32,7 @@ const noSources = async () => ({
2932
preferredSourceDocuments: [],
3033
});
3134

32-
const mockDb = {} as any;
35+
const mockDb = {} as unknown as SQLiteDatabase;
3336

3437
const baseModel = {
3538
id: 1,
@@ -64,7 +67,7 @@ beforeEach(() => {
6467
mockLLMModule.fromModelName.mockImplementation(
6568
async (_namedSources, _onProgress, onToken) => {
6669
capturedTokenCallback = onToken;
67-
return mockInstance as any;
70+
return mockInstance as unknown as LLMModule;
6871
}
6972
);
7073

@@ -89,7 +92,7 @@ beforeEach(() => {
8992
mockLLMModule.fromModelName.mockImplementation(
9093
async (_namedSources, _onProgress, onToken) => {
9194
capturedTokenCallback = onToken;
92-
return mockInstance as any;
95+
return mockInstance as unknown as LLMModule;
9396
}
9497
);
9598
});
@@ -112,7 +115,7 @@ describe('loadModel', () => {
112115
mockLLMModule.fromModelName.mockImplementation(async (...args) => {
113116
wasLoading = useLLMStore.getState().isLoading;
114117
capturedTokenCallback = args[4];
115-
return mockInstance as any;
118+
return mockInstance as unknown as LLMModule;
116119
});
117120

118121
await useLLMStore.getState().loadModel(baseModel);
@@ -142,7 +145,7 @@ describe('loadModel', () => {
142145
mockInstance = makeMockInstance();
143146
mockLLMModule.fromModelName.mockImplementation(async (...args) => {
144147
capturedTokenCallback = args[4];
145-
return mockInstance as any;
148+
return mockInstance as unknown as LLMModule;
146149
});
147150
await useLLMStore.getState().loadModel({ ...baseModel, id: 2 });
148151

@@ -367,7 +370,7 @@ describe('sendChatMessage', () => {
367370
});
368371

369372
it('adds user message and assistant placeholder to activeChatMessages before generating', async () => {
370-
let messagesBeforeGenerate: any[] = [];
373+
let messagesBeforeGenerate: Message[] = [];
371374
mockInstance.generate.mockImplementation(async () => {
372375
messagesBeforeGenerate = useLLMStore.getState().activeChatMessages;
373376
return 'response';
@@ -536,7 +539,7 @@ describe('sendChatMessage imagePath', () => {
536539
modelName: 'LFM VL',
537540
vision: true,
538541
featured: true,
539-
} as any,
542+
} as Model,
540543
activeChatId: 1,
541544
activeChatMessages: [],
542545
});

__tests__/sourceStore.test.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useLLMStore } from '../store/llmStore';
66
import type { SQLiteDatabase } from 'expo-sqlite';
77
import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite';
88
import type { LFMEmbeddings } from '../utils/lfmEmbeddings';
9+
import { MAX_SOURCE_CHUNKS } from '../constants/retrieval';
910

1011
jest.mock('../database/sourcesRepository');
1112
jest.mock('../utils/fileReaders');
@@ -25,8 +26,10 @@ jest.mock('@react-native-rag/op-sqlite', () => ({}));
2526

2627
const mockDb = {} as Partial<SQLiteDatabase> as SQLiteDatabase;
2728
const vectorStoreAdd = jest.fn();
29+
const vectorStoreDelete = jest.fn();
2830
const mockVectorStore = {
2931
add: vectorStoreAdd,
32+
delete: vectorStoreDelete,
3033
} as Partial<OPSQLiteVectorStore> as OPSQLiteVectorStore;
3134

3235
const mockReadDocumentText = fileReaders.readDocumentText as jest.Mock;
@@ -141,12 +144,58 @@ describe('addSource', () => {
141144
.addSource(baseSource, '/path/doc.txt', mockVectorStore);
142145

143146
const sources = useSourceStore.getState().sources;
144-
expect(result).toEqual({ success: true, sourceId: 99 });
147+
expect(result).toEqual({ success: true, sourceId: 99, truncated: false });
145148
expect(sources).toHaveLength(1);
146149
expect(sources[0].id).toBe(99);
147150
expect(sources[0].isProcessing).toBe(false);
148151
});
149152

153+
it('caps embedded chunks at MAX_SOURCE_CHUNKS and flags the result truncated', async () => {
154+
mockReadDocumentText.mockResolvedValue('content');
155+
mockInsertSource.mockResolvedValue(99);
156+
const manyChunks = Array.from(
157+
{ length: MAX_SOURCE_CHUNKS + 1 },
158+
(_, i) => `chunk-${i}`
159+
);
160+
MockSplitter.mockImplementation(() => ({
161+
splitText: jest.fn().mockResolvedValue(manyChunks),
162+
}));
163+
164+
const result = await useSourceStore
165+
.getState()
166+
.addSource(baseSource, '/path/doc.txt', mockVectorStore);
167+
168+
expect(result).toEqual({ success: true, sourceId: 99, truncated: true });
169+
expect(vectorStoreAdd).toHaveBeenCalledTimes(MAX_SOURCE_CHUNKS);
170+
});
171+
172+
it('aborts embedding and rolls back the partial source when the signal is aborted', async () => {
173+
mockReadDocumentText.mockResolvedValue('content');
174+
mockInsertSource.mockResolvedValue(99);
175+
MockSplitter.mockImplementation(() => ({
176+
splitText: jest.fn().mockResolvedValue(['chunk-a', 'chunk-b']),
177+
}));
178+
const controller = new AbortController();
179+
controller.abort();
180+
181+
const result = await useSourceStore
182+
.getState()
183+
.addSource(
184+
baseSource,
185+
'/path/doc.txt',
186+
mockVectorStore,
187+
undefined,
188+
undefined,
189+
controller.signal
190+
);
191+
192+
expect(result).toEqual({ success: false, cancelled: true });
193+
expect(vectorStoreAdd).not.toHaveBeenCalled();
194+
expect(vectorStoreDelete).toHaveBeenCalledTimes(1);
195+
expect(mockDeleteSource).toHaveBeenCalledWith(mockDb, 99);
196+
expect(useSourceStore.getState().sources).toHaveLength(0);
197+
});
198+
150199
it('passes firstChunk to insertSource', async () => {
151200
mockReadDocumentText.mockResolvedValue('content');
152201
mockInsertSource.mockResolvedValue(1);
@@ -316,10 +365,10 @@ describe('cleanupOrphanedSources', () => {
316365
mockGetOrphanedSources.mockResolvedValue(orphaned);
317366
mockDeleteSource.mockResolvedValue(undefined);
318367

319-
const vectorStoreDelete = jest.fn();
368+
const orphanVectorStoreDelete = jest.fn();
320369
const mockVectorStoreWithDelete = {
321370
add: vectorStoreAdd,
322-
delete: vectorStoreDelete,
371+
delete: orphanVectorStoreDelete,
323372
} as Partial<OPSQLiteVectorStore> as OPSQLiteVectorStore;
324373

325374
await useSourceStore
@@ -328,7 +377,7 @@ describe('cleanupOrphanedSources', () => {
328377

329378
expect(mockGetOrphanedSources).toHaveBeenCalledWith(mockDb);
330379
expect(mockDeleteSource).toHaveBeenCalledWith(mockDb, 5);
331-
expect(vectorStoreDelete).toHaveBeenCalledWith({
380+
expect(orphanVectorStoreDelete).toHaveBeenCalledWith({
332381
predicate: expect.any(Function),
333382
});
334383
});

constants/retrieval.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export const ANSWER_CITATION_OVERLAP_RATIO = 0.5;
4141
export const TEXT_SPLITTER_CHUNK_SIZE = 1000;
4242
export const TEXT_SPLITTER_CHUNK_OVERLAP = 200;
4343

44+
/** Safety backstop on chunks embedded per source; high enough that large real documents (~1.6 MB of text) index in full, low enough to stop a pathological multi-MB file from embedding for tens of minutes. */
45+
export const MAX_SOURCE_CHUNKS = 2000;
46+
4447
/** Min matched run to treat as overlap when stitching passages — below the splitter overlap, above coincidental repetition. */
4548
export const MIN_STITCH_OVERLAP = 24;
4649

0 commit comments

Comments
 (0)