// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' const chatApi = vi.hoisted(() => ({ startRunViaSocket: vi.fn(), resumeSession: vi.fn(), registerSessionHandlers: vi.fn(), unregisterSessionHandlers: vi.fn(), })) vi.mock('@/api/hermes/chat', () => ({ startRunViaSocket: chatApi.startRunViaSocket, resumeSession: chatApi.resumeSession, registerSessionHandlers: chatApi.registerSessionHandlers, unregisterSessionHandlers: chatApi.unregisterSessionHandlers, getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })), respondToolApproval: vi.fn(), respondClarify: vi.fn(), onPeerUserMessage: vi.fn(() => vi.fn()), onSessionCommand: vi.fn(() => vi.fn()), onSessionTitleUpdated: vi.fn(() => vi.fn()), onSessionWorkspaceUpdated: vi.fn(() => vi.fn()), })) vi.mock('@/api/client', () => ({ getActiveProfileName: () => 'default', hasApiKey: () => false, })) vi.mock('@/api/hermes/sessions', () => ({ archiveSession: vi.fn(), deleteSession: vi.fn(), fetchSessionMessagesPage: vi.fn(), fetchSessions: vi.fn(), fetchWorkspaceRunChangesForSession: vi.fn(async () => []), fetchWorkspaceRunChangeFile: vi.fn(async () => null), setSessionModel: vi.fn(), })) vi.mock('@/api/hermes/download', () => ({ getDownloadUrl: (_path: string, name: string) => `/download/${name}`, })) vi.mock('@/api/hermes/system', () => ({ checkHealth: vi.fn(), fetchAvailableModels: vi.fn(), addCustomModel: vi.fn(), removeCustomModel: vi.fn(), updateDefaultModel: vi.fn(), updateModelVisibility: vi.fn(), updateModelAlias: vi.fn(), })) vi.mock('@/utils/completion-sound', () => ({ primeCompletionSound: vi.fn(), playCompletionSound: vi.fn(), })) import { useChatStore, type Message, type Session } from '@/stores/hermes/chat' function makeSession(id: string): Session { return { id, title: id, messages: [], createdAt: Date.now(), updatedAt: Date.now(), } } import { fetchSessionMessagesPage } from '@/api/hermes/sessions' describe('session loading recovery', () => { let callbacks: Array<(data: any) => void> beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() setActivePinia(createPinia()) callbacks = [] chatApi.resumeSession.mockImplementation((_id: string, callback: any) => { callbacks.push(callback) }) vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() }) it('ignores old replies and keeps loading the latest request during A/B/A switching', async () => { const store = useChatStore() store.sessions = [makeSession('a'), makeSession('b')] const first = store.switchSession('a') const second = store.switchSession('b') const third = store.switchSession('a') callbacks[0]({ session_id: 'a', messages: [{ role: 'user', content: 'old' }], events: [], isWorking: false }) await Promise.all([first, second]) expect(store.isLoadingMessages).toBe(true) expect(store.messages).toEqual([]) callbacks[2]({ session_id: 'a', messages: [{ role: 'user', content: 'latest' }], events: [], isWorking: false }) await third expect(store.messages[0]?.content).toBe('latest') expect(store.isLoadingMessages).toBe(false) }) it('loads history via HTTP when the socket does not reply', async () => { const store = useChatStore() store.sessions = [{ ...makeSession('a'), profile: 'research' }] vi.mocked(fetchSessionMessagesPage).mockResolvedValue({ session: { title: 'Recovered' }, messages: [{ id: 1, role: 'user', content: 'HTTP history' }], total: 1, hasMore: false } as any) const pending = store.switchSession('a') await vi.advanceTimersByTimeAsync(3000) await pending expect(fetchSessionMessagesPage).toHaveBeenCalledWith('a', 0, expect.any(Number), 'research') expect(store.messages[0]?.content).toBe('HTTP history') expect(store.messageLoadFailed).toBe(false) expect(store.isStreaming).toBe(false) callbacks[0]({ session_id: 'a', messages: [{ role: 'user', content: 'stale socket' }], isWorking: true, events: [] }) expect(store.messages[0]?.content).toBe('HTTP history') expect(store.isStreaming).toBe(true) }) it('falls back immediately if the socket cannot be opened', async () => { const store = useChatStore() store.sessions = [makeSession('a')] chatApi.resumeSession.mockImplementation(() => { throw new Error('socket unavailable') }) vi.mocked(fetchSessionMessagesPage).mockResolvedValue({ session: {}, messages: [{ role: 'user', content: 'HTTP fallback' }], total: 1, hasMore: false } as any) await store.switchSession('a') expect(store.messages[0]?.content).toBe('HTTP fallback') expect(store.messageLoadFailed).toBe(false) await vi.advanceTimersByTimeAsync(3000) expect(fetchSessionMessagesPage).toHaveBeenCalledTimes(1) }) it('exposes failure and supports retrying the same session', async () => { const store = useChatStore() store.sessions = [makeSession('a')] vi.mocked(fetchSessionMessagesPage).mockRejectedValue(new Error('offline')) const pending = store.switchSession('a') await vi.advanceTimersByTimeAsync(3000) await pending expect(store.messageLoadFailed).toBe(true) expect(store.isLoadingMessages).toBe(false) const retry = store.switchSession('a') expect(store.messageLoadFailed).toBe(false) callbacks.at(-1)!({ session_id: 'a', messages: [{ role: 'user', content: 'Recovered' }], events: [], isWorking: false }) await retry expect(store.messages[0]?.content).toBe('Recovered') }) it('does not apply a late HTTP fallback after another selection', async () => { const store = useChatStore() store.sessions = [makeSession('a'), makeSession('b')] let finish!: (data: any) => void vi.mocked(fetchSessionMessagesPage).mockReturnValue(new Promise(resolve => { finish = resolve })) const first = store.switchSession('a') await vi.advanceTimersByTimeAsync(3000) const second = store.switchSession('b') callbacks[1]({ session_id: 'b', messages: [{ role: 'user', content: 'B' }], events: [], isWorking: false }) await second finish({ session: {}, messages: [{ role: 'user', content: 'A' }], total: 1, hasMore: false }) await first expect(store.activeSessionId).toBe('b') expect(store.messages[0]?.content).toBe('B') expect(store.messageLoadFailed).toBe(false) }) })