Merge pull request #89833 from NousResearch/bb/scope-session-lookup

fix(desktop): scope session lookup to the active profile (supersedes #79522)
This commit is contained in:
brooklyn!
2026-08-19 10:57:17 -05:00
committed by GitHub
2 changed files with 39 additions and 51 deletions
@@ -62,31 +62,33 @@ describe('resolveStoredSession profile ownership', () => {
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.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 probes: active profile first)
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1')
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'meta')
expect(mockGetSession).toHaveBeenNthCalledWith(3, 's1', 'default')
// rung 2 (active profile) then rung 3 (stamped cross-profile probe)
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1', 'meta')
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'default')
})
it('probes the ACTIVE profile when the unscoped lookup 404s (hidden Bot Mode chat)', async () => {
// A hidden Bot Mode canonical chat is never in the sidebar cache, and the
// unscoped GET routes to the PRIMARY backend (404). The owner is the
// active profile itself — it must be probed, not skipped.
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1', message_count: 2 }))
it('scopes the first by-id lookup so a miss does not skip the active profile', async () => {
$activeGatewayProfile.set('brain')
$profiles.set(profiles('default', 'brain'))
mockGetSession.mockImplementation(async (id, profile) => {
if (profile === 'brain') {
return session({ id, profile: 'brain' })
}
throw new Error('404: Session not found')
})
const resolved = await resolveStoredSession('s1')
expect(resolved?.profile).toBe('meta')
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1')
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'meta')
expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta')
expect(resolved?.profile).toBe('brain')
expect(mockGetSession).toHaveBeenCalledWith('s1', 'brain')
expect(mockGetSession).not.toHaveBeenCalledWith('s1')
expect(mockGetSession).not.toHaveBeenCalledWith('s1', 'default')
})
it('accepts a profile-less cache hit for single-profile users', async () => {
@@ -105,6 +107,7 @@ describe('resolveStoredSession profile ownership', () => {
const resolved = await resolveStoredSession('s1')
expect(resolved?.profile).toBe('meta')
expect(mockGetSession).toHaveBeenCalledWith('s1', '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')
})
@@ -113,7 +116,6 @@ describe('resolveStoredSession profile ownership', () => {
// 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.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' }))
$activeGatewayProfile.set('default')
$profiles.set(profiles('default', 'meta'))
@@ -125,7 +127,6 @@ describe('resolveStoredSession profile ownership', () => {
})
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.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1' }))
@@ -137,7 +138,6 @@ describe('resolveStoredSession profile ownership', () => {
})
it('resolveSessionProfile routes a default-profile session from a non-default gateway', async () => {
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' }))
@@ -1307,17 +1307,19 @@ export async function resolveStoredSession(storedSessionId: string): Promise<Ses
return cached
}
// Direct by-id on the live backend — one row lookup, no list scan. Covers
// single-profile users and any id on the active profile (e.g. an old session
// past the sidebar's recent window). 404 just means it's not on this profile.
try {
const session = await getSession(storedSessionId)
// Direct by-id on the active profile — one row lookup, no list scan. Electron
// routes an unscoped GET to the primary backend, which may not own the
// active profile. A 404 there used to skip that profile in the probes below,
// so the session was never found.
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
// Older backends omit `profile` on unscoped GETs; the serving backend is
// the active gateway's, so back-fill that rather than caching an unowned
// row. A present stamp is preserved: in app-global remote mode a bare hit
// can legitimately carry another profile's row (see the branch tests).
session.profile ||= normalizeProfileKey($activeGatewayProfile.get())
try {
const session = await getSession(storedSessionId, activeKey)
// Older backends can omit `profile`; this request targeted the active
// profile, so back-fill that rather than caching an unowned row. A present
// stamp is preserved for backend compatibility.
session.profile ||= activeKey
upsertResolvedSession(session, storedSessionId)
@@ -1326,30 +1328,16 @@ export async function resolveStoredSession(storedSessionId: string): Promise<Ses
// Not on the active profile — fall through to the cross-profile probe.
}
// Multi-profile only: probe each profile by id (still one cheap lookup
// each) rather than pulling every profile's recent sessions. The first hit
// carries its owning `profile`, which routes the resume to the right backend.
//
// The ACTIVE profile is probed too, and first. The unscoped rung above does
// NOT cover it: Electron routes a profile-less /api/sessions GET to the
// PRIMARY backend, not the active gateway's, so a session owned by the
// active non-primary profile 404s there. Skipping the active key then left
// it as the ONE profile never probed — a hidden Bot Mode chat (never in the
// sidebar cache) owned by the focused bot resolved to undefined on every
// switch, the transcript prefetch went unscoped to the primary, and the
// thread painted empty until an explicitly-profiled row (right-click →
// Sessions) seeded the cache.
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
// Multi-profile only: probe each remaining profile by id (still one cheap
// lookup each) rather than pulling every profile's recent sessions. The
// first hit carries its owning `profile`, which routes the resume to the
// right backend. The active profile was already tried above.
const otherProfiles = $profiles
.get()
.map(profile => normalizeProfileKey(profile.name))
.filter(key => key !== activeKey)
const probeProfiles = [
activeKey,
...$profiles
.get()
.map(profile => normalizeProfileKey(profile.name))
.filter(key => key !== activeKey)
]
for (const profile of probeProfiles) {
for (const profile of otherProfiles) {
try {
const session = await getSession(storedSessionId, profile)