diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index a32a91f3b11..96c32648419 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -1,8 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + getCronJobs, + getGlobalModelInfo, + getGlobalModelOptions, + getHermesConfig, + getHermesConfigDefaults, getProfiles, getSessionMessages, + getStatus, listAllProfileSessions, listSessions } from './hermes' @@ -89,6 +95,37 @@ describe('Hermes REST session helpers', () => { ) }) + it('gives the whole startup data burst the long timeout, not just profiles', async () => { + api.mockResolvedValue({}) + + const bootCalls: [() => Promise, 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' }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 1c248c05fd2..974517de85b 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -47,7 +47,18 @@ import type { ToolsetInfo } from '@/types/hermes' -export const STARTUP_PROFILE_REQUEST_TIMEOUT_MS = 60_000 +// Desktop startup fires a burst of read-only data calls (config, profiles, +// model info/options, cron) the moment the backend passes readiness. On a +// profile-heavy or remote install these can each take tens of seconds — e.g. +// /api/profiles runs list_profiles(), which does a recursive skill-tree walk +// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.cjs) +// times out a backend that is alive-but-busy, surfacing as a spurious +// "Timed out connecting to Hermes backend" that hangs the UI (#48504). +// +// Give the boot burst a generous per-call timeout instead of raising the +// global default: interactive/runtime calls and the liveness poll (/api/status) +// keep the short default so a genuinely-dead backend is still detected fast. +export const STARTUP_REQUEST_TIMEOUT_MS = 60_000 const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000 const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 // prompt.submit is effectively fire-and-forget: turn completion is signaled by @@ -288,7 +299,8 @@ export function renameSession( export function getGlobalModelInfo(): Promise { return window.hermesDesktop.api({ ...profileScoped(), - path: '/api/model/info' + path: '/api/model/info', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } @@ -334,7 +346,8 @@ export function getLogs(params: { export function getHermesConfig(): Promise { return window.hermesDesktop.api({ ...profileScoped(), - path: '/api/config' + path: '/api/config', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } @@ -348,7 +361,8 @@ export function getHermesConfigRecord(): Promise { export function getHermesConfigDefaults(): Promise { return window.hermesDesktop.api({ ...profileScoped(), - path: '/api/config/defaults' + path: '/api/config/defaults', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } @@ -639,7 +653,8 @@ export function testMessagingPlatform(platformId: string): Promise { return window.hermesDesktop.api({ - path: '/api/cron/jobs' + path: '/api/cron/jobs', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } @@ -704,7 +719,7 @@ export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { export function getProfiles(): Promise { return window.hermesDesktop.api({ path: '/api/profiles', - timeoutMs: STARTUP_PROFILE_REQUEST_TIMEOUT_MS + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } @@ -761,7 +776,8 @@ export function getUsageAnalytics(days = 30): Promise { export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise { return window.hermesDesktop.api({ ...profileScoped(), - path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options' + path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 38ec723e54b..79a9c530138 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -1,6 +1,6 @@ import { atom, computed } from 'nanostores' -import { getProfiles, setApiRequestProfile, STARTUP_PROFILE_REQUEST_TIMEOUT_MS } from '@/hermes' +import { getProfiles, setApiRequestProfile, STARTUP_REQUEST_TIMEOUT_MS } from '@/hermes' import { queryClient } from '@/lib/query-client' import { arraysEqual, @@ -112,7 +112,7 @@ export async function refreshActiveProfile(): Promise { try { const res = await window.hermesDesktop.api({ path: '/api/profiles/active', - timeoutMs: STARTUP_PROFILE_REQUEST_TIMEOUT_MS + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) setActiveProfile(res.current || 'default')