diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx index c7ce8d050e..20299493ba 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx @@ -321,6 +321,46 @@ describe('useDesktopIntegrations', () => { }) }) + + + describe('resume-exhausted write barrier (#98467)', () => { + it('does not re-persist an exhausted session on a session-list refresh', () => { + window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/exhausted-session') + window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'exhausted-session') + + const sessions = [session({ id: 'exhausted-session', profile: 'default' })] + + const result = render({ profileReady: true, sessions }) + + // The cleanup effect drops the remembered exhausted session... + result.rerender({ + activeProfile: 'default', + locationPathname: '/exhausted-session', + profileReady: true, + resumeExhaustedSessionId: 'exhausted-session', + resumeLastSession: true, + routedSessionId: 'exhausted-session', + sessions + }) + + // ...and a routine session-list refresh (same sessions, ordinary re-render) + // must not write the dead id back into remembered navigation. + result.rerender({ + activeProfile: 'default', + locationPathname: '/exhausted-session', + profileReady: true, + resumeExhaustedSessionId: 'exhausted-session', + resumeLastSession: true, + routedSessionId: 'exhausted-session', + sessions: [...sessions, session({ id: 'other-session', profile: 'default' })] + }) + + expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBeNull() + expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBeNull() +>>>>>>> a771a14a49 (fix(desktop): stop re-persisting an exhausted session after cleanup (#98467)) + }) + }) + describe('ownership validation', () => { it('refuses to restore a session route owned by another profile', () => { window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/ai-session') diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index 4680537f1c..bf0b8f7538 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -239,7 +239,14 @@ export function useDesktopIntegrations({ // non-overlay route (a page like /skills, or a session route) per profile. // Session-shaped routes require an explicit matching owner; unresolved and // wrong-profile rows must not replace known-safe navigation. - if (routedSessionId && sessionBelongsToProfile(sessions, routedSessionId, activeProfile)) { + // The resume-exhausted session must not be written back into remembered + // navigation: the cleanup effect above drops it once, but this + // persistence effect re-runs on every session-list refresh while its + // deps are unchanged — without the barrier the dead id outlives every + // restart and the window boots into the resume-error screen each time. + const exhausted = routedSessionId !== null && routedSessionId === resumeExhaustedSessionId + + if (routedSessionId && !exhausted && sessionBelongsToProfile(sessions, routedSessionId, activeProfile)) { // A delegate child (source='subagent') is never itself a rememberable // destination: it is invisible in the sidebar, so a restart would resume // an orphan chat while the sidebar highlights its parent (#56983). @@ -266,6 +273,7 @@ export function useDesktopIntegrations({ locationPathname, navigate, profileReady, + resumeExhaustedSessionId, resumeLastSession, routedSessionId, sessions