import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, audioSpeakRequestTimeoutMs, audioTranscribeRequestTimeoutMs, getAllSessionMessages, getCronJobs, getGlobalModelInfo, getGlobalModelOptions, getHermesConfig, getHermesConfigDefaults, getProfiles, getSessionMessages, getStatus, listAllProfileSessions, listSessions, listSidebarSessions, resetSidebarBatchCapability, setApiRequestProfile, speakText, transcribeAudio } from './hermes' import { refreshActiveProfile } from './store/profile' const emptySessionsResponse = { limit: 0, offset: 0, sessions: [], total: 0 } describe('Hermes REST helpers', () => { let api: ReturnType beforeEach(() => { resetSidebarBatchCapability() api = vi.fn().mockResolvedValue(emptySessionsResponse) Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { api } }) }) afterEach(() => { setApiRequestProfile(null) vi.restoreAllMocks() Reflect.deleteProperty(window, 'hermesDesktop') }) it('uses a longer timeout for the single-profile session list', async () => { await listSessions(50, 1) expect(api).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent', timeoutMs: 60_000 }) ) }) it('uses a longer timeout for the all-profile session list', async () => { await listAllProfileSessions(50, 1) expect(api).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/profiles/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent&profile=all', timeoutMs: 60_000 }) ) }) it('batches the sidebar slices into a single request with per-slice limits + excludes', async () => { api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } }) await listSidebarSessions({ recentsProfile: 'work', recentsLimit: 30, recentsExclude: ['cron', 'tool'], cronLimit: 50, messagingLimit: 100, messagingExclude: ['cron', 'desktop'] }) expect(api).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/profiles/sessions/sidebar?recents_profile=work&recents_limit=30&cron_limit=50' + '&messaging_limit=100&recents_exclude=cron%2Ctool&messaging_exclude=cron%2Cdesktop', timeoutMs: 60_000 }) ) }) it('defaults missing sidebar slices to empty session arrays', async () => { api.mockResolvedValue({}) const result = await listSidebarSessions({ recentsProfile: 'all', recentsLimit: 20, recentsExclude: [], cronLimit: 50, messagingLimit: 100, messagingExclude: [] }) expect(result.recents.sessions).toEqual([]) expect(result.cron.sessions).toEqual([]) expect(result.messaging.sessions).toEqual([]) }) it('falls back to the per-slice endpoint when the batched route 404s on an older backend', async () => { const row = (id: string) => ({ id, title: id, profile: 'default' }) api.mockImplementation(({ path }: { path: string }) => { if (path.startsWith('/api/profiles/sessions/sidebar')) { // The exact skew failure: Electron surfaces the backend catch-all. return Promise.reject( new Error( 'Error invoking remote method \'hermes:api\': Error: 404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}' ) ) } if (path.includes('source=cron')) { return Promise.resolve({ ...emptySessionsResponse, sessions: [row('cron-1')], total: 1 }) } if (path.includes('exclude_sources=cron%2Cdesktop')) { return Promise.resolve({ ...emptySessionsResponse, sessions: [row('msg-1')], total: 1 }) } return Promise.resolve({ ...emptySessionsResponse, sessions: [row('recent-1')], total: 7, profile_totals: { default: 7 } }) }) const result = await listSidebarSessions({ recentsProfile: 'work', recentsLimit: 30, recentsExclude: ['cron', 'tool'], cronLimit: 50, messagingLimit: 100, messagingExclude: ['cron', 'desktop'] }) // Slices reassembled from the legacy per-slice route with the same // scoping: recents on the caller's profile, cron + messaging cross-profile. expect(result.recents.sessions.map(s => s.id)).toEqual(['recent-1']) // One row back against a 30-row window: the profile is fully loaded, so // the legacy path must not claim there's another page. expect(result.recents.profiles_truncated).toEqual({ default: false }) expect(result.cron.sessions.map(s => s.id)).toEqual(['cron-1']) expect(result.messaging.sessions.map(s => s.id)).toEqual(['msg-1']) const paths = api.mock.calls.map(call => (call[0] as { path: string }).path) expect(paths.filter(p => p.startsWith('/api/profiles/sessions/sidebar'))).toHaveLength(1) expect(paths.filter(p => p.startsWith('/api/profiles/sessions?'))).toHaveLength(3) expect(paths).toContainEqual(expect.stringContaining('profile=work')) expect(paths).toContainEqual(expect.stringContaining('source=cron')) expect(paths).toContainEqual(expect.stringContaining('exclude_sources=cron%2Ctool')) }) it('remembers endpoint-missing and skips re-probing the batched route on later refreshes', async () => { api.mockImplementation(({ path }: { path: string }) => path.startsWith('/api/profiles/sessions/sidebar') ? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}')) : Promise.resolve(emptySessionsResponse) ) const req = { recentsProfile: 'all' as const, recentsLimit: 20, recentsExclude: [], cronLimit: 50, messagingLimit: 100, messagingExclude: [] } await listSidebarSessions(req) await listSidebarSessions(req) const batchedProbes = api.mock.calls.filter(call => (call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar') ) // First refresh probes once and learns; the second goes straight to the // per-slice route (3 calls each refresh, no repeated dead probe). expect(batchedProbes).toHaveLength(1) expect(api.mock.calls.length).toBe(1 + 3 + 3) }) it('re-probes the batched route after a gateway switch resets the capability flag', async () => { api.mockImplementation(({ path }: { path: string }) => path.startsWith('/api/profiles/sessions/sidebar') ? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}')) : Promise.resolve(emptySessionsResponse) ) const req = { recentsProfile: 'all' as const, recentsLimit: 20, recentsExclude: [], cronLimit: 50, messagingLimit: 100, messagingExclude: [] } await listSidebarSessions(req) // Soft gateway switch: the next backend may support the batched route. resetSidebarBatchCapability() api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } }) const result = await listSidebarSessions(req) const batchedProbes = api.mock.calls.filter(call => (call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar') ) expect(batchedProbes).toHaveLength(2) expect(result.recents.sessions).toEqual([]) }) it('does NOT fall back on transient failures — only endpoint-missing shapes trigger legacy mode', async () => { api.mockRejectedValue(new Error('Request timed out after 60000ms')) await expect( listSidebarSessions({ recentsProfile: 'all', recentsLimit: 20, recentsExclude: [], cronLimit: 50, messagingLimit: 100, messagingExclude: [] }) ).rejects.toThrow('timed out') // One call: the batched probe. No legacy fan-out, no sticky degradation. expect(api).toHaveBeenCalledTimes(1) // And the next refresh still uses the batched route. api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } }) await listSidebarSessions({ recentsProfile: 'all', recentsLimit: 20, recentsExclude: [], cronLimit: 50, messagingLimit: 100, messagingExclude: [] }) expect((api.mock.calls[1][0] as { path: string }).path).toMatch(/^\/api\/profiles\/sessions\/sidebar\?/) }) it('uses a longer timeout for profile listing during desktop startup', async () => { api.mockResolvedValue({ profiles: [] }) await getProfiles() expect(api).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/profiles', timeoutMs: 60_000 }) ) }) it('uses a longer timeout for active profile refresh during desktop startup', async () => { api.mockResolvedValueOnce({ current: 'default' }).mockResolvedValueOnce({ profiles: [] }) await refreshActiveProfile() expect(api).toHaveBeenNthCalledWith( 1, expect.objectContaining({ path: '/api/profiles/active', timeoutMs: 60_000 }) ) expect(api).toHaveBeenNthCalledWith( 2, expect.objectContaining({ path: '/api/profiles', timeoutMs: 60_000 }) ) }) it('gives the whole startup data burst the long timeout, not just profiles', async () => { api.mockResolvedValue({}) const bootCalls: [() => Promise, string][] = [ [getHermesConfig, '/api/config'], [getHermesConfigDefaults, '/api/config/defaults'], [getGlobalModelInfo, '/api/model/info'], [() => getGlobalModelOptions(), '/api/model/options?explicit_only=1'], [getCronJobs, '/api/cron/jobs'] ] for (const [call, path] of bootCalls) { api.mockClear() await call() expect(api).toHaveBeenCalledWith(expect.objectContaining({ path, timeoutMs: 60_000 })) } }) it('keeps the liveness poll on the short default so a dead backend fails fast', async () => { api.mockResolvedValue({}) api.mockClear() await getStatus() // /api/status must NOT carry the long startup timeout — it is the runtime // liveness probe and has to fail quickly when the backend drops. const call = api.mock.calls[0]?.[0] as { path: string; timeoutMs?: number } expect(call.path).toBe('/api/status') expect(call.timeoutMs).toBeUndefined() }) it('tags cross-profile message reads for Electron routing and backend lookup', async () => { api.mockResolvedValue({ messages: [], session_id: 'session-1' }) await getSessionMessages('session-1', 'xiaoxuxu') expect(api).toHaveBeenCalledWith({ path: '/api/sessions/session-1/messages?profile=xiaoxuxu', profile: 'xiaoxuxu' }) }) it('passes bounded transcript pagination through to the backend', async () => { api.mockResolvedValue({ messages: [], session_id: 'session-1' }) await getSessionMessages('session-1', 'xiaoxuxu', { limit: 500, offset: 1000, order: 'latest' }) expect(api).toHaveBeenCalledWith({ path: '/api/sessions/session-1/messages?profile=xiaoxuxu&limit=500&offset=1000&order=latest', profile: 'xiaoxuxu' }) }) it('loads complete transcripts through bounded oldest-first pages', async () => { api .mockResolvedValueOnce({ messages: [{ id: 1 }, { id: 2 }], session_id: 'session-1', pagination: { limit: 2, offset: 0, order: 'oldest', returned: 2 } }) .mockResolvedValueOnce({ messages: [{ id: 3 }], session_id: 'session-1', pagination: { limit: 2, offset: 2, order: 'oldest', returned: 1 } }) const result = await getAllSessionMessages('session-1', 'xiaoxuxu') expect(result.messages).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]) expect(api).toHaveBeenNthCalledWith(1, { path: '/api/sessions/session-1/messages?profile=xiaoxuxu&limit=500&offset=0&order=oldest', profile: 'xiaoxuxu' }) expect(api).toHaveBeenNthCalledWith(2, { path: '/api/sessions/session-1/messages?profile=xiaoxuxu&limit=500&offset=2&order=oldest', profile: 'xiaoxuxu' }) }) it('stops complete transcript loads before Desktop memory becomes unbounded', async () => { api.mockResolvedValueOnce({ messages: [{ id: 1, content: 'large transcript page' }], session_id: 'session-1', pagination: { limit: 1, offset: 0, order: 'oldest', returned: 1 } }) await expect(getAllSessionMessages('session-1', null, { maxJsonChars: 1 })).rejects.toThrow( 'Desktop safe-load limit' ) }) it('bounds blocking TTS synthesis timeouts by text length', () => { expect(audioSpeakRequestTimeoutMs('short message')).toBe(AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS) expect(audioSpeakRequestTimeoutMs('x'.repeat(8_000))).toBe(280_000) expect(audioSpeakRequestTimeoutMs('x'.repeat(100_000))).toBe(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS) }) it('routes blocking TTS synthesis through the active profile backend', async () => { setApiRequestProfile('rhaegal') api.mockResolvedValueOnce({ data_url: 'data:audio/mpeg;base64,AA==', mime_type: 'audio/mpeg', ok: true, provider: 'openai' }) await expect(speakText('Read this aloud')).resolves.toEqual({ data_url: 'data:audio/mpeg;base64,AA==', mime_type: 'audio/mpeg', ok: true, provider: 'openai' }) expect(api).toHaveBeenCalledWith({ body: { text: 'Read this aloud' }, method: 'POST', path: '/api/audio/speak', profile: 'rhaegal', timeoutMs: AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS }) }) it('bounds blocking transcription timeouts by payload length', () => { expect(audioTranscribeRequestTimeoutMs('data:audio/webm;base64,AA==')).toBe(AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS) expect(audioTranscribeRequestTimeoutMs('x'.repeat(3_000_000))).toBe(300_000) expect(audioTranscribeRequestTimeoutMs('x'.repeat(9_000_000))).toBe(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS) }) it('uses an extended timeout for blocking transcription', async () => { api.mockResolvedValueOnce({ ok: true, provider: 'openai', text: 'transcribed text' }) await expect(transcribeAudio('data:audio/webm;base64,AA==', 'audio/webm')).resolves.toEqual({ ok: true, provider: 'openai', text: 'transcribed text' }) expect(api).toHaveBeenCalledWith({ body: { data_url: 'data:audio/webm;base64,AA==', mime_type: 'audio/webm' }, method: 'POST', path: '/api/audio/transcribe', timeoutMs: AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS }) }) it('defaults model options to configured providers only', async () => { await getGlobalModelOptions() expect(api).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/model/options?explicit_only=1' }) ) }) it('can opt into unconfigured providers for onboarding flows', async () => { await getGlobalModelOptions({ includeUnconfigured: true, refresh: true, explicitOnly: false }) expect(api).toHaveBeenCalledWith( expect.objectContaining({ path: '/api/model/options?refresh=1&include_unconfigured=1' }) ) }) })