mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS,
|
|
AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,
|
|
AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS,
|
|
AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
|
|
audioSpeakRequestTimeoutMs,
|
|
audioTranscribeRequestTimeoutMs,
|
|
getCronJobs,
|
|
getGlobalModelInfo,
|
|
getGlobalModelOptions,
|
|
getHermesConfig,
|
|
getHermesConfigDefaults,
|
|
getProfiles,
|
|
getSessionMessages,
|
|
getStatus,
|
|
listAllProfileSessions,
|
|
listSessions,
|
|
listSidebarSessions,
|
|
resetSidebarBatchCapability,
|
|
setApiRequestProfile,
|
|
speakText,
|
|
transcribeAudio
|
|
} from './hermes'
|
|
import { refreshActiveProfile } from './store/profile'
|
|
|
|
const emptySessionsResponse = {
|
|
limit: 0,
|
|
offset: 0,
|
|
sessions: [],
|
|
total: 0
|
|
}
|
|
|
|
describe('Hermes REST helpers', () => {
|
|
let api: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
resetSidebarBatchCapability()
|
|
api = vi.fn().mockResolvedValue(emptySessionsResponse)
|
|
Object.defineProperty(window, 'hermesDesktop', {
|
|
configurable: true,
|
|
value: { api }
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
setApiRequestProfile(null)
|
|
vi.restoreAllMocks()
|
|
Reflect.deleteProperty(window, 'hermesDesktop')
|
|
})
|
|
|
|
it('uses a longer timeout for the single-profile session list', async () => {
|
|
await listSessions(50, 1)
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: '/api/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses a longer timeout for the all-profile session list', async () => {
|
|
await listAllProfileSessions(50, 1)
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: '/api/profiles/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent&profile=all',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('batches the sidebar slices into a single request with per-slice limits + excludes', async () => {
|
|
api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
|
|
|
|
await listSidebarSessions({
|
|
recentsProfile: 'work',
|
|
recentsLimit: 30,
|
|
recentsExclude: ['cron', 'tool'],
|
|
cronLimit: 50,
|
|
messagingLimit: 100,
|
|
messagingExclude: ['cron', 'desktop']
|
|
})
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path:
|
|
'/api/profiles/sessions/sidebar?recents_profile=work&recents_limit=30&cron_limit=50' +
|
|
'&messaging_limit=100&recents_exclude=cron%2Ctool&messaging_exclude=cron%2Cdesktop',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('defaults missing sidebar slices to empty session arrays', async () => {
|
|
api.mockResolvedValue({})
|
|
|
|
const result = await listSidebarSessions({
|
|
recentsProfile: 'all',
|
|
recentsLimit: 20,
|
|
recentsExclude: [],
|
|
cronLimit: 50,
|
|
messagingLimit: 100,
|
|
messagingExclude: []
|
|
})
|
|
|
|
expect(result.recents.sessions).toEqual([])
|
|
expect(result.cron.sessions).toEqual([])
|
|
expect(result.messaging.sessions).toEqual([])
|
|
})
|
|
|
|
it('falls back to the per-slice endpoint when the batched route 404s on an older backend', async () => {
|
|
const row = (id: string) => ({ id, title: id, profile: 'default' })
|
|
|
|
api.mockImplementation(({ path }: { path: string }) => {
|
|
if (path.startsWith('/api/profiles/sessions/sidebar')) {
|
|
// The exact skew failure: Electron surfaces the backend catch-all.
|
|
return Promise.reject(
|
|
new Error(
|
|
'Error invoking remote method \'hermes:api\': Error: 404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}'
|
|
)
|
|
)
|
|
}
|
|
|
|
if (path.includes('source=cron')) {
|
|
return Promise.resolve({ ...emptySessionsResponse, sessions: [row('cron-1')], total: 1 })
|
|
}
|
|
|
|
if (path.includes('exclude_sources=cron%2Cdesktop')) {
|
|
return Promise.resolve({ ...emptySessionsResponse, sessions: [row('msg-1')], total: 1 })
|
|
}
|
|
|
|
return Promise.resolve({
|
|
...emptySessionsResponse,
|
|
sessions: [row('recent-1')],
|
|
total: 7,
|
|
profile_totals: { default: 7 }
|
|
})
|
|
})
|
|
|
|
const result = await listSidebarSessions({
|
|
recentsProfile: 'work',
|
|
recentsLimit: 30,
|
|
recentsExclude: ['cron', 'tool'],
|
|
cronLimit: 50,
|
|
messagingLimit: 100,
|
|
messagingExclude: ['cron', 'desktop']
|
|
})
|
|
|
|
// Slices reassembled from the legacy per-slice route with the same
|
|
// scoping: recents on the caller's profile, cron + messaging cross-profile.
|
|
expect(result.recents.sessions.map(s => s.id)).toEqual(['recent-1'])
|
|
// One row back against a 30-row window: the profile is fully loaded, so
|
|
// the legacy path must not claim there's another page.
|
|
expect(result.recents.profiles_truncated).toEqual({ default: false })
|
|
expect(result.cron.sessions.map(s => s.id)).toEqual(['cron-1'])
|
|
expect(result.messaging.sessions.map(s => s.id)).toEqual(['msg-1'])
|
|
|
|
const paths = api.mock.calls.map(call => (call[0] as { path: string }).path)
|
|
expect(paths.filter(p => p.startsWith('/api/profiles/sessions/sidebar'))).toHaveLength(1)
|
|
expect(paths.filter(p => p.startsWith('/api/profiles/sessions?'))).toHaveLength(3)
|
|
expect(paths).toContainEqual(expect.stringContaining('profile=work'))
|
|
expect(paths).toContainEqual(expect.stringContaining('source=cron'))
|
|
expect(paths).toContainEqual(expect.stringContaining('exclude_sources=cron%2Ctool'))
|
|
})
|
|
|
|
it('remembers endpoint-missing and skips re-probing the batched route on later refreshes', async () => {
|
|
api.mockImplementation(({ path }: { path: string }) =>
|
|
path.startsWith('/api/profiles/sessions/sidebar')
|
|
? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}'))
|
|
: Promise.resolve(emptySessionsResponse)
|
|
)
|
|
|
|
const req = {
|
|
recentsProfile: 'all' as const,
|
|
recentsLimit: 20,
|
|
recentsExclude: [],
|
|
cronLimit: 50,
|
|
messagingLimit: 100,
|
|
messagingExclude: []
|
|
}
|
|
|
|
await listSidebarSessions(req)
|
|
await listSidebarSessions(req)
|
|
|
|
const batchedProbes = api.mock.calls.filter(call =>
|
|
(call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar')
|
|
)
|
|
|
|
// First refresh probes once and learns; the second goes straight to the
|
|
// per-slice route (3 calls each refresh, no repeated dead probe).
|
|
expect(batchedProbes).toHaveLength(1)
|
|
expect(api.mock.calls.length).toBe(1 + 3 + 3)
|
|
})
|
|
|
|
it('re-probes the batched route after a gateway switch resets the capability flag', async () => {
|
|
api.mockImplementation(({ path }: { path: string }) =>
|
|
path.startsWith('/api/profiles/sessions/sidebar')
|
|
? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}'))
|
|
: Promise.resolve(emptySessionsResponse)
|
|
)
|
|
|
|
const req = {
|
|
recentsProfile: 'all' as const,
|
|
recentsLimit: 20,
|
|
recentsExclude: [],
|
|
cronLimit: 50,
|
|
messagingLimit: 100,
|
|
messagingExclude: []
|
|
}
|
|
|
|
await listSidebarSessions(req)
|
|
// Soft gateway switch: the next backend may support the batched route.
|
|
resetSidebarBatchCapability()
|
|
api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
|
|
|
|
const result = await listSidebarSessions(req)
|
|
|
|
const batchedProbes = api.mock.calls.filter(call =>
|
|
(call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar')
|
|
)
|
|
|
|
expect(batchedProbes).toHaveLength(2)
|
|
expect(result.recents.sessions).toEqual([])
|
|
})
|
|
|
|
it('does NOT fall back on transient failures — only endpoint-missing shapes trigger legacy mode', async () => {
|
|
api.mockRejectedValue(new Error('Request timed out after 60000ms'))
|
|
|
|
await expect(
|
|
listSidebarSessions({
|
|
recentsProfile: 'all',
|
|
recentsLimit: 20,
|
|
recentsExclude: [],
|
|
cronLimit: 50,
|
|
messagingLimit: 100,
|
|
messagingExclude: []
|
|
})
|
|
).rejects.toThrow('timed out')
|
|
|
|
// One call: the batched probe. No legacy fan-out, no sticky degradation.
|
|
expect(api).toHaveBeenCalledTimes(1)
|
|
|
|
// And the next refresh still uses the batched route.
|
|
api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
|
|
await listSidebarSessions({
|
|
recentsProfile: 'all',
|
|
recentsLimit: 20,
|
|
recentsExclude: [],
|
|
cronLimit: 50,
|
|
messagingLimit: 100,
|
|
messagingExclude: []
|
|
})
|
|
|
|
expect((api.mock.calls[1][0] as { path: string }).path).toMatch(/^\/api\/profiles\/sessions\/sidebar\?/)
|
|
})
|
|
|
|
it('uses a longer timeout for profile listing during desktop startup', async () => {
|
|
api.mockResolvedValue({ profiles: [] })
|
|
|
|
await getProfiles()
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: '/api/profiles',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses a longer timeout for active profile refresh during desktop startup', async () => {
|
|
api.mockResolvedValueOnce({ current: 'default' }).mockResolvedValueOnce({ profiles: [] })
|
|
|
|
await refreshActiveProfile()
|
|
|
|
expect(api).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
path: '/api/profiles/active',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
expect(api).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
path: '/api/profiles',
|
|
timeoutMs: 60_000
|
|
})
|
|
)
|
|
})
|
|
|
|
it('gives the whole startup data burst the long timeout, not just profiles', async () => {
|
|
api.mockResolvedValue({})
|
|
|
|
const bootCalls: [() => Promise<unknown>, string][] = [
|
|
[getHermesConfig, '/api/config'],
|
|
[getHermesConfigDefaults, '/api/config/defaults'],
|
|
[getGlobalModelInfo, '/api/model/info'],
|
|
[() => getGlobalModelOptions(), '/api/model/options?explicit_only=1'],
|
|
[getCronJobs, '/api/cron/jobs']
|
|
]
|
|
|
|
for (const [call, path] of bootCalls) {
|
|
api.mockClear()
|
|
await call()
|
|
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path, timeoutMs: 60_000 }))
|
|
}
|
|
})
|
|
|
|
it('keeps the liveness poll on the short default so a dead backend fails fast', async () => {
|
|
api.mockResolvedValue({})
|
|
api.mockClear()
|
|
|
|
await getStatus()
|
|
|
|
// /api/status must NOT carry the long startup timeout — it is the runtime
|
|
// liveness probe and has to fail quickly when the backend drops.
|
|
const call = api.mock.calls[0]?.[0] as { path: string; timeoutMs?: number }
|
|
expect(call.path).toBe('/api/status')
|
|
expect(call.timeoutMs).toBeUndefined()
|
|
})
|
|
|
|
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
|
|
api.mockResolvedValue({ messages: [], session_id: 'session-1' })
|
|
|
|
await getSessionMessages('session-1', 'xiaoxuxu')
|
|
|
|
expect(api).toHaveBeenCalledWith({
|
|
path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
|
|
profile: 'xiaoxuxu'
|
|
})
|
|
})
|
|
|
|
it('bounds blocking TTS synthesis timeouts by text length', () => {
|
|
expect(audioSpeakRequestTimeoutMs('short message')).toBe(AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS)
|
|
expect(audioSpeakRequestTimeoutMs('x'.repeat(8_000))).toBe(280_000)
|
|
expect(audioSpeakRequestTimeoutMs('x'.repeat(100_000))).toBe(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS)
|
|
})
|
|
|
|
it('routes blocking TTS synthesis through the active profile backend', async () => {
|
|
setApiRequestProfile('rhaegal')
|
|
api.mockResolvedValueOnce({
|
|
data_url: 'data:audio/mpeg;base64,AA==',
|
|
mime_type: 'audio/mpeg',
|
|
ok: true,
|
|
provider: 'openai'
|
|
})
|
|
|
|
await expect(speakText('Read this aloud')).resolves.toEqual({
|
|
data_url: 'data:audio/mpeg;base64,AA==',
|
|
mime_type: 'audio/mpeg',
|
|
ok: true,
|
|
provider: 'openai'
|
|
})
|
|
|
|
expect(api).toHaveBeenCalledWith({
|
|
body: { text: 'Read this aloud' },
|
|
method: 'POST',
|
|
path: '/api/audio/speak',
|
|
profile: 'rhaegal',
|
|
timeoutMs: AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS
|
|
})
|
|
})
|
|
|
|
it('bounds blocking transcription timeouts by payload length', () => {
|
|
expect(audioTranscribeRequestTimeoutMs('data:audio/webm;base64,AA==')).toBe(AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS)
|
|
expect(audioTranscribeRequestTimeoutMs('x'.repeat(3_000_000))).toBe(300_000)
|
|
expect(audioTranscribeRequestTimeoutMs('x'.repeat(9_000_000))).toBe(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS)
|
|
})
|
|
|
|
it('uses an extended timeout for blocking transcription', async () => {
|
|
api.mockResolvedValueOnce({
|
|
ok: true,
|
|
provider: 'openai',
|
|
text: 'transcribed text'
|
|
})
|
|
|
|
await expect(transcribeAudio('data:audio/webm;base64,AA==', 'audio/webm')).resolves.toEqual({
|
|
ok: true,
|
|
provider: 'openai',
|
|
text: 'transcribed text'
|
|
})
|
|
|
|
expect(api).toHaveBeenCalledWith({
|
|
body: { data_url: 'data:audio/webm;base64,AA==', mime_type: 'audio/webm' },
|
|
method: 'POST',
|
|
path: '/api/audio/transcribe',
|
|
timeoutMs: AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS
|
|
})
|
|
})
|
|
|
|
it('defaults model options to configured providers only', async () => {
|
|
await getGlobalModelOptions()
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: '/api/model/options?explicit_only=1'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('can opt into unconfigured providers for onboarding flows', async () => {
|
|
await getGlobalModelOptions({ includeUnconfigured: true, refresh: true, explicitOnly: false })
|
|
|
|
expect(api).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: '/api/model/options?refresh=1&include_unconfigured=1'
|
|
})
|
|
)
|
|
})
|
|
})
|