diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index deb4833868f..79fefdccd8e 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -133,13 +133,52 @@ describe('mergeSessionPage', () => { 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' })] - const incoming = [session({ id: 'other' })] + 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']) + }) }) describe('workspaceCwdForNewSession', () => { diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 4139915cea2..ed28b92cb88 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -125,10 +125,18 @@ export function mergeSessionPage( } const incomingIds = new Set(incoming.map(session => session.id)) + // Deduplicate by compression lineage: when auto-compression rotates the tip + // id (old #4 → new #5), the incoming page carries the new tip but the + // previous list still holds the old one. Without lineage-level dedup both + // rows survive as separate sidebar entries (fixes #43483). + const incomingLineageKeys = new Set( + incoming.map(session => session._lineage_root_id ?? session.id) + ) const survivors = previous.filter( session => !incomingIds.has(session.id) && + !incomingLineageKeys.has(session._lineage_root_id ?? session.id) && (keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id))) )