diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts new file mode 100644 index 00000000000..41b6117d544 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as HermesModule from '@/hermes' +import { getSession } from '@/hermes' +import { $activeGatewayProfile, $profiles } from '@/store/profile' +import { $sessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { resolveSessionProfile, resolveStoredSession } from './utils' + +vi.mock('@/hermes', async importActual => ({ + ...(await importActual()), + getSession: vi.fn() +})) + +const mockGetSession = vi.mocked(getSession) + +const session = (over: Partial): SessionInfo => over as SessionInfo + +const profiles = (...names: string[]) => names.map(name => ({ name }) as never) + +describe('resolveStoredSession profile ownership', () => { + beforeEach(() => { + $sessions.set([]) + $profiles.set(profiles('default', 'meta')) + $activeGatewayProfile.set('meta') + mockGetSession.mockReset() + }) + + afterEach(() => { + $sessions.set([]) + $profiles.set([]) + $activeGatewayProfile.set('default') + }) + + it('returns a cached row that carries an owning profile', async () => { + $sessions.set([session({ id: 's1', profile: 'default' })]) + + const resolved = await resolveStoredSession('s1') + + expect(resolved?.profile).toBe('default') + expect(mockGetSession).not.toHaveBeenCalled() + }) + + it('treats a profile-less cache hit as unresolved when multiple profiles exist', async () => { + $sessions.set([session({ id: 's1' })]) + mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) + mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' })) + + const resolved = await resolveStoredSession('s1') + + expect(resolved?.profile).toBe('default') + // rung 2 (bare) then rung 3 (stamped cross-profile probe) + expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1') + expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'default') + }) + + it('accepts a profile-less cache hit for single-profile users', async () => { + $profiles.set(profiles('default')) + $sessions.set([session({ id: 's1' })]) + + const resolved = await resolveStoredSession('s1') + + expect(resolved?.id).toBe('s1') + expect(mockGetSession).not.toHaveBeenCalled() + }) + + it('stamps the active profile on a bare by-id hit from an older backend', async () => { + mockGetSession.mockResolvedValueOnce(session({ id: 's1' })) + + const resolved = await resolveStoredSession('s1') + + expect(resolved?.profile).toBe('meta') + // the upserted cache row is owned too, so the next hit short-circuits + expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta') + }) + + it('probed desktop profile overrides a remote backend answering as its own "default"', async () => { + // Per-profile remote override: Electron strips the desktop alias before + // forwarding, so the standalone backend stamps its backend-local root. + mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) + mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' })) + $activeGatewayProfile.set('default') + $profiles.set(profiles('default', 'meta')) + + const resolved = await resolveStoredSession('s1') + + expect(resolved?.profile).toBe('meta') + expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta') + }) + + it('stamps the probed profile on a scoped hit from an older backend that omits it', async () => { + mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) + mockGetSession.mockResolvedValueOnce(session({ id: 's1' })) + + const resolved = await resolveStoredSession('s1') + + expect(resolved?.profile).toBe('default') + // the cached row is owned too — no unowned row is ever re-cached + expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('default') + }) + + it('resolveSessionProfile routes a default-profile session from a non-default gateway', async () => { + mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) + mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' })) + + await expect(resolveSessionProfile('s1')).resolves.toBe('default') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index f6e19919f06..ffc799c432f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -587,7 +587,13 @@ function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { export async function resolveStoredSession(storedSessionId: string): Promise { const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) - if (cached) { + // A row with no owning profile can't route a resume when more than one + // profile exists — a resume without a profile lands on whichever gateway is + // active (#67603 family, cross-profile open asymmetry). Treat such a hit as + // unresolved and fall through to the by-id lookups, which stamp ownership. + const multiProfile = $profiles.get().length > 1 + + if (cached && (cached.profile?.trim() || !multiProfile)) { return cached } @@ -597,6 +603,12 @@ export async function resolveStoredSession(storedSessionId: string): Promise