This commit is contained in:
2026-10-24 16:34:02 +08:00
parent 99e5687ce0
commit 7119e04a65
24 changed files with 553 additions and 39 deletions
+16
View File
@@ -85,6 +85,22 @@ describe('chat-run socket reconnect handling', () => {
socketState.sockets = []
})
it('removes cancelled resume listeners without disturbing the current request', async () => {
const { resumeSession } = await import('../../packages/client/src/api/hermes/chat')
const controller = new AbortController()
const oldReply = vi.fn()
resumeSession('session-1', oldReply, 'default', 'chat-run', controller.signal)
const socket = socketState.sockets[0]
controller.abort()
expect(socket.__listenerCount('resumed')).toBe(0)
const currentReply = vi.fn()
resumeSession('session-1', currentReply, 'default')
socket.__trigger('resumed', { session_id: 'session-1', messages: [], isWorking: false, events: [] })
expect(oldReply).not.toHaveBeenCalled()
expect(currentReply).toHaveBeenCalledOnce()
expect(socket.__listenerCount('resumed')).toBe(0)
})
it('keeps transient mobile disconnects alive and resumes after reconnect', async () => {
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const onEvent = vi.fn()
@@ -475,7 +475,7 @@ describe('chat store session.command fanout', () => {
workspace: '/repo',
})
expect(store.activeSessionId).toBe('branch-1')
expect(chatApi.resumeSession).toHaveBeenCalledWith('branch-1', expect.any(Function), 'default', 'chat-run')
expect(chatApi.resumeSession).toHaveBeenCalledWith('branch-1', expect.any(Function), 'default', 'chat-run', expect.any(AbortSignal))
await store.switchSession('session-1')
expect(store.activeSessionId).toBe('session-1')
@@ -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)
})
})
+11
View File
@@ -94,6 +94,17 @@ describe('ChatView tab title', () => {
vi.spyOn(chatStore, 'loadSessions').mockResolvedValue()
})
it('loads sessions while profiles, settings and models are still pending', async () => {
const pending = new Promise<void>(() => {})
vi.mocked(useProfilesStore().fetchProfiles).mockReturnValue(pending)
vi.mocked(useSettingsStore().fetchSettings).mockReturnValue(pending)
vi.mocked(useAppStore().loadModels).mockReturnValue(pending)
const wrapper = mount(ChatView)
await nextTick()
expect(useChatStore().loadSessions).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('reflects the active session title in the browser tab', async () => {
const chatStore = useChatStore()
chatStore.activeSession = makeSession('Research Plan')
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import { fetchProfileAvatar } from '@/api/hermes/profiles'
vi.mock('@/api/hermes/profiles', () => ({ fetchProfileAvatar: vi.fn() }))
describe('profile avatar loading', () => {
beforeEach(() => vi.clearAllMocks())
it('fetches an uploaded image only when displayed and ignores old profile responses', async () => {
let finish!: (data: any) => void
vi.mocked(fetchProfileAvatar).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
vi.mocked(fetchProfileAvatar).mockResolvedValueOnce({ type: 'image', dataUrl: 'data:image/png;base64,Yg==' })
const wrapper = mount(ProfileAvatar, { props: { name: 'a', avatar: { type: 'image', updatedAt: 1 } } })
expect(fetchProfileAvatar).toHaveBeenCalledWith('a')
await wrapper.setProps({ name: 'b', avatar: { type: 'image', updatedAt: 2 } })
await flushPromises()
expect(wrapper.find('img').attributes('src')).toBe('data:image/png;base64,Yg==')
finish({ type: 'image', dataUrl: 'data:image/png;base64,YQ==' })
await flushPromises()
expect(wrapper.find('img').attributes('src')).toBe('data:image/png;base64,Yg==')
wrapper.unmount()
})
it('does not fetch generated or already loaded avatars', async () => {
const wrapper = mount(ProfileAvatar, { props: { name: 'a', avatar: { type: 'generated', seed: 'a' } } })
await wrapper.setProps({ avatar: { type: 'image', dataUrl: 'data:image/png;base64,YQ==' } })
expect(fetchProfileAvatar).not.toHaveBeenCalled()
expect(wrapper.find('img').attributes('src')).toBe('data:image/png;base64,YQ==')
wrapper.unmount()
})
})
+14
View File
@@ -25,6 +25,20 @@ describe('Profiles Store', () => {
vi.clearAllMocks()
})
it('shares an in-flight profile request and allows later refreshes', async () => {
let resolve!: (value: any[]) => void
mockProfilesApi.fetchProfiles.mockReturnValue(new Promise(done => { resolve = done }))
const store = useProfilesStore()
const first = store.fetchProfiles()
const second = store.fetchProfiles()
expect(mockProfilesApi.fetchProfiles).toHaveBeenCalledOnce()
resolve([])
await Promise.all([first, second])
mockProfilesApi.fetchProfiles.mockResolvedValue([])
await store.fetchProfiles()
expect(mockProfilesApi.fetchProfiles).toHaveBeenCalledTimes(2)
})
it('fetchProfiles loads profiles and sets active', async () => {
const profiles = [
{ name: 'default', active: true, model: 'gpt-4', alias: '' },