From 9059cac398b42579b3197cae6ccbd517bee509d7 Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Tue, 23 Jun 2026 23:25:19 +0700 Subject: [PATCH 1/4] fix(desktop): keep configured onboarding state on fallback runtime probes --- apps/desktop/src/store/onboarding.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 927c0911ddabf..17a12621e6c33 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -188,6 +188,16 @@ async function checkRuntime(ctx: OnboardingContext): Promise Date: Tue, 23 Jun 2026 23:25:25 +0700 Subject: [PATCH 2/4] test(desktop): cover fallback timeout onboarding downgrade regression --- apps/desktop/src/store/onboarding.test.ts | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 7173e89f572cd..ffd06b912d0d9 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -63,6 +63,16 @@ function onboardingContext(requestGateway: OnboardingContext['requestGateway']): return { requestGateway } } +function fallbackTimeoutGateway(): OnboardingContext['requestGateway'] { + return async method => { + if (method === 'setup.status' || method === 'setup.runtime_check') { + throw new Error(`request timed out: ${method}`) + } + + throw new Error(`unexpected gateway method: ${method}`) + } +} + describe('refreshOnboarding', () => { beforeEach(() => { window.localStorage.clear() @@ -116,6 +126,54 @@ describe('refreshOnboarding', () => { expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['cached']) }) + it('does not downgrade configured=true on fallback-only readiness failures', async () => { + const api = vi.fn(async ({ path }: { path: string }) => { + if (path === '/api/providers/oauth') { + return { providers: [provider('fresh')] } + } + + throw new Error(`unexpected api path: ${path}`) + }) + + installApiMock(api) + $desktopOnboarding.set( + baseState({ + configured: true, + providers: [provider('cached')], + reason: null, + requested: false + }) + ) + + const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway())) + + expect(ready).toBe(false) + expect(api).not.toHaveBeenCalled() + expect($desktopOnboarding.get().configured).toBe(true) + expect($desktopOnboarding.get().reason).toBeNull() + expect(window.localStorage.getItem('hermes-desktop-onboarded-v1')).toBeNull() + }) + + it('still surfaces onboarding when fallback failure happens before configured state', async () => { + const api = vi.fn(async ({ path }: { path: string }) => { + if (path === '/api/providers/oauth') { + return { providers: [provider('fresh')] } + } + + throw new Error(`unexpected api path: ${path}`) + }) + + installApiMock(api) + $desktopOnboarding.set(baseState({ configured: false, providers: null, requested: true })) + + const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway())) + + expect(ready).toBe(false) + expect(api).toHaveBeenCalledTimes(1) + expect($desktopOnboarding.get().configured).toBe(false) + expect($desktopOnboarding.get().reason).toContain('request timed out') + }) + it('deduplicates concurrent provider refresh calls', async () => { let resolveProviders!: (value: { providers: OAuthProvider[] }) => void From 980027c13740087419580697b2fe28cdf3c51b8c Mon Sep 17 00:00:00 2001 From: kshitijk4poor Date: Wed, 24 Jun 2026 18:11:10 +0530 Subject: [PATCH 3/4] fix(desktop): show non-blocking notification on fallback runtime probe When shouldPreserveConfiguredOnFallback keeps configured=true, also call notifyError('runtime-not-ready', ...) so the user knows the backend wasn't verified instead of silently proceeding. Adapted from @mohamedorigami-jpg's approach in PR #37634. --- apps/desktop/src/store/onboarding.test.ts | 25 +++++++++++++++++++++++ apps/desktop/src/store/onboarding.ts | 8 ++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index ffd06b912d0d9..0184ac5b64b40 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -154,6 +154,31 @@ describe('refreshOnboarding', () => { expect(window.localStorage.getItem('hermes-desktop-onboarded-v1')).toBeNull() }) + it('shows a non-blocking notification when preserving configured on fallback', async () => { + const notifyErrorSpy = vi.spyOn( + await import('@/store/notifications'), + 'notifyError' + ) + + installApiMock(vi.fn()) + $desktopOnboarding.set( + baseState({ + configured: true, + providers: [provider('cached')], + reason: null, + requested: false + }) + ) + + await refreshOnboarding(onboardingContext(fallbackTimeoutGateway())) + + expect(notifyErrorSpy).toHaveBeenCalledWith( + 'runtime-not-ready', + expect.stringContaining('could not verify the running backend') + ) + expect($desktopOnboarding.get().configured).toBe(true) + }) + it('still surfaces onboarding when fallback failure happens before configured state', async () => { const api = vi.fn(async ({ path }: { path: string }) => { if (path === '/api/providers/oauth') { diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 17a12621e6c33..b5de18d64b5b0 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -526,6 +526,14 @@ export async function refreshOnboarding(ctx: OnboardingContext) { const state = $desktopOnboarding.get() if (shouldPreserveConfiguredOnFallback(runtime, state)) { + // Gateway probes timed out but the user was already configured — don't + // downgrade to the blocking onboarding overlay. Surface a non-blocking + // notification so the user knows the backend wasn't verified. + notifyError( + 'runtime-not-ready', + 'Hermes Desktop could not verify the running backend on startup. Some features may be unavailable until the gateway is reachable.' + ) + return false } From 66b23d9fc239fe4e37eaa9209854cc21840c4e19 Mon Sep 17 00:00:00 2001 From: kshitijk4poor Date: Wed, 24 Jun 2026 18:20:45 +0530 Subject: [PATCH 4/4] fix(desktop): use notify() with stable id for fallback notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes-pr-review findings: - notifyError('runtime-not-ready', msg) misused the (error, fallback) API: the key became the notification body and the message became the title. Switch to notify({ id, kind, title, message }) which puts content in the right slots. - The stable id 'runtime-not-ready' deduplicates: notify() replaces by id, so repeated refreshOnboarding calls during an outage no longer stack up to 4 persistent error toasts. - Remove dead !state.manual guard from shouldPreserveConfiguredOnFallback: refreshOnboarding already short-circuits on manual before the helper. - Test: seed localStorage with '1' before asserting it survives (was testing the wrong invariant — null in, null out). - Test: use static import for spy instead of fragile await import. - Test: add negative case for requested=true + configured=true (should still downgrade — requested overrides preservation). --- apps/desktop/src/store/onboarding.test.ts | 47 +++++++++++++++++++---- apps/desktop/src/store/onboarding.ts | 15 +++++--- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 0184ac5b64b40..806dafc7bf611 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OAuthProvider } from '@/types/hermes' +import * as notifications from '@/store/notifications' + import { $desktopOnboarding, type DesktopOnboardingState, @@ -136,6 +138,8 @@ describe('refreshOnboarding', () => { }) installApiMock(api) + // Simulate a returning user: cache is set and store is configured. + window.localStorage.setItem('hermes-desktop-onboarded-v1', '1') $desktopOnboarding.set( baseState({ configured: true, @@ -151,14 +155,12 @@ describe('refreshOnboarding', () => { expect(api).not.toHaveBeenCalled() expect($desktopOnboarding.get().configured).toBe(true) expect($desktopOnboarding.get().reason).toBeNull() - expect(window.localStorage.getItem('hermes-desktop-onboarded-v1')).toBeNull() + // The cache must survive the refresh — proving we didn't downgrade. + expect(window.localStorage.getItem('hermes-desktop-onboarded-v1')).toBe('1') }) it('shows a non-blocking notification when preserving configured on fallback', async () => { - const notifyErrorSpy = vi.spyOn( - await import('@/store/notifications'), - 'notifyError' - ) + const notifySpy = vi.spyOn(notifications, 'notify') installApiMock(vi.fn()) $desktopOnboarding.set( @@ -172,13 +174,42 @@ describe('refreshOnboarding', () => { await refreshOnboarding(onboardingContext(fallbackTimeoutGateway())) - expect(notifyErrorSpy).toHaveBeenCalledWith( - 'runtime-not-ready', - expect.stringContaining('could not verify the running backend') + expect(notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'runtime-not-ready', + kind: 'error' + }) ) expect($desktopOnboarding.get().configured).toBe(true) }) + it('does not preserve configured when onboarding was explicitly requested', async () => { + const api = vi.fn(async ({ path }: { path: string }) => { + if (path === '/api/providers/oauth') { + return { providers: [provider('fresh')] } + } + + throw new Error(`unexpected api path: ${path}`) + }) + + installApiMock(api) + $desktopOnboarding.set( + baseState({ + configured: true, + providers: [provider('cached')], + reason: null, + requested: true + }) + ) + + const ready = await refreshOnboarding(onboardingContext(fallbackTimeoutGateway())) + + expect(ready).toBe(false) + // requested overrides preservation — should downgrade. + expect($desktopOnboarding.get().configured).toBe(false) + expect(api).toHaveBeenCalledTimes(1) + }) + it('still surfaces onboarding when fallback failure happens before configured state', async () => { const api = vi.fn(async ({ path }: { path: string }) => { if (path === '/api/providers/oauth') { diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index b5de18d64b5b0..33c32a1033b87 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -195,7 +195,7 @@ function shouldPreserveConfiguredOnFallback( // A fallback result means both runtime probes were non-authoritative // (transport timeout/disconnect). Keep a previously verified configured // state instead of forcing the blocking onboarding overlay. - return runtime.source === 'fallback' && state.configured === true && !state.requested && !state.manual + return runtime.source === 'fallback' && state.configured === true && !state.requested } function notifyReady(provider: string) { @@ -528,11 +528,14 @@ export async function refreshOnboarding(ctx: OnboardingContext) { if (shouldPreserveConfiguredOnFallback(runtime, state)) { // Gateway probes timed out but the user was already configured — don't // downgrade to the blocking onboarding overlay. Surface a non-blocking - // notification so the user knows the backend wasn't verified. - notifyError( - 'runtime-not-ready', - 'Hermes Desktop could not verify the running backend on startup. Some features may be unavailable until the gateway is reachable.' - ) + // notification with a stable id so repeated calls during an outage dedup + // instead of stacking toasts. + notify({ + id: 'runtime-not-ready', + kind: 'error', + title: 'Runtime not ready', + message: 'Hermes Desktop could not verify the running backend on startup. Some features may be unavailable until the gateway is reachable.' + }) return false }