/** * Offline model fallback tests. * * Verifies that OneShotTasks.analyzeMediaImage(), ChatService.sendMessage(), * and ChatService.generateConversationTitle() automatically fall back to * the configured offline model when airplane mode is active. * * Strategy: spy on resolveModel to capture which model ID is passed, * then let it throw to short-circuit the actual AI call — the engine's * try/catch returns { success: false } which is fine for our assertions. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { OneShotTasks } from '../../src/main/engine/ai/tasks'; import { ChatService } from '../../src/main/engine/ai/chat'; import { ProviderRegistry } from '../../src/main/engine/ai/providers'; // Tiny valid 2x2 JPEG (base64) — avoids sharp "corrupt header" error // eslint-disable-next-line max-len const TINY_JPEG_B64 = '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAACAAIDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AAA//2Q=='; // --------------------------------------------------------------------------- // Shared mock helpers // --------------------------------------------------------------------------- function createMockChatEngine(settings: Record = {}) { return { getSetting: vi.fn(async (key: string) => settings[key] ?? null), getConversation: vi.fn(), addMessage: vi.fn(async (msg: unknown) => ({ id: 'msg-1', ...msg as Record })), getDefaultSystemPrompt: vi.fn(async () => 'You are a helpful assistant'), updateConversation: vi.fn(), } as unknown as InstanceType; } function createMockMediaEngine() { return { getMedia: vi.fn(async () => ({ id: 'media-1', mimeType: 'image/jpeg', filename: 'test.jpg', })), getThumbnailDataUrl: vi.fn(async () => `data:image/jpeg;base64,${TINY_JPEG_B64}`), } as unknown as InstanceType; } /** Mock the ModelCatalogEngine returned by ProviderRegistry */ function mockModelCatalog(registry: ProviderRegistry): void { vi.spyOn(registry, 'getModelCatalogEngine').mockReturnValue({ getContextWindow: vi.fn(async () => 8192), } as never); } // --------------------------------------------------------------------------- // OneShotTasks — analyzeMediaImage offline fallback // --------------------------------------------------------------------------- describe('OneShotTasks offline model fallback', () => { let registry: ProviderRegistry; beforeEach(() => { registry = new ProviderRegistry(); registry.setOllamaEnabled(true); registry.registerOllamaModel('llava:latest'); }); it('analyzeMediaImage uses offline_image_analysis_model when airplane mode is on', async () => { registry.setOpencodeKey('test-key'); registry.setOfflineMode(true); const chatEngine = createMockChatEngine({ chat_image_analysis_model: 'claude-sonnet-4-5', // cloud model offline_image_analysis_model: 'llava:latest', // local model }); const tasks = new OneShotTasks(registry, chatEngine, createMockMediaEngine()); // resolveModel spy — let it throw to short-circuit the generateText call const resolveModelSpy = vi.spyOn(registry, 'resolveModel') .mockImplementation(() => { throw new Error('mock-stop'); }); await tasks.analyzeMediaImage('media-1', 'en'); expect(resolveModelSpy).toHaveBeenCalledWith('llava:latest'); }); it('analyzeMediaImage auto-falls back to first local model when no offline model configured', async () => { registry.setOpencodeKey('test-key'); registry.setOfflineMode(true); const chatEngine = createMockChatEngine({ chat_image_analysis_model: 'claude-sonnet-4-5', // No offline_image_analysis_model set — should auto-pick llava:latest }); const tasks = new OneShotTasks(registry, chatEngine, createMockMediaEngine()); const resolveModelSpy = vi.spyOn(registry, 'resolveModel') .mockImplementation(() => { throw new Error('mock-stop'); }); await tasks.analyzeMediaImage('media-1', 'en'); expect(resolveModelSpy).toHaveBeenCalledWith('llava:latest'); }); it('analyzeMediaImage returns error when offline with no local models at all', async () => { const emptyRegistry = new ProviderRegistry(); emptyRegistry.setOpencodeKey('test-key'); emptyRegistry.setOfflineMode(true); const chatEngine = createMockChatEngine({ chat_image_analysis_model: 'claude-sonnet-4-5', }); const tasks = new OneShotTasks(emptyRegistry, chatEngine, createMockMediaEngine()); const result = await tasks.analyzeMediaImage('media-1', 'en'); expect(result.success).toBe(false); expect(result.error).toContain('offline'); }); it('analyzeMediaImage uses default model when NOT offline', async () => { registry.setOpencodeKey('test-key'); // Offline mode is OFF const chatEngine = createMockChatEngine({ chat_image_analysis_model: 'claude-sonnet-4-5', offline_image_analysis_model: 'llava:latest', }); const tasks = new OneShotTasks(registry, chatEngine, createMockMediaEngine()); const resolveModelSpy = vi.spyOn(registry, 'resolveModel') .mockImplementation(() => { throw new Error('mock-stop'); }); await tasks.analyzeMediaImage('media-1', 'en'); // Should use the regular model, NOT the offline one expect(resolveModelSpy).toHaveBeenCalledWith('claude-sonnet-4-5'); }); }); // --------------------------------------------------------------------------- // ChatService — sendMessage offline fallback // --------------------------------------------------------------------------- describe('ChatService offline model fallback', () => { let registry: ProviderRegistry; beforeEach(() => { registry = new ProviderRegistry(); registry.setOllamaEnabled(true); registry.registerOllamaModel('llama3:latest'); }); it('sendMessage swaps cloud model for offline_chat_model when airplane mode is on', async () => { registry.setOpencodeKey('test-key'); registry.setOfflineMode(true); const chatEngine = createMockChatEngine({ offline_chat_model: 'llama3:latest', }); chatEngine.getConversation = vi.fn(async () => ({ id: 'conv-1', title: 'Test', model: 'claude-sonnet-4', // cloud model on conversation createdAt: new Date(), messages: [], })); const service = new ChatService( chatEngine, registry, {} as never, () => null, ); mockModelCatalog(registry); // resolveModel spy — let it throw to short-circuit const resolveModelSpy = vi.spyOn(registry, 'resolveModel') .mockImplementation(() => { throw new Error('mock-stop'); }); const result = await service.sendMessage('conv-1', 'Hello', {}); // Model swap should have happened before resolveModel was called expect(resolveModelSpy).toHaveBeenCalledWith('llama3:latest'); expect(result.success).toBe(false); // throws mock-stop in try/catch }); it('sendMessage auto-falls back to first local model when no offline_chat_model configured', async () => { registry.setOpencodeKey('test-key'); registry.setOfflineMode(true); const chatEngine = createMockChatEngine({ // No offline_chat_model — should auto-pick llama3:latest }); chatEngine.getConversation = vi.fn(async () => ({ id: 'conv-1', title: 'Test', model: 'claude-sonnet-4', createdAt: new Date(), messages: [], })); const service = new ChatService( chatEngine, registry, {} as never, () => null, ); mockModelCatalog(registry); const resolveModelSpy = vi.spyOn(registry, 'resolveModel') .mockImplementation(() => { throw new Error('mock-stop'); }); await service.sendMessage('conv-1', 'Hello', {}); expect(resolveModelSpy).toHaveBeenCalledWith('llama3:latest'); }); it('sendMessage returns error when offline with no local models at all', async () => { const emptyRegistry = new ProviderRegistry(); emptyRegistry.setOpencodeKey('test-key'); emptyRegistry.setOfflineMode(true); const chatEngine = createMockChatEngine({}); chatEngine.getConversation = vi.fn(async () => ({ id: 'conv-1', title: 'Test', model: 'claude-sonnet-4', createdAt: new Date(), messages: [], })); const service = new ChatService( chatEngine, emptyRegistry, {} as never, () => null, ); const result = await service.sendMessage('conv-1', 'Hello', {}); // With no local providers enabled, isReady() returns false expect(result.success).toBe(false); }); it('sendMessage keeps local model when conversation already uses local model and offline', async () => { registry.setOfflineMode(true); const chatEngine = createMockChatEngine({}); chatEngine.getConversation = vi.fn(async () => ({ id: 'conv-1', title: 'Test', model: 'llama3:latest', // already a local model createdAt: new Date(), messages: [], })); const service = new ChatService( chatEngine, registry, {} as never, () => null, ); mockModelCatalog(registry); const resolveModelSpy = vi.spyOn(registry, 'resolveModel') .mockImplementation(() => { throw new Error('mock-stop'); }); await service.sendMessage('conv-1', 'Hello', {}); // Should use the local model directly, no swap needed expect(resolveModelSpy).toHaveBeenCalledWith('llama3:latest'); }); }); // --------------------------------------------------------------------------- // ChatService — generateConversationTitle offline fallback // --------------------------------------------------------------------------- describe('ChatService title generation offline fallback', () => { let registry: ProviderRegistry; beforeEach(() => { registry = new ProviderRegistry(); registry.setOllamaEnabled(true); registry.registerOllamaModel('llama3:latest'); }); it('title generation silently skips when offline with no offline_title_model', async () => { registry.setOpencodeKey('test-key'); registry.setOfflineMode(true); const chatEngine = createMockChatEngine({ chat_title_model: 'claude-haiku-4-5', // cloud model // No offline_title_model set }); chatEngine.getConversation = vi.fn(async () => ({ id: 'conv-1', title: 'Test', model: 'llama3:latest', // local model for chat createdAt: new Date(), messages: [], })); const service = new ChatService( chatEngine, registry, {} as never, () => null, ); mockModelCatalog(registry); // resolveModel used for chat model; title generation should be skipped const resolveModelSpy = vi.spyOn(registry, 'resolveModel') .mockImplementation(() => { throw new Error('mock-stop'); }); await service.sendMessage('conv-1', 'Hello', {}); // resolveModel should only be called once — for the chat model, not for title // (title generation is skipped silently when offline with no offline_title_model) const calls = resolveModelSpy.mock.calls; expect(calls.some(c => c[0] === 'claude-haiku-4-5')).toBe(false); }); });