Files
hermes-agent/apps/desktop/src/store/session.test.ts
T
hermes-seaeye[bot] bcef556b00 fmt(js): npm run fix on merge (#101481)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-02 17:18:47 +00:00

1477 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { SessionInfo } from '@/types/hermes'
const setUnreadRemote = vi.fn<(id: string, unread: boolean, profile?: null | string) => Promise<{ ok: boolean }>>(() =>
Promise.resolve({ ok: true })
)
vi.mock('@/hermes', () => ({
// Opening a session now PATCHes its persisted unread flag (clearUnreadOnOpen
// -> markSessionUnread); keep the REST mutation minimal for the suite.
setApiRequestProfile: () => {},
setSessionUnreadRemote: (id: string, unread: boolean, profile?: null | string) => setUnreadRemote(id, unread, profile)
}))
import { deferred } from '../test/deferred'
import { makeSessionInfo } from '../test/session-info'
import {
$activeSessionId,
$connection,
$currentCwd,
$currentModel,
$currentProvider,
$selectedStoredSessionId,
$sessions,
$unreadFinishedSessionIds,
_resetLegacyDiscardForTests,
_resetSessionOwnerHintsForTests,
applyConfiguredDefaultProjectDir,
carryForwardFailedProfileSessions,
commitWorkspaceCwdForSelectedSession,
ensureDefaultWorkspaceCwd,
forgetSessionOwnerHintsForConnection,
forgetSessionOwnerHintsForSession,
getConfiguredDefaultProjectDir,
getRememberedRoute,
getRememberedSessionId,
getRememberedWorkspaceCwd,
getSessionOwnerHint,
getSessionOwnerHints,
hydrateSessionOwnerHints,
keepFailedProfileMeta,
knownSessionOwner,
knownSessionProfile,
lineageAliases,
mergeSessionPage,
rememberedSessionProfile,
resolveComposerSessionKey,
sessionBelongsToProfile,
sessionMatchesStoredId,
sessionOwnerRouteFromRow,
sessionPinId,
setComposerSelectionOwner,
setConnection,
setCurrentCwd,
setCurrentCwdTransient,
setCurrentModel,
setCurrentModelSource,
setCurrentProvider,
setRememberedRoute,
setRememberedSessionId,
setSelectedStoredSessionId,
setSessionOwnerHint,
setSessions,
shouldMigrateComposerScope,
touchSessionActivity,
workspaceCwdForNewSession
} from './session'
import {
$attentionSessionIds,
clearAllSessionStates,
getRecentlySettledSessionIds,
publishSessionState
} from './session-states'
const session = (over: Partial<SessionInfo>): SessionInfo => makeSessionInfo({ id: 'live', ...over })
describe('composer model persistence scope', () => {
const local = { baseUrl: '', connectionId: 'local', mode: 'local' } as never
beforeEach(() => {
window.localStorage.clear()
setConnection(local)
setCurrentModel('')
setCurrentProvider('')
setCurrentModelSource('')
})
afterEach(() => {
setConnection(local)
window.localStorage.clear()
})
it('keeps manual model selections isolated by remote connection and profile', () => {
const remote = (profile: string) =>
({ baseUrl: 'https://aibox.example', connectionId: 'aibox', mode: 'remote', profile }) as never
setConnection(remote('fred'))
setCurrentModel('grok-4')
setCurrentProvider('xai-oauth')
setCurrentModelSource('manual')
setConnection(remote('fred-work'))
expect($currentModel.get()).toBe('')
expect($currentProvider.get()).toBe('')
setCurrentModel('local/model')
setCurrentProvider('custom:local')
setConnection(remote('fred'))
expect($currentModel.get()).toBe('grok-4')
expect($currentProvider.get()).toBe('xai-oauth')
})
it('keeps inferred local-primary connections on the historical bare keys', () => {
setComposerSelectionOwner('remote', 'default')
window.localStorage.setItem('hermes.desktop.composer.model', 'legacy-model')
window.localStorage.setItem('hermes.desktop.composer.provider', 'legacy-provider')
setConnection({ baseUrl: '', connectionId: 'local', mode: 'local', profile: 'default' } as never)
expect($currentModel.get()).toBe('legacy-model')
expect($currentProvider.get()).toBe('legacy-provider')
setCurrentModel('next-model')
expect(window.localStorage.getItem('hermes.desktop.composer.model')).toBe('next-model')
expect(window.localStorage.getItem('hermes.desktop.composer.model.registry.local.default')).toBeNull()
})
it('uses the live registry owner when the connection descriptor is stale', () => {
const remote = (profile: string) =>
({ baseUrl: 'https://aibox.example', connectionId: 'aibox', mode: 'remote', profile }) as never
setConnection(remote('fred'))
setCurrentModel('grok-4')
setCurrentProvider('xai-oauth')
setCurrentModelSource('manual')
// ensureGatewayAgent publishes this coordinate even if getConnectionFor
// fails and $connection therefore still describes fred.
setComposerSelectionOwner('aibox', 'fred-work')
setCurrentModel('local/model')
setCurrentProvider('custom:local')
setCurrentModelSource('default')
setComposerSelectionOwner('aibox', 'fred')
expect($currentModel.get()).toBe('grok-4')
expect($currentProvider.get()).toBe('xai-oauth')
setComposerSelectionOwner('aibox', 'fred-work')
expect($currentModel.get()).toBe('local/model')
expect($currentProvider.get()).toBe('custom:local')
})
})
describe('session owner hints', () => {
afterEach(() => {
_resetSessionOwnerHintsForTests({ storage: true })
})
it('preserves the registry owner recorded on a discovered session row', () => {
expect(
knownSessionOwner(
[session({ connection_id: 'test-amnezia', id: 'registry-session', profile: 'default' })],
'registry-session'
)
).toEqual({ connectionId: 'test-amnezia', profile: 'default' })
})
it('preserves the exact registry owner for session-scoped RPC routing', () => {
const route = {
connectionId: 'test-amnezia',
mode: 'remote' as const,
profile: 'default',
targetProfile: 'default'
}
setSessionOwnerHint('remote-session', route)
expect(knownSessionOwner([session({ id: 'remote-session', profile: 'default' })], 'remote-session')).toEqual(route)
})
it('keeps identical session ids separate across connection and profile owners', () => {
const sourceA = { connectionId: 'source-a', mode: 'remote' as const, profile: 'worker', targetProfile: 'backend-a' }
const sourceB = { connectionId: 'source-b', mode: 'remote' as const, profile: 'worker', targetProfile: 'backend-b' }
setSessionOwnerHint('same-session', sourceA)
setSessionOwnerHint('same-session', sourceB)
expect(getSessionOwnerHint('same-session', sourceA)).toEqual(sourceA)
expect(getSessionOwnerHint('same-session', sourceB)).toEqual(sourceB)
expect(getSessionOwnerHint('same-session')).toBeUndefined()
})
it('bounds owner hints and evicts the oldest scoped identity', () => {
for (let index = 0; index < 257; index += 1) {
setSessionOwnerHint(`bounded-${index}`, {
connectionId: 'bounded-source',
mode: 'remote',
profile: 'worker',
targetProfile: 'backend-worker'
})
}
const scope = { connectionId: 'bounded-source', profile: 'worker' }
expect(getSessionOwnerHint('bounded-0', scope)).toBeUndefined()
expect(getSessionOwnerHint('bounded-256', scope)).toMatchObject({ connectionId: 'bounded-source' })
})
it('survives a relaunch: hints are persisted and rehydrated in LRU order', () => {
const omar = { connectionId: 'local', mode: 'local' as const, profile: 'omar' }
const remote = { connectionId: 'homelab', mode: 'remote' as const, profile: 'worker', targetProfile: 'w' }
setSessionOwnerHint('stored-omar', omar)
setSessionOwnerHint('stored-remote', remote)
// "Relaunch": the in-memory map is gone, storage is not.
_resetSessionOwnerHintsForTests()
expect(getSessionOwnerHint('stored-omar')).toBeUndefined()
hydrateSessionOwnerHints()
expect(getSessionOwnerHint('stored-omar')).toEqual(omar)
expect(getSessionOwnerHint('stored-remote')).toEqual(remote)
// LRU order survives: the oldest persisted entry is the first evicted.
for (let index = 0; index < 255; index += 1) {
setSessionOwnerHint(`filler-${index}`, { connectionId: 'filler', profile: 'p' })
}
expect(getSessionOwnerHint('stored-omar')).toBeUndefined()
expect(getSessionOwnerHint('stored-remote')).toEqual(remote)
})
it('ignores malformed persisted entries and never throws on hydrate', () => {
window.localStorage.setItem(
'hermes.desktop.sessionOwnerHints.v1',
JSON.stringify([
'junk',
['no-route', null],
['bad-shape', { connectionId: 7, profile: 'x' }],
['good', { connectionId: 'local', profile: 'omar', mode: 'sideways' }]
])
)
_resetSessionOwnerHintsForTests()
expect(() => hydrateSessionOwnerHints()).not.toThrow()
expect(getSessionOwnerHint('good')).toEqual({ connectionId: 'local', profile: 'omar' })
expect(getSessionOwnerHint('no-route')).toBeUndefined()
expect(getSessionOwnerHint('bad-shape')).toBeUndefined()
})
it('forgets every hint naming a removed connection, in memory and on disk', () => {
setSessionOwnerHint('stored-a', { connectionId: 'gone', profile: 'omar' })
setSessionOwnerHint('stored-b', { connectionId: 'gone', profile: 'default' })
setSessionOwnerHint('stored-c', { connectionId: 'local', profile: 'omar' })
forgetSessionOwnerHintsForConnection('gone')
expect(getSessionOwnerHint('stored-a')).toBeUndefined()
expect(getSessionOwnerHint('stored-b')).toBeUndefined()
expect(getSessionOwnerHint('stored-c')).toEqual({ connectionId: 'local', profile: 'omar' })
_resetSessionOwnerHintsForTests()
hydrateSessionOwnerHints()
expect(getSessionOwnerHint('stored-a')).toBeUndefined()
expect(getSessionOwnerHint('stored-c')).toEqual({ connectionId: 'local', profile: 'omar' })
})
it('forgets every route for one session without disturbing other sessions', () => {
setSessionOwnerHint('poisoned', { connectionId: 'local', mode: 'local', profile: 'default' })
setSessionOwnerHint('poisoned', { connectionId: 'remote-a', mode: 'remote', profile: 'default' })
setSessionOwnerHint('healthy', { connectionId: 'remote-a', mode: 'remote', profile: 'default' })
forgetSessionOwnerHintsForSession('poisoned')
expect(getSessionOwnerHints('poisoned')).toEqual([])
expect(getSessionOwnerHint('healthy')).toMatchObject({ connectionId: 'remote-a' })
_resetSessionOwnerHintsForTests()
hydrateSessionOwnerHints()
expect(getSessionOwnerHints('poisoned')).toEqual([])
expect(getSessionOwnerHint('healthy')).toMatchObject({ connectionId: 'remote-a' })
})
it('pins only connection-tagged rows and leaves primary SSH rows ambient', () => {
expect(sessionOwnerRouteFromRow(session({ connection_id: 'source-a', profile: 'worker' }))).toEqual({
connectionId: 'source-a',
profile: 'worker',
targetProfile: 'worker'
})
expect(sessionOwnerRouteFromRow(session({ profile: 'default' }))).toBeUndefined()
expect(sessionOwnerRouteFromRow(session({ connection_id: ' ', profile: 'default' }))).toBeUndefined()
})
})
describe('knownSessionOwner', () => {
afterEach(() => {
_resetSessionOwnerHintsForTests({ storage: true })
})
it('returns the EXACT route for a connection-tagged row, the bare profile otherwise', () => {
const rows = [
session({ connection_id: 'local', id: 'tagged', profile: 'omar' }),
session({ id: 'untagged', profile: 'coder' }),
session({ connection_id: ' ', id: 'blank-tag', profile: 'coder' })
]
expect(knownSessionOwner(rows, 'tagged')).toEqual({ connectionId: 'local', profile: 'omar' })
expect(knownSessionOwner(rows, 'untagged')).toBe('coder')
expect(knownSessionOwner(rows, 'blank-tag')).toBe('coder')
expect(knownSessionOwner(rows, null)).toBeUndefined()
})
it('falls through to the EXACT hint route for an unlisted session', () => {
const route = { connectionId: 'homelab', profile: 'worker', targetProfile: 'w' }
setSessionOwnerHint('hidden', route)
expect(knownSessionOwner([], 'hidden')).toEqual(route)
})
})
describe('computed $attentionSessionIds', () => {
beforeEach(() => {
clearAllSessionStates()
})
afterEach(() => {
clearAllSessionStates()
})
it('reflects sessions with needsInput=true and a storedSessionId', () => {
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: true })
publishSessionState('rt2', { ...createClientSessionState('s2'), needsInput: false })
expect($attentionSessionIds.get()).toEqual(['s1'])
})
it('updates when needsInput changes', () => {
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: true })
expect($attentionSessionIds.get()).toEqual(['s1'])
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: false })
expect($attentionSessionIds.get()).toEqual([])
})
// A chat that hasn't been persisted yet has no stored id, and until it gets
// one the surfaces key on its runtime id — so publishing under that is what
// lets a clarify prompt on the very first turn reach the row.
it('falls back to the runtime id for a session with no storedSessionId', () => {
publishSessionState('rt1', { ...createClientSessionState(null), needsInput: true })
expect($attentionSessionIds.get()).toEqual(['rt1'])
})
})
describe('sessionPinId', () => {
it('uses the live id when there is no compression lineage', () => {
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')
})
it('uses the lineage root so a pin survives compression', () => {
// After auto-compression the entry surfaces under a fresh tip id but keeps
// the original root — pinning on the root keeps the pin stable.
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
})
})
describe('lineageAliases across a deep compression chain', () => {
it('aliases every segment, intermediates included', () => {
// The projected row carries the full chain: a tile or route can hold a
// MIDDLE segment's id from when IT was the tip.
const rows = [session({ _lineage_ids: ['root', 'mid', 'tip'], _lineage_root_id: 'root', id: 'tip' })]
expect(lineageAliases('mid', rows).sort()).toEqual(['mid', 'root', 'tip'])
expect(lineageAliases('tip', rows).sort()).toEqual(['mid', 'root', 'tip'])
expect(sessionMatchesStoredId(rows[0], 'mid')).toBe(true)
})
})
describe('resolveComposerSessionKey', () => {
it('keeps the lineage root across compression tip rotation', () => {
const tipBefore = '20260720_062637_ad96b3'
const tipAfter = '20260720_071049_a28905'
const sessions = [session({ id: tipAfter, _lineage_root_id: tipBefore })]
expect(resolveComposerSessionKey(tipBefore, [session({ id: tipBefore })])).toBe(tipBefore)
expect(resolveComposerSessionKey(tipAfter, sessions)).toBe(tipBefore)
})
it('falls back to the live id when the tip row is not loaded yet', () => {
expect(resolveComposerSessionKey('tip-new', [])).toBe('tip-new')
})
})
describe('shouldMigrateComposerScope', () => {
it('allows tip → lineage-root rekey within the same conversation', () => {
const sessions = [session({ id: 'tip-a', _lineage_root_id: 'root-a' })]
expect(shouldMigrateComposerScope('tip-a', 'root-a', sessions)).toBe(true)
})
it('blocks cross-session migrate when route flipped but store selection lags', () => {
// ChatView mid-switch: selectedStoredSessionId still A, route-driven
// queueSessionKey already B. Migrating would re-home A's queue onto B.
const sessions = [
session({ id: 'tip-a', _lineage_root_id: 'root-a' }),
session({ id: 'tip-b', _lineage_root_id: 'root-b' })
]
expect(shouldMigrateComposerScope('tip-a', 'root-b', sessions)).toBe(false)
expect(shouldMigrateComposerScope('root-a', 'root-b', sessions)).toBe(false)
expect(shouldMigrateComposerScope('tip-a', 'tip-b', sessions)).toBe(false)
})
it('is a no-op for identical or missing keys', () => {
const sessions = [session({ id: 'tip-a', _lineage_root_id: 'root-a' })]
expect(shouldMigrateComposerScope('root-a', 'root-a', sessions)).toBe(false)
expect(shouldMigrateComposerScope(null, 'root-a', sessions)).toBe(false)
expect(shouldMigrateComposerScope('root-a', null, sessions)).toBe(false)
})
})
describe('mergeSessionPage', () => {
it('carries the owning connection onto a row that comes back untagged (local registry source)', () => {
// A `local` registry source's rows are served by the primary aggregate as
// plain local rows: the unified-list splice tags only non-local sources.
// The refresh must not strip the exact owner the routed create stamped.
const previous = [session({ connection_id: 'local', id: 'omar-1', last_active: 5, profile: 'omar' })]
const incoming = [session({ id: 'omar-1', last_active: 5, profile: 'omar' })]
expect(mergeSessionPage(previous, incoming, [])[0]).toMatchObject({ connection_id: 'local', profile: 'omar' })
})
it('drops the carried tag when the refreshed row names a different profile, and never overrides an incoming tag', () => {
const previous = [
session({ connection_id: 'local', id: 'moved', profile: 'omar' }),
session({ connection_id: 'local', id: 'foreign', profile: 'omar' })
]
const incoming = [
session({ id: 'moved', profile: 'default' }),
session({ connection_id: 'homelab', id: 'foreign', profile: 'omar' })
]
const merged = mergeSessionPage(previous, incoming, [])
expect(merged.find(s => s.id === 'moved')?.connection_id).toBeUndefined()
expect(merged.find(s => s.id === 'foreign')?.connection_id).toBe('homelab')
})
it('keeps reference identity when the carried tag is already present', () => {
const previous = [session({ connection_id: 'local', id: 'same', last_active: 1, profile: 'omar' })]
const incoming = [session({ connection_id: 'local', id: 'same', last_active: 1, profile: 'omar' })]
expect(mergeSessionPage(previous, incoming, [])[0]).toBe(incoming[0])
})
it('never stitches one profile twin onto another: same id in two profiles stays two sessions (#92454)', () => {
// Two profiles can hold sessions with the SAME stored id (restored
// backups, copied state.dbs). Keyed by bare id these collapsed into one
// row whose title/activity carry crossed profiles — the visible seed of
// the cross-profile transcript/route mixups.
const previous = [session({ id: 'twin', last_active: 50, profile: 'testbot', title: 'Testbot work' })]
const incoming = [
session({ id: 'twin', last_active: 10, profile: 'quietbot' }),
session({ id: 'twin', last_active: 50, profile: 'testbot', title: 'Testbot work' })
]
const merged = mergeSessionPage(previous, incoming, [])
// Both twins survive as distinct rows…
expect(merged.map(s => `${s.profile}:${s.id}`).sort()).toEqual(['quietbot:twin', 'testbot:twin'])
// …and testbot's title/activity is NOT stitched onto quietbot's row.
const quiet = merged.find(s => s.profile === 'quietbot')
expect(quiet?.title ?? null).toBeNull()
expect(quiet?.last_active).toBe(10)
})
it('a kept twin in another profile survives the incoming page dedupe (#92454)', () => {
// The incoming page carries only ONE profile's copy; the other profile's
// twin was in the previous list and pinned via keep. Bare-id dedupe
// treated the ids as equal and dropped the kept twin.
const previous = [session({ id: 'twin', profile: 'quietbot' })]
const incoming = [session({ id: 'twin', message_count: 2, profile: 'testbot' })]
const merged = mergeSessionPage(previous, incoming, ['twin'])
expect(merged.map(s => `${s.profile}:${s.id}`).sort()).toEqual(['quietbot:twin', 'testbot:twin'])
})
it('returns the server page untouched when there is nothing to keep', () => {
const previous = [session({ id: 'a' }), session({ id: 'b' })]
const incoming = [session({ id: 'a' })]
// Content, not identity: the title-carry map rebuilds the array even when
// nothing is carried, and `incoming` is a fresh server page every fetch.
expect(mergeSessionPage(previous, incoming, [])).toEqual(incoming)
})
it('keeps a still-working session the server omitted', () => {
// Repro of the disappearing-sessions bug: A finished and is returned by the
// server, but B and C are mid-first-response (message_count 0 in the DB) so
// listSessions(min_messages=1) skips them. They must survive the refresh.
const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })]
const incoming = [session({ id: 'a', message_count: 2 })]
const merged = mergeSessionPage(previous, incoming, ['b', 'c'])
expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a'])
// The finished session comes from the fresh server payload, not the stale
// optimistic copy.
expect(merged.find(s => s.id === 'a')?.message_count).toBe(2)
})
it('does not duplicate a working session the server already returned', () => {
const previous = [session({ id: 'b' }), session({ id: 'a' })]
const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })]
const merged = mergeSessionPage(previous, incoming, ['b'])
expect(merged.map(s => s.id)).toEqual(['b', 'a'])
expect(merged.find(s => s.id === 'b')?.message_count).toBe(4)
})
it('never resurrects a session the server dropped that is not in the keep set', () => {
// A deleted/archived session is removed from `previous` optimistically and
// is not in the keep set, so it must stay gone after a refresh.
const previous = [session({ id: 'b' }), session({ id: 'gone' })]
const incoming = [session({ id: 'b' })]
expect(mergeSessionPage(previous, incoming, ['b']).map(s => s.id)).toEqual(['b'])
})
it('keeps a pinned session that has aged off the recent page', () => {
// Repro of "loses pins until you refresh": a pinned chat falls off the
// most-recent page, so the server stops returning it. A hard replace would
// evict it and the Pinned section would go empty. The keep set (which
// carries pinned ids) must hold it in memory.
const previous = [session({ id: 'recent' }), session({ id: 'pinned' })]
const incoming = [session({ id: 'recent' })]
const merged = mergeSessionPage(previous, incoming, ['pinned'])
expect(merged.map(s => s.id)).toEqual(['pinned', 'recent'])
})
it('keeps a pinned session matched by its lineage root after compression', () => {
// The pin is stored on the lineage-root id, but the loaded row surfaces
// under its live compression tip. Matching on _lineage_root_id keeps it.
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] as SessionInfo[]
const incoming = [session({ id: 'other' })] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['root'])
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
})
it('evicts an old compression tip when the incoming page has the new tip from the same lineage', () => {
// Repro of #43483: after auto-compression rotates the tip (#4 → #5),
// the sidebar showed both the old tip and the new tip as separate rows.
// The old tip must be evicted because its lineage key matches the incoming
// new tip's lineage key.
const previous = [session({ id: 'tip-4', _lineage_root_id: 'root' }), session({ id: 'other' })] as SessionInfo[]
const incoming = [session({ id: 'tip-5', _lineage_root_id: 'root' })] as SessionInfo[]
// 'tip-4' is in the keep set (e.g. it was the active/working session),
// but should still be evicted because the incoming page carries the same
// lineage under a new tip id.
const merged = mergeSessionPage(previous, incoming, ['tip-4'])
expect(merged.map(s => s.id)).toEqual(['tip-5'])
// The new tip comes from the server payload.
expect(merged.find(s => s.id === 'tip-5')?._lineage_root_id).toBe('root')
})
it('preserves an unrelated pinned session even when lineage dedup is active', () => {
// Regression guard: lineage dedup must not accidentally evict sessions
// from a different lineage that happen to be in the keep set.
const previous = [
session({ id: 'a-old', _lineage_root_id: 'lineage-a' }),
session({ id: 'b', _lineage_root_id: 'lineage-b' })
] as SessionInfo[]
const incoming = [session({ id: 'a-new', _lineage_root_id: 'lineage-a' })] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['b'])
expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
})
it('never regresses last_active behind an optimistic user-send bump', () => {
const previous = [session({ id: 'old', last_active: 9_000 })]
const incoming = [session({ id: 'old', last_active: 100, message_count: 4 })]
const merged = mergeSessionPage(previous, incoming, [])
expect(merged[0]?.last_active).toBe(9_000)
expect(merged[0]?.message_count).toBe(4)
})
it('carries an optimistic last_active across a compression tip rotation', () => {
const previous = [session({ id: 'tip-4', _lineage_root_id: 'root', last_active: 9_000 })] as SessionInfo[]
const incoming = [session({ id: 'tip-5', _lineage_root_id: 'root', last_active: 50 })] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['tip-4'])
expect(merged.map(s => s.id)).toEqual(['tip-5'])
expect(merged[0]?.last_active).toBe(9_000)
})
it('sorts survivors by last_active so they interleave with incoming instead of forming a stale block', () => {
// Repro of #47203: two survivors (B and C) have different last_active
// timestamps. B settled more recently than C. Without sorting, survivors
// are prepended in their old order from `previous`, which may be stale.
// With sorting, B (more recent) should appear before C.
const previous = [
session({ id: 'c', last_active: 100 }),
session({ id: 'b', last_active: 200 }),
session({ id: 'a', last_active: 300 })
]
// Server returns A (fresh page, order=recent), omits B and C (min_messages=1)
const incoming = [session({ id: 'a', last_active: 300, message_count: 2 })]
const merged = mergeSessionPage(previous, incoming, ['b', 'c'])
// B (last_active 200) should come before C (last_active 100)
expect(merged.map(s => s.id)).toEqual(['a', 'b', 'c'])
})
it('places a very recent survivor in correct position among incoming sessions', () => {
// A survivor with last_active between two incoming sessions should be
// interleaved, not prepended as a block.
const previous = [session({ id: 'survivor', last_active: 150 }), session({ id: 'old', last_active: 50 })]
const incoming = [session({ id: 'newest', last_active: 200 }), session({ id: 'older', last_active: 100 })]
const merged = mergeSessionPage(previous, incoming, ['survivor'])
// survivor (150) should be between newest (200) and older (100)
expect(merged.map(s => s.id)).toEqual(['newest', 'survivor', 'older'])
})
it('keeps a survivor whose optimistic last_active outranks the whole page on top', () => {
// touchSessionActivity stamps last_active on user-send before the server
// sees the message; that bump must place the survivor by its FRESH time.
const previous = [session({ id: 'typing', last_active: 900 }), session({ id: 'settled', last_active: 100 })]
const incoming = [session({ id: 'settled', last_active: 100, message_count: 3 })]
const merged = mergeSessionPage(previous, incoming, ['typing'])
expect(merged.map(s => s.id)).toEqual(['typing', 'settled'])
})
it('falls back to started_at for survivors that have no last_active yet', () => {
// A brand-new session (no persisted message) carries last_active 0; the
// backend's effective-recency key falls back to started_at, so we must
// too, or a fresh draft sinks to the very bottom of the sidebar.
const previous = [
session({ id: 'draft', last_active: 0, started_at: 500 }),
session({ id: 'other', last_active: 400 })
]
const incoming = [session({ id: 'other', last_active: 400, message_count: 2 })]
const merged = mergeSessionPage(previous, incoming, ['draft'])
expect(merged.map(s => s.id)).toEqual(['draft', 'other'])
})
it('interleaves against the title-preserving merged rows, not the raw incoming page', () => {
// The optimistic last_active carried onto an incoming row must count for
// its position in the interleave: previous knows 'bumped' was touched at
// 300 even though the server page still reports 100.
const previous = [session({ id: 'survivor', last_active: 200 }), session({ id: 'bumped', last_active: 300 })]
const incoming = [session({ id: 'bumped', last_active: 100, message_count: 2 })]
const merged = mergeSessionPage(previous, incoming, ['survivor'])
expect(merged.map(s => s.id)).toEqual(['bumped', 'survivor'])
expect(merged[0]?.last_active).toBe(300)
})
})
describe('carryForwardFailedProfileSessions', () => {
it('is a no-op when the backend reported no profile errors', () => {
const previous = [session({ id: 'yesterday', profile: 'default' })]
const incoming = [session({ id: 'today', profile: 'default' })]
expect(carryForwardFailedProfileSessions(previous, incoming, undefined)).toBe(incoming)
expect(carryForwardFailedProfileSessions(previous, incoming, [])).toBe(incoming)
})
it('re-attaches idle rows for a profile whose slice failed (empty 200 + errors)', () => {
// Repro: current session is running, sidebar scan hits disk I/O, backend
// returns recents=[] with errors=[{profile:default}]. mergeSessionPage then
// keeps only working/pinned/selected and Yesterday/This-week vanish.
const previous = [
session({ id: 'running', last_active: 300, profile: 'default', title: 'Now' }),
session({ id: 'yesterday', last_active: 200, profile: 'default', title: 'Yesterday' }),
session({ id: 'week', last_active: 100, profile: 'default', title: 'This week' })
]
const carried = carryForwardFailedProfileSessions(previous, [], [{ profile: 'default', error: 'disk I/O error' }])
expect(carried.map(s => s.id)).toEqual(['running', 'yesterday', 'week'])
expect(carried[1]).toBe(previous[1])
})
it('does not resurrect a successful profile’s omitted rows, and does not duplicate', () => {
const previous = [
session({ id: 'work-old', profile: 'work' }),
session({ id: 'home-idle', profile: 'default' }),
session({ id: 'home-fresh', profile: 'default' })
]
const incoming = [session({ id: 'home-fresh', message_count: 4, profile: 'default' })]
const carried = carryForwardFailedProfileSessions(previous, incoming, [{ profile: 'work' }])
expect(carried.map(s => `${s.profile}:${s.id}`)).toEqual(['default:home-fresh', 'work:work-old'])
})
it('re-ranks carried rows by recency instead of parking them at the tail', () => {
const previous = [
session({ id: 'idle-newer', last_active: 500, profile: 'work' }),
session({ id: 'idle-older', last_active: 50, profile: 'work' })
]
const incoming = [session({ id: 'home', last_active: 100, profile: 'default' })]
expect(carryForwardFailedProfileSessions(previous, incoming, [{ profile: 'work' }]).map(s => s.id)).toEqual([
'idle-newer',
'home',
'idle-older'
])
})
it('treats a missing profile tag on the error as default', () => {
const previous = [session({ id: 'idle', profile: 'default' })]
expect(carryForwardFailedProfileSessions(previous, [], [{ error: 'disk I/O error' }]).map(s => s.id)).toEqual([
'idle'
])
})
})
describe('keepFailedProfileMeta', () => {
it('is a no-op when the backend reported no profile errors', () => {
const incoming = { default: { cost_usd: 1, tokens: 2 } }
expect(keepFailedProfileMeta({ default: { cost_usd: 9, tokens: 9 } }, incoming, [])).toBe(incoming)
})
it('restores previous usage/truncated flags for profiles whose slice failed', () => {
const previous = { default: { cost_usd: 4, tokens: 40 }, work: { cost_usd: 1, tokens: 10 } }
const incoming = { work: { cost_usd: 2, tokens: 20 } }
expect(keepFailedProfileMeta(previous, incoming, [{ profile: 'default' }])).toEqual({
default: { cost_usd: 4, tokens: 40 },
work: { cost_usd: 2, tokens: 20 }
})
})
})
describe('touchSessionActivity', () => {
afterEach(() => {
setSessions([])
})
it('bumps last_active for a live id and a lineage-root pin target', () => {
setSessions([
session({ id: 'tip', _lineage_root_id: 'root', last_active: 10, preview: 'old' }),
session({ id: 'other', last_active: 20 })
] as SessionInfo[])
touchSessionActivity('root', { at: 99, preview: 'just sent' })
const rows = $sessions.get()
const tip = rows.find(s => s.id === 'tip')
const other = rows.find(s => s.id === 'other')
expect(tip?.last_active).toBe(99)
expect(tip?.preview).toBe('just sent')
expect(other?.last_active).toBe(20)
})
it('is monotonic — a stale stamp does not pull the row down', () => {
setSessions([session({ id: 'a', last_active: 50 })])
touchSessionActivity('a', { at: 10 })
expect($sessions.get()[0]?.last_active).toBe(50)
})
it('preserves array identity when nothing matched', () => {
const prev = [session({ id: 'a', last_active: 1 })]
setSessions(prev)
touchSessionActivity('missing', { at: 99 })
expect($sessions.get()).toBe(prev)
})
})
describe('workspaceCwdForNewSession', () => {
afterEach(() => {
applyConfiguredDefaultProjectDir(null)
$connection.set(null)
$currentCwd.set('')
$activeSessionId.set(null)
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
delete (window as { hermesDesktop?: unknown }).hermesDesktop
})
it('does not publish a delayed configured default after ownership is lost', async () => {
const settingsResult = deferred<{
defaultLabel: string
dir: string
resolvedCwd: string
}>()
const sanitizeWorkspaceCwd = vi.fn(async (cwd: string) => ({ cwd }))
;(window as { hermesDesktop?: unknown }).hermesDesktop = {
sanitizeWorkspaceCwd,
settings: { getDefaultProjectDir: vi.fn(() => settingsResult.promise) }
}
applyConfiguredDefaultProjectDir('/newer/default')
let ownsSwitch = true
const seeding = ensureDefaultWorkspaceCwd(() => ownsSwitch)
ownsSwitch = false
settingsResult.resolve({ defaultLabel: '/stale', dir: '/stale/default', resolvedCwd: '/stale/default' })
await seeding
expect(getConfiguredDefaultProjectDir()).toBe('/newer/default')
expect($currentCwd.get()).toBe('')
expect(sanitizeWorkspaceCwd).not.toHaveBeenCalled()
})
it('does not publish a delayed sanitized cwd after ownership is lost', async () => {
const sanitized = deferred<{ cwd: string }>()
;(window as { hermesDesktop?: unknown }).hermesDesktop = {
sanitizeWorkspaceCwd: vi.fn(() => sanitized.promise),
settings: {
getDefaultProjectDir: vi.fn(async () => ({
defaultLabel: '/configured',
dir: '/configured',
resolvedCwd: '/configured'
}))
}
}
let ownsSwitch = true
const seeding = ensureDefaultWorkspaceCwd(() => ownsSwitch)
await vi.waitFor(() => expect(getConfiguredDefaultProjectDir()).toBe('/configured'))
ownsSwitch = false
sanitized.resolve({ cwd: '/stale/sanitized' })
await seeding
expect($currentCwd.get()).toBe('')
})
it('prefers the configured default over the sticky remembered workspace', () => {
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky')
applyConfiguredDefaultProjectDir('/home/user/configured')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
it('keeps the configured default separate from a selected workspace', () => {
setCurrentCwd('/home/user/repo/.worktrees/feature')
applyConfiguredDefaultProjectDir('/home/user/configured')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
expect($currentCwd.get()).toBe('/home/user/repo/.worktrees/feature')
})
it('starts detached (no inherited cwd) when no default project dir is configured', () => {
// A bare new chat must NOT inherit the sticky/remembered or live workspace —
// that's the "why is my new session already on a branch" bug. Only an
// explicit configured default pre-attaches.
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky')
$currentCwd.set('/home/user/live')
expect(workspaceCwdForNewSession()).toBe('')
})
it('does not rewrite the live cwd while a session is active', () => {
$activeSessionId.set('sess-1')
$currentCwd.set('/live/session/path')
applyConfiguredDefaultProjectDir('/home/user/configured')
expect($currentCwd.get()).toBe('/live/session/path')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
it('keeps remote workspace memory separate from local and other remotes', () => {
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
$currentCwd.set('/live/session/path')
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
// Bare new sessions are intentionally DETACHED across every mode
// (#57911): neither sticky local nor sticky remote cwd is an accept-
// able bare-default — only an explicit configured default pre-attaches.
// The per-backend memory itself stays isolated (asserted directly).
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-a')
expect(getRememberedWorkspaceCwd()).toBe('/backend/project-a')
expect(workspaceCwdForNewSession()).toBe('')
$connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
expect(getRememberedWorkspaceCwd()).toBe('')
setCurrentCwd('/backend/project-b')
expect(getRememberedWorkspaceCwd()).toBe('/backend/project-b')
expect(workspaceCwdForNewSession()).toBe('')
// Back on local with no configured default: a bare new chat is detached and
// never reads the remote keys (nor inherits the sticky local workspace).
$connection.set(null)
expect(workspaceCwdForNewSession()).toBe('')
})
it('remembers only the workspace the user picked, not the one they looked at', () => {
// The reported bug (#77496 / #80213): every session resume used to write
// the remembered-workspace key — so opening a project chat silently moved
// the memory. Following a conversation must leave the memory alone.
// (Since #57911 a bare new session is detached in remote mode too, so the
// memory is asserted directly — its remaining consumer is resume seeding
// via ensureDefaultWorkspaceCwd.)
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
setCurrentCwd('/backend/picked')
setCurrentCwdTransient('/backend/some-other-project')
expect($currentCwd.get()).toBe('/backend/some-other-project')
expect(getRememberedWorkspaceCwd()).toBe('/backend/picked')
expect(workspaceCwdForNewSession()).toBe('')
})
it('settling a resumed session does not move the remembered workspace', () => {
// The reporter's exact sequence: work in a project, open a chat from it,
// then ask for a new session. Resume settling publishes the conversation's
// cwd through commitWorkspaceCwdForSelectedSession — which must not claim
// that folder as the user's chosen workspace.
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
setCurrentCwd('/backend/picked')
setSelectedStoredSessionId('sess-in-project')
commitWorkspaceCwdForSelectedSession('/backend/last-project')
expect(getRememberedWorkspaceCwd()).toBe('/backend/picked')
expect(workspaceCwdForNewSession()).toBe('')
})
it('does not stick a previous remote workspace onto a bare new session (#57911)', () => {
// Repro: in remote mode the user attaches to project-A so the renderer
// persists /tradingview as the remembered cwd under the remote key. The
// user then presses Cmd+N *without* being scoped into any project. A bare
// new session must NOT inherit /tradingview — pre-fix this returned the
// sticky remembered cwd and the gateway mapped it back to the wrong
// project via project_tree.py.
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
setCurrentCwd('/tradingview')
applyConfiguredDefaultProjectDir(null)
expect(workspaceCwdForNewSession()).toBe('')
})
it('respects an explicit configured default in remote mode (#57911)', () => {
// Symmetric guard: removing the remote branch must NOT regress users who
// *did* set a configured default — the explicit default pre-attaches
// identically across local and remote mode.
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
setCurrentCwd('/tradingview')
applyConfiguredDefaultProjectDir('/home/user/configured')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
})
function makeState(over: Partial<ClientSessionState> = {}): ClientSessionState {
return { ...createClientSessionState('s1'), ...over }
}
describe('getRecentlySettledSessionIds', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
// clearAllSessionStates also drops settle-grace entries + watchdog timers,
// so nothing leaks in from a previous test.
clearAllSessionStates()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
})
afterEach(() => {
vi.useRealTimers()
clearAllSessionStates()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
})
it('keeps a session for the grace window after its turn settles, then drops it', () => {
// A turn starts then ends: the working→idle transition grants grace.
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
// Still inside the window.
vi.setSystemTime(29_000)
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
// Past the window: the entry is pruned on read.
vi.setSystemTime(31_000)
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('does not grant grace when the session was never working (idle re-asserts)', () => {
const idle = makeState({ busy: false, storedSessionId: 'idle' })
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('clears the grace timer when the session goes busy again', () => {
const working = makeState({ busy: true, storedSessionId: 's2' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
// A new turn for the same session is "working" again — drop it from the
// settled set so it's tracked as working, not recently-finished.
const workingAgain = { ...idle, busy: true }
publishSessionState('rt1', workingAgain)
expect(getRecentlySettledSessionIds()).toEqual([])
})
})
describe('unread finished sessions', () => {
beforeEach(() => {
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$selectedStoredSessionId.set(null)
$sessions.set([])
setUnreadRemote.mockClear()
})
afterEach(() => {
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$selectedStoredSessionId.set(null)
$sessions.set([])
})
it('marks a session unread when its turn finishes in the background', () => {
$selectedStoredSessionId.set('other-session')
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
})
it('does NOT mark unread when the finishing session is the active one', () => {
$selectedStoredSessionId.set('s1')
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
$selectedStoredSessionId.set('other-session')
const idle = makeState({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('clears unread when the user opens the session', () => {
$selectedStoredSessionId.set('other')
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
setSelectedStoredSessionId('s1')
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('clears the whole conversation family when any row is opened', () => {
$sessions.set([
session({ id: 'parent', _lineage_root_id: null }),
session({ id: 'child', _lineage_root_id: 'parent' }),
session({ id: 'root', _lineage_root_id: null })
])
$selectedStoredSessionId.set('other')
// Parent and child both finish in the background.
for (const storedId of ['parent', 'child']) {
const working = makeState({ busy: true, storedSessionId: storedId })
publishSessionState(`rt-${storedId}`, working)
publishSessionState(`rt-${storedId}`, { ...working, busy: false })
}
expect($unreadFinishedSessionIds.get().sort()).toEqual(['child', 'parent'])
// Opening the CHILD clears the PARENT's dot too (same family).
setSelectedStoredSessionId('child')
expect($unreadFinishedSessionIds.get()).toEqual([])
$sessions.set([])
})
it('does NOT re-light a completion that settled before the user read it', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
$selectedStoredSessionId.set('other')
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
// User reads the session at t=2s, then the same completion re-asserts at
// t=3s — the re-assert is the same settled state, so it must not re-light.
vi.setSystemTime(2_000_000)
setSelectedStoredSessionId('s1')
expect($unreadFinishedSessionIds.get()).toEqual([])
vi.setSystemTime(3_000_000)
publishSessionState('rt1', { ...working, busy: false })
expect($unreadFinishedSessionIds.get()).toEqual([])
vi.useRealTimers()
})
it('re-lights when a NEW turn settles after the read baseline', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
$selectedStoredSessionId.set('other')
const working = makeState({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
// User reads s1 at t=2s, then moves on to another session.
vi.setSystemTime(2_000_000)
setSelectedStoredSessionId('s1')
expect($unreadFinishedSessionIds.get()).toEqual([])
setSelectedStoredSessionId('other')
// A NEW turn starts (busy again) and finishes at t=4s — genuinely new
// completion after the read baseline, so it re-lights.
vi.setSystemTime(3_000_000)
publishSessionState('rt1', { ...working, busy: true })
vi.setSystemTime(4_000_000)
publishSessionState('rt1', { ...working, busy: false })
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
vi.useRealTimers()
})
it('openSession marks read before any focus short-circuit', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
$selectedStoredSessionId.set('s1')
$unreadFinishedSessionIds.set(['s1'])
// A no-op navigate — openSession with 'in-place' against the already
// selected session hits focusOpenSession and returns without loading.
const { openSession } = await import('@/app/open-session')
openSession('s1', () => {}, 'in-place')
expect($unreadFinishedSessionIds.get()).toEqual([])
vi.useRealTimers()
})
it('clears a persisted unread row when the session is opened', async () => {
$sessions.set([session({ id: 's1', unread: true })])
setSelectedStoredSessionId('s1')
// The optimistic flip is synchronous; the PATCH is fire-and-forget.
expect($sessions.get().find(s => s.id === 's1')?.unread).toBe(false)
await Promise.resolve()
expect(setUnreadRemote).toHaveBeenCalledWith('s1', false, undefined)
})
it('does not PATCH a read row when it is opened', async () => {
$sessions.set([session({ id: 's1', unread: false })])
setSelectedStoredSessionId('s1')
await Promise.resolve()
expect(setUnreadRemote).not.toHaveBeenCalled()
})
})
describe('remembered session id (per profile)', () => {
beforeEach(() => {
localStorage.clear()
_resetLegacyDiscardForTests()
})
afterEach(() => {
localStorage.clear()
})
it('scopes the remembered session by profile so one profile cannot read another', () => {
setRememberedSessionId('work-session', 'ai-engineer')
setRememberedSessionId('personal-session', 'default')
expect(getRememberedSessionId('ai-engineer')).toBe('work-session')
expect(getRememberedSessionId('default')).toBe('personal-session')
// A profile with nothing remembered does not inherit another's session.
expect(getRememberedSessionId('research')).toBeNull()
})
it('discards legacy unsuffixed keys on first read (zero-migration, refuse-to-guess)', () => {
// An existing install remembered its session under the pre-per-profile key.
localStorage.setItem('hermes.desktop.lastSessionId', 'legacy-session')
// Reading from any profile discards the legacy key — ownership is unknowable.
expect(getRememberedSessionId('default')).toBeNull()
expect(getRememberedSessionId('coder')).toBeNull()
// The legacy key must be cleared.
expect(localStorage.getItem('hermes.desktop.lastSessionId')).toBeNull()
})
it('uses encodeURIComponent so profile names with reserved chars are isolated', () => {
setRememberedSessionId('ops-session', 'research/ops')
expect(getRememberedSessionId('research/ops')).toBe('ops-session')
// Verify the storage key uses encoded form.
expect(localStorage.getItem('hermes.desktop.lastSessionId.profile.research%2Fops')).toBe('ops-session')
// Another profile with a different encoding cannot read it.
expect(getRememberedSessionId('research')).toBeNull()
})
it('clearing one profile leaves the others intact', () => {
setRememberedSessionId('work-session', 'ai-engineer')
setRememberedSessionId('personal-session', 'default')
setRememberedSessionId(null, 'ai-engineer')
expect(getRememberedSessionId('ai-engineer')).toBeNull()
expect(getRememberedSessionId('default')).toBe('personal-session')
})
})
describe('remembered route (per profile)', () => {
beforeEach(() => {
localStorage.clear()
_resetLegacyDiscardForTests()
})
afterEach(() => {
localStorage.clear()
})
it('scopes the remembered route by profile so one profile cannot restore another', () => {
// A session route embeds a session id. Remembered globally, a cold start
// under 'default' would navigate straight into ai-engineer's conversation.
setRememberedRoute('/session/work-session', 'ai-engineer')
setRememberedRoute('/session/personal-session', 'default')
expect(getRememberedRoute('ai-engineer')).toBe('/session/work-session')
expect(getRememberedRoute('default')).toBe('/session/personal-session')
expect(getRememberedRoute('research')).toBeNull()
})
it('discards legacy unsuffixed keys on first read (zero-migration, refuse-to-guess)', () => {
localStorage.setItem('hermes.desktop.lastRoute', '/skills')
// Reading from any profile discards the legacy key.
expect(getRememberedRoute('default')).toBeNull()
expect(getRememberedRoute('coder')).toBeNull()
expect(localStorage.getItem('hermes.desktop.lastRoute')).toBeNull()
})
it('uses encodeURIComponent so profile names with reserved chars are isolated', () => {
setRememberedRoute('/cron', 'research/ops')
expect(getRememberedRoute('research/ops')).toBe('/cron')
expect(localStorage.getItem('hermes.desktop.lastRoute.profile.research%2Fops')).toBe('/cron')
expect(getRememberedRoute('research')).toBeNull()
})
it('clearing one profile leaves the others intact', () => {
setRememberedRoute('/session/work-session', 'ai-engineer')
setRememberedRoute('/session/personal-session', 'default')
setRememberedRoute(null, 'ai-engineer')
expect(getRememberedRoute('ai-engineer')).toBeNull()
expect(getRememberedRoute('default')).toBe('/session/personal-session')
})
it('route and session id agree on the owner, so restore cannot cross profiles', () => {
// The cold-start restore prefers the route over the id, so the two keys
// must be written under the same owner or the id scoping is bypassed.
const owner = rememberedSessionProfile([session({ id: 'stored-1', profile: 'ai-engineer' })], 'stored-1', 'default')
setRememberedSessionId('stored-1', owner)
setRememberedRoute('/session/stored-1', owner)
expect(getRememberedRoute('default')).toBeNull()
expect(getRememberedSessionId('default')).toBeNull()
expect(getRememberedRoute('ai-engineer')).toBe('/session/stored-1')
})
})
describe('sessionBelongsToProfile', () => {
it('validates that a session row matches a stored id and target profile', () => {
const sessions = [
session({ id: 's1', profile: 'ai-engineer' }),
session({ id: 's2', profile: 'default' }),
session({ id: 's3', profile: 'ai-engineer' })
]
expect(sessionBelongsToProfile(sessions, 's1', 'ai-engineer')).toBe(true)
expect(sessionBelongsToProfile(sessions, 's3', 'ai-engineer')).toBe(true)
expect(sessionBelongsToProfile(sessions, 's2', 'default')).toBe(true)
// Wrong profile.
expect(sessionBelongsToProfile(sessions, 's1', 'default')).toBe(false)
// Missing session.
expect(sessionBelongsToProfile(sessions, 's-missing', 'ai-engineer')).toBe(false)
})
it('matches on lineage root so compressed tips validate their owner', () => {
const sessions = [session({ id: 'tip-2', _lineage_root_id: 'root-1', profile: 'work' })]
expect(sessionBelongsToProfile(sessions, 'root-1', 'work')).toBe(true)
expect(sessionBelongsToProfile(sessions, 'tip-2', 'work')).toBe(true)
// Wrong profile even when lineage matches.
expect(sessionBelongsToProfile(sessions, 'root-1', 'personal')).toBe(false)
})
it('normalizes blank/empty profiles to default', () => {
const sessions = [session({ id: 's1', profile: '' }), session({ id: 's2', profile: null as unknown as string })]
expect(sessionBelongsToProfile(sessions, 's1', 'default')).toBe(true)
expect(sessionBelongsToProfile(sessions, 's1', '')).toBe(true)
expect(sessionBelongsToProfile(sessions, 's2', 'default')).toBe(true)
})
it('returns false for an empty session list', () => {
expect(sessionBelongsToProfile([], 'any-id', 'default')).toBe(false)
})
})
describe('rememberedSessionProfile', () => {
it('keys by the session row owning profile, not the active one', () => {
const sessions = [session({ id: 'stored-1', profile: 'ai-engineer' })]
expect(rememberedSessionProfile(sessions, 'stored-1', 'default')).toBe('ai-engineer')
})
it('matches on the lineage root so a compressed tip resolves its owner', () => {
const sessions = [session({ _lineage_root_id: 'root-1', id: 'tip-2', profile: 'work' })]
expect(rememberedSessionProfile(sessions, 'root-1', 'default')).toBe('work')
})
it('falls back to the active profile for a session not yet in the list', () => {
expect(rememberedSessionProfile([], 'uncached', 'research')).toBe('research')
})
it('consults the owner hint for a hidden session absent from the list (Bot Chat 4001 class)', () => {
// Bot Mode's canonical chats are born hidden: no sidebar row ever exists,
// so without the hint the router would fall back to the ACTIVE profile and
// land prompt.submit on a backend that never owned the session.
setSessionOwnerHint('hidden-bot-chat', { connectionId: 'local', mode: 'local', profile: 'developer' })
expect(rememberedSessionProfile([], 'hidden-bot-chat', 'default')).toBe('developer')
})
it('prefers the routed targetProfile over the route profile in the hint fallback', () => {
setSessionOwnerHint('hidden-remote-chat', {
connectionId: 'ssh-1',
profile: 'default',
targetProfile: 'clippy'
})
expect(rememberedSessionProfile([], 'hidden-remote-chat', 'default')).toBe('clippy')
})
it('still prefers a real session row over the hint', () => {
setSessionOwnerHint('rowed-session', { connectionId: 'local', profile: 'wrong-hint' })
const sessions = [session({ id: 'rowed-session', profile: 'right-owner' })]
expect(rememberedSessionProfile(sessions, 'rowed-session', 'default')).toBe('right-owner')
})
it('normalizes a blank active profile to default', () => {
expect(rememberedSessionProfile([], null, '')).toBe('default')
expect(rememberedSessionProfile([], null, null)).toBe('default')
})
})
describe('knownSessionProfile', () => {
it('returns the row owner when the session is listed', () => {
const sessions = [session({ id: 'stored-1', profile: 'ai-engineer' })]
expect(knownSessionProfile(sessions, 'stored-1')).toBe('ai-engineer')
})
it('returns the owner hint for a hidden session absent from the list', () => {
setSessionOwnerHint('hidden-bot-chat', { connectionId: 'local', mode: 'local', profile: 'developer' })
expect(knownSessionProfile([], 'hidden-bot-chat')).toBe('developer')
})
it('returns undefined for an unknown session — NEVER falls back to active', () => {
// This is the whole point of the no-active-fallback architecture: an
// unresolved owner must be undefined so the caller does a cross-profile
// probe, not silently route the RPC to whatever profile is on screen.
expect(knownSessionProfile([], 'totally-unknown')).toBeUndefined()
expect(knownSessionProfile([], null)).toBeUndefined()
})
})
describe('knownSessionOwner', () => {
it('preserves a registry connection on a same-named session row', () => {
expect(
knownSessionOwner(
[session({ connection_id: 'source-b', id: 'shared-session', profile: 'default' })],
'shared-session'
)
).toEqual({ connectionId: 'source-b', profile: 'default' })
})
it('preserves a composite owner hint when the row is not listed', () => {
const owner = { connectionId: 'source-a', profile: 'default', targetProfile: 'backend-default' }
setSessionOwnerHint('hidden-session', owner)
expect(knownSessionOwner([], 'hidden-session')).toEqual(owner)
})
})