++
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// @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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user