diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 14ced2e032..d8a72b07d8 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5634,6 +5634,12 @@ async function openStoredBotChat(owner, storedId, summary) { const hasAuthoritativeCount = typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count) const expectHistory = hasAuthoritativeCount ? summary.message_count > 0 : true + // Current SDKs export the Bot-specific budget. The fallback preserves + // compatibility with older hosts and isolated plugin test harnesses. + const hydrationTimeoutMs = + typeof sdk !== 'undefined' && Number.isFinite(sdk.BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS) + ? sdk.BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS + : 60_000 // A profile backend that just woke up can lose the hydration-timeout race // even though the session is fine (hermes-agent#89617) — clicking Retry @@ -5655,6 +5661,7 @@ async function openStoredBotChat(owner, storedId, summary) { awaitHydration: true, expectHistory, forceResume: true, + hydrationTimeoutMs, keepAllProfilesScope: true, workspaceMode: 'bots', workspaceOwnerKey: ownerKey, diff --git a/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs index 5fb267bb14..f085cba939 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs @@ -77,6 +77,11 @@ test('pref defaults OFF and persists via ctx.storage under activity-toasts', () assert.match(source, /storage\?\.get\?\.\('activity-toasts'\)/) }) +test('Bot Chat opens pass the exported 60-second hydration budget', () => { + assert.match(source, /sdk\.BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS/) + assert.match(source, /hydrationTimeoutMs,/) +}) + test('activity in the hidden canonical Bot Chat still badges (the "6d ago" class)', () => { // The canonical Bot Chat is hidden from session lists, so last_session // never advances when a DM lands there — only canonical_session does. diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index dc508b48cf..f1cf9d23f7 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -311,7 +311,12 @@ const $activeConnectionId = computed($connection, connection => { return connection.mode === 'local' ? 'local' : null }) -const DEFAULT_SESSION_HYDRATION_TIMEOUT_MS = 20_000 +/** Ordinary session opens fail fast when their gateway or socket is dead. */ +export const DEFAULT_SESSION_HYDRATION_TIMEOUT_MS = 20_000 +/** Cold Bot profiles get a larger per-attempt budget to start their backend + * and paint durable history. Bot Mode opts into one retry, so its effective + * ceiling is two bounded attempts rather than an unbounded wait. */ +export const BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS = 60_000 let openSessionGeneration = 0 export interface PluginOpenSessionOptions { diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 43d7039031..a06df3cbb9 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -104,7 +104,9 @@ vi.mock('@/store/gateway', async () => { } }) -const { host } = await import('./index') +const { BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS, DEFAULT_SESSION_HYDRATION_TIMEOUT_MS, host } = + await import('./index') + const { openSession: openSessionCore } = await import('@/app/open-session') const { deleteProfile, hermesApi } = await import('@/hermes') @@ -834,6 +836,65 @@ describe('profile-aware plugin session opens', () => { expect($gatewaySwapTarget.get()).toBeNull() }) + it('keeps the ordinary session hydration default at 20 seconds', async () => { + vi.useFakeTimers() + + try { + expect(DEFAULT_SESSION_HYDRATION_TIMEOUT_MS).toBe(20_000) + $activeGatewayProfile.set('hyoseob') + + const outcome = host + .openSession('slow-bot-chat', { + profile: 'hyoseob', + awaitHydration: true, + expectHistory: true + }) + .then( + () => 'resolved', + error => String(error) + ) + + await vi.advanceTimersByTimeAsync(19_999) + expect(setResumeExhaustedSessionId).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(await outcome).toMatch(/timed out loading/i) + expect(setResumeExhaustedSessionId).toHaveBeenCalledWith('slow-bot-chat') + } finally { + vi.useRealTimers() + } + }) + + it('honors the exported 60-second Bot Chat hydration override', async () => { + vi.useFakeTimers() + + try { + expect(BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS).toBe(60_000) + $activeGatewayProfile.set('hyoseob') + + const outcome = host + .openSession('slow-bot-chat', { + profile: 'hyoseob', + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS + }) + .then( + () => 'resolved', + error => String(error) + ) + + await vi.advanceTimersByTimeAsync(BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS - 1) + expect(setResumeExhaustedSessionId).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(await outcome).toMatch(/timed out loading/i) + expect(setResumeExhaustedSessionId).toHaveBeenCalledWith('slow-bot-chat') + } finally { + vi.useRealTimers() + } + }) + it('retries a Bot Chat hydration timeout once without ever arming the Retry surface (#89617)', async () => { $activeGatewayProfile.set('hyoseob')