hermes-agent/apps/desktop/src/hermes.test.ts
Brooklyn Nicholson 4aad27b751 fix(desktop): extend startup long-timeout to the whole boot data burst
Broadens Tranquil-Flow's profile-startup timeout fix (#48518) from getProfiles
+ refreshActiveProfile to the rest of the calls the desktop fires during
connect: /api/config, /api/config/defaults, /api/model/info, /api/model/options,
/api/cron/jobs. On a profile-heavy or remote install any of these can exceed
the 15s DEFAULT_FETCH_TIMEOUT_MS while the backend is alive-but-busy (e.g.
list_profiles walks the skill tree per profile), surfacing as the spurious
"Timed out connecting to Hermes backend after 15000ms" that hangs the UI
(#48504).

Uses the surgical per-call mechanism (renamed STARTUP_PROFILE_REQUEST_TIMEOUT_MS
→ STARTUP_REQUEST_TIMEOUT_MS) rather than raising the global default (the
alternative in #48526): the liveness poll /api/status and all interactive/
runtime calls keep the short default, so a genuinely-dead backend is still
detected fast and the boot readiness probe (waitForHermes) is untouched.

Supersedes #48518 (carried as the base commit) and #48526 (global-default
raise). Fixes #48504.

Co-authored-by: YapBi <129007007+HeLLGURD@users.noreply.github.com>
Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>
2026-07-02 19:36:43 -05:00

139 lines
3.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getCronJobs,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfig,
getHermesConfigDefaults,
getProfiles,
getSessionMessages,
getStatus,
listAllProfileSessions,
listSessions
} from './hermes'
import { refreshActiveProfile } from './store/profile'
const emptySessionsResponse = {
limit: 0,
offset: 0,
sessions: [],
total: 0
}
describe('Hermes REST session helpers', () => {
let api: ReturnType<typeof vi.fn>
beforeEach(() => {
api = vi.fn().mockResolvedValue(emptySessionsResponse)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
})
afterEach(() => {
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('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'],
[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'
})
})
})