++
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: '' },
|
||||
|
||||
@@ -45,3 +45,44 @@ test('batch-delete text appears in its confirmation popup without leaking into t
|
||||
await expect(popup).toContainText(confirmation)
|
||||
await expect(popup.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const path of ['/hermes/chat', '/hermes/session/restored-chat?profile=research']) {
|
||||
test(`shows conversation before slow profile, settings and model requests finish: ${path}`, async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await mockHermesApi(page, { sessions: [session] })
|
||||
await mockChatSocket(page)
|
||||
await page.addInitScript(() => {
|
||||
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = {
|
||||
'restored-chat': { session_id: 'restored-chat', messages: [{ id: 1, role: 'user', content: 'History without waiting for profiles', timestamp: 1000 }], isWorking: false, events: [] },
|
||||
}
|
||||
})
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route(url => ['/api/hermes/profiles', '/api/hermes/config', '/api/hermes/available-models'].includes(url.pathname), async route => {
|
||||
await gate
|
||||
await route.fallback()
|
||||
})
|
||||
try {
|
||||
await page.goto(`/#${path}`)
|
||||
await expect(page.getByText('History without waiting for profiles', { exact: true })).toBeVisible()
|
||||
} finally { release() }
|
||||
})
|
||||
}
|
||||
|
||||
test('falls back to HTTP history and offers retry after a failed load', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await mockHermesApi(page, { sessions: [session] })
|
||||
await mockChatSocket(page)
|
||||
let fail = true
|
||||
await page.route('**/sessions/conversations/restored-chat/messages/paginated?*', async route => {
|
||||
await route.fulfill(fail
|
||||
? { status: 503, json: { error: 'temporarily unavailable' } }
|
||||
: { json: { session, messages: [{ id: 1, role: 'user', content: 'History recovered by retry', timestamp: 1000 }], total: 1, hasMore: false } })
|
||||
})
|
||||
await page.goto('/#/hermes/chat')
|
||||
await expect(page.getByRole('alert')).toContainText('Could not load this conversation', { timeout: 12000 })
|
||||
fail = false
|
||||
await page.getByRole('alert').getByRole('button', { name: 'Retry' }).click()
|
||||
await expect(page.getByText('History recovered by retry', { exact: true })).toBeVisible({ timeout: 12000 })
|
||||
await expect(page.getByRole('alert')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { performance } from 'perf_hooks'
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-process', () => ({
|
||||
execHermesWithBin: vi.fn(() => { throw new Error('Inventory must not invoke CLI') }),
|
||||
spawnHermesWithBin: vi.fn(),
|
||||
}))
|
||||
import { listProfilesSnapshot } from '../../packages/server/src/services/hermes/hermes-cli'
|
||||
import { execHermesWithBin } from '../../packages/server/src/services/hermes/hermes-process'
|
||||
|
||||
const originalHome = process.env.HERMES_HOME
|
||||
let home: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalHome === undefined) delete process.env.HERMES_HOME
|
||||
else process.env.HERMES_HOME = originalHome
|
||||
if (home) await rm(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('disk profile inventory', () => {
|
||||
it('reads models and active selection for 20 profiles without scanning histories or skills', async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'hermes-profile-snapshot-'))
|
||||
process.env.HERMES_HOME = home
|
||||
await writeFile(join(home, 'config.yaml'), 'model: default-model\n')
|
||||
await writeFile(join(home, 'active_profile'), 'perf-19\n')
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const dir = join(home, 'profiles', `perf-${index}`)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'config.yaml'), `model:\n default: model-${index}\n`)
|
||||
// These are deliberately invalid; inventory must not parse session history.
|
||||
await writeFile(join(dir, 'state.db'), 'not a database')
|
||||
}
|
||||
const started = performance.now()
|
||||
const profiles = listProfilesSnapshot()
|
||||
console.info(`Disk inventory for 21 profiles: ${(performance.now() - started).toFixed(2)} ms`)
|
||||
expect(profiles).toHaveLength(21)
|
||||
expect(profiles.find(profile => profile.name === 'perf-19')).toMatchObject({ model: 'model-19', active: true })
|
||||
expect(profiles[0]).toMatchObject({ name: 'default', model: 'default-model', active: false })
|
||||
expect(execHermesWithBin).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,7 @@ const gatewayAutostartMocks = vi.hoisted(() => ({
|
||||
// Mock hermes-cli
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
listProfiles: vi.fn(),
|
||||
listProfilesSnapshot: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
createProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
@@ -313,6 +314,28 @@ describe('Profile Routes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists 20 profiles without waiting for CLI or embedding uploaded images', async () => {
|
||||
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-ui-profile-list-'))
|
||||
tempHomes.push(webUiHome)
|
||||
process.env.HERMES_WEB_UI_HOME = webUiHome
|
||||
const { list, updateAvatar, getAvatar } = await import('../../packages/server/src/controllers/hermes/profiles')
|
||||
await updateAvatar({ params: { name: 'work-0' }, request: { body: { type: 'image', dataUrl: 'data:image/png;base64,YQ==' } } })
|
||||
vi.mocked(hermesCli.listProfiles).mockReturnValue(new Promise(() => {}))
|
||||
vi.mocked(hermesCli.listProfilesSnapshot).mockReturnValue(Array.from({ length: 20 }, (_, index) => ({ name: `work-${index}`, active: false, model: 'test', alias: '' })))
|
||||
const ctx: any = { state: { profile: { name: 'work-0' } } }
|
||||
await list(ctx)
|
||||
expect(hermesCli.listProfiles).not.toHaveBeenCalled()
|
||||
expect(ctx.body.profiles).toHaveLength(20)
|
||||
expect(ctx.body.profiles[0]).toMatchObject({ active: true, avatar: { type: 'image' } })
|
||||
expect(ctx.body.profiles[0].avatar.dataUrl).toBeUndefined()
|
||||
const imageCtx: any = { params: { name: 'work-0' } }
|
||||
await getAvatar(imageCtx)
|
||||
expect(imageCtx.body.avatar.dataUrl).toBe('data:image/png;base64,YQ==')
|
||||
const denied: any = { params: { name: 'work-0' }, state: { user: { id: -123, role: 'admin' } } }
|
||||
await getAvatar(denied)
|
||||
expect(denied.status).toBe(403)
|
||||
})
|
||||
|
||||
describe('profile avatars', () => {
|
||||
it('stores generated avatar metadata under the Web UI home', async () => {
|
||||
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-ui-avatar-'))
|
||||
|
||||
Reference in New Issue
Block a user