163 lines
6.4 KiB
TypeScript
163 lines
6.4 KiB
TypeScript
// @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)
|
|
})
|
|
})
|