Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
creatingSessionRef,
ensureSessionState,
getRouteToken,
getRoutedStoredSessionId,
navigate,
onFreshDraftRouteIntent: clearRoutedSessionIntent,
requestGateway,
Expand Down
168 changes: 168 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@ import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects'
import {
$activeSessionId,
$activeSessionStoredIdRotation,
$currentCwd,
$messages,
$newChatWorkspaceTarget,
$resumeFailedSessionId,
$selectedStoredSessionId,
setActiveSessionId,
setActiveSessionStoredIdRotation,
setCurrentCwd,
setMessages,
setNewChatWorkspaceTarget,
setResumeFailedSessionId,
setSelectedStoredSessionId,
setSessions
} from '@/store/session'

import { sessionRoute } from '../../routes'
import type { ClientSessionState } from '../../types'

import { useSessionActions } from './use-session-actions'
Expand Down Expand Up @@ -75,6 +80,7 @@ function Harness({
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
getRoutedStoredSessionId: () => null,
navigate: vi.fn() as never,
requestGateway,
resetViewSync: vi.fn(),
Expand All @@ -93,6 +99,166 @@ function Harness({
return null
}

function StoredIdRotationHarness({
activeSessionIdRef,
getRoutedStoredSessionId,
navigate,
selectedStoredSessionIdRef
}: {
activeSessionIdRef: MutableRefObject<string | null>
getRoutedStoredSessionId: () => null | string
navigate: (to: string, options?: { replace?: boolean }) => void
selectedStoredSessionIdRef: MutableRefObject<string | null>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })

useSessionActions({
activeSessionId: activeSessionIdRef.current,
activeSessionIdRef,
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
getRoutedStoredSessionId,
navigate: navigate as never,
requestGateway: async () => ({}) as never,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: selectedStoredSessionIdRef.current,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
})

return null
}

describe('active stored-session id rotation routing', () => {
afterEach(() => {
cleanup()
setActiveSessionId(null)
setActiveSessionStoredIdRotation(null)
setSelectedStoredSessionId(null)
vi.restoreAllMocks()
})

it('follows a rotation while the same conversation still owns the foreground route', async () => {
const activeSessionIdRef: MutableRefObject<string | null> = { current: 'runtime-A' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: 'stored-A' }
const navigate = vi.fn()

setSelectedStoredSessionId('stored-A')
render(
<StoredIdRotationHarness
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => 'stored-A'}
navigate={navigate}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
/>
)

act(() => {
setActiveSessionStoredIdRotation({
nextStoredSessionId: 'stored-A-next',
previousStoredSessionId: 'stored-A',
runtimeSessionId: 'runtime-A'
})
})

await waitFor(() => expect(selectedStoredSessionIdRef.current).toBe('stored-A-next'))
expect($selectedStoredSessionId.get()).toBe('stored-A-next')
expect(navigate).toHaveBeenCalledWith(sessionRoute('stored-A-next'), { replace: true })
expect($activeSessionStoredIdRotation.get()).toBeNull()
})

it('does not overwrite a newer route intent before its resume effect has synchronized selection', async () => {
const activeSessionIdRef: MutableRefObject<string | null> = { current: 'runtime-A' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: 'stored-A' }
const navigate = vi.fn()

setSelectedStoredSessionId('stored-A')
render(
<StoredIdRotationHarness
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => 'stored-C'}
navigate={navigate}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
/>
)

act(() => {
setActiveSessionStoredIdRotation({
nextStoredSessionId: 'stored-A-next',
previousStoredSessionId: 'stored-A',
runtimeSessionId: 'runtime-A'
})
})

await waitFor(() => expect($activeSessionStoredIdRotation.get()).toBeNull())
expect(selectedStoredSessionIdRef.current).toBe('stored-A')
expect($selectedStoredSessionId.get()).toBe('stored-A')
expect(navigate).not.toHaveBeenCalled()
})

it('does not let the previous runtime jump back after selection already moved', async () => {
const activeSessionIdRef: MutableRefObject<string | null> = { current: 'runtime-A' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: 'stored-C' }
const navigate = vi.fn()

setSelectedStoredSessionId('stored-C')
render(
<StoredIdRotationHarness
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => 'stored-C'}
navigate={navigate}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
/>
)

act(() => {
setActiveSessionStoredIdRotation({
nextStoredSessionId: 'stored-A-next',
previousStoredSessionId: 'stored-A',
runtimeSessionId: 'runtime-A'
})
})

await waitFor(() => expect($activeSessionStoredIdRotation.get()).toBeNull())
expect(selectedStoredSessionIdRef.current).toBe('stored-C')
expect($selectedStoredSessionId.get()).toBe('stored-C')
expect(navigate).not.toHaveBeenCalled()
})

it('updates the underlying selection without navigating out of an overlay or page', async () => {
const activeSessionIdRef: MutableRefObject<string | null> = { current: 'runtime-A' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: 'stored-A' }
const navigate = vi.fn()

setSelectedStoredSessionId('stored-A')
render(
<StoredIdRotationHarness
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => null}
navigate={navigate}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
/>
)

act(() => {
setActiveSessionStoredIdRotation({
nextStoredSessionId: 'stored-A-next',
previousStoredSessionId: 'stored-A',
runtimeSessionId: 'runtime-A'
})
})

await waitFor(() => expect(selectedStoredSessionIdRef.current).toBe('stored-A-next'))
expect($selectedStoredSessionId.get()).toBe('stored-A-next')
expect(navigate).not.toHaveBeenCalled()
})
})

async function createWith(
profileSetup: () => void,
beforeCreate?: (handle: HarnessHandle) => Promise<void> | void
Expand Down Expand Up @@ -231,6 +397,7 @@ function ResumeHarness({
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
getRoutedStoredSessionId: () => null,
navigate: vi.fn() as never,
requestGateway,
resetViewSync: vi.fn(),
Expand Down Expand Up @@ -480,6 +647,7 @@ function BranchHarness({
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
getRoutedStoredSessionId: () => null,
navigate: vi.fn() as never,
requestGateway,
resetViewSync: vi.fn(),
Expand Down
58 changes: 39 additions & 19 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
import {
$activeSessionStoredId,
$activeSessionStoredIdRotation,
$currentCwd,
$currentFastMode,
$currentModel,
Expand All @@ -26,6 +26,7 @@ import {
type NewChatWorkspaceTarget,
sessionPinId,
setActiveSessionId,
setActiveSessionStoredIdRotation,
setAwaitingResponse,
setBusy,
setCurrentBranch,
Expand Down Expand Up @@ -83,6 +84,7 @@ interface SessionActionsOptions {
creatingSessionRef: MutableRefObject<boolean>
ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState
getRouteToken: () => string
getRoutedStoredSessionId: () => null | string
navigate: NavigateFunction
onFreshDraftRouteIntent?: () => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
Expand Down Expand Up @@ -163,6 +165,7 @@ export function useSessionActions({
creatingSessionRef,
ensureSessionState,
getRouteToken,
getRoutedStoredSessionId,
navigate,
onFreshDraftRouteIntent,
requestGateway,
Expand All @@ -178,32 +181,49 @@ export function useSessionActions({
const copy = t.desktop
const resumeRequestRef = useRef(0)

// Follow auto-compression's stored-id rotation. When the active session's
// stored id changes (compression ends the SessionDB session and forks a
// continuation), re-anchor the URL route + selection to the new id so the
// next send doesn't hit a stale stored→runtime mapping and trigger a full
// thread reload. replace: true — it's the same conversation, not a new
// history entry.
const rotatedStoredId = useStore($activeSessionStoredId)
// Follow auto-compression's stored-id rotation only while the exact runtime,
// selection, and route intent still belong to the rotating conversation.
// The previous implementation carried only the next stored id and navigated
// unconditionally; a fast A → B → C switch could therefore be overwritten
// by A's delayed session.info event and visibly jump back to A.
const storedIdRotation = useStore($activeSessionStoredIdRotation)

useEffect(() => {
if (!rotatedStoredId || rotatedStoredId === selectedStoredSessionIdRef.current) {
if (!storedIdRotation) {
return
}

const oldStoredId = selectedStoredSessionIdRef.current
// Consume the event even when it is stale. Rotation is an edge, not durable
// state; replaying it after a later remount/selection would steal focus.
setActiveSessionStoredIdRotation(current => (current === storedIdRotation ? null : current))

setSelectedStoredSessionId(rotatedStoredId)
selectedStoredSessionIdRef.current = rotatedStoredId
navigate(sessionRoute(rotatedStoredId), { replace: true })
const selectedStoredSessionId = selectedStoredSessionIdRef.current
const routedStoredSessionId = getRoutedStoredSessionId()

// Clean up the stale stored→runtime mapping so getRuntimeIdForStoredSession
// can't resolve the old id to this runtime (it would fail the storedSessionId
// check and return null, but leaving the stale key is sloppy).
if (oldStoredId) {
runtimeIdByStoredSessionIdRef.current.delete(oldStoredId)
if (
activeSessionIdRef.current !== storedIdRotation.runtimeSessionId ||
selectedStoredSessionId !== storedIdRotation.previousStoredSessionId ||
(routedStoredSessionId !== null && routedStoredSessionId !== storedIdRotation.previousStoredSessionId)
) {
return
}

setSelectedStoredSessionId(storedIdRotation.nextStoredSessionId)
selectedStoredSessionIdRef.current = storedIdRotation.nextStoredSessionId

// A route overlay/page has no routed session id, but the underlying selected
// chat still needs to follow the continuation. Update that selection in
// place without navigating out of the surface the user deliberately opened.
if (routedStoredSessionId === storedIdRotation.previousStoredSessionId) {
navigate(sessionRoute(storedIdRotation.nextStoredSessionId), { replace: true })
}
}, [rotatedStoredId, navigate, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef])
}, [
activeSessionIdRef,
getRoutedStoredSessionId,
navigate,
selectedStoredSessionIdRef,
storedIdRotation
])

const startFreshSessionDraft = useCallback(
(options: boolean | FreshSessionDraftOptions = false) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { ChatMessage } from '@/lib/chat-messages'
import {
$activeSessionStoredIdRotation,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$messages,
$turnStartedAt,
setActiveSessionId,
setActiveSessionStoredIdRotation,
setCurrentFastMode,
setCurrentModel,
setCurrentProvider,
Expand All @@ -29,6 +32,50 @@ interface HarnessProps {
selectedStoredSessionId: string | null
}

describe('useSessionStateCache — stored-id rotation provenance', () => {
afterEach(() => {
cleanup()
setActiveSessionId(null)
setActiveSessionStoredIdRotation(null)
})

it('emits the previous, next, and runtime ids and removes the stale reverse mapping', () => {
let cache!: Cache

setActiveSessionId('runtime-A')
render(<Harness activeSessionId="runtime-A" onReady={value => (cache = value)} selectedStoredSessionId="stored-A" />)

act(() => {
cache.ensureSessionState('runtime-A', 'stored-A')
cache.ensureSessionState('runtime-A', 'stored-A-next')
})

expect($activeSessionStoredIdRotation.get()).toEqual({
nextStoredSessionId: 'stored-A-next',
previousStoredSessionId: 'stored-A',
runtimeSessionId: 'runtime-A'
})
expect(cache.runtimeIdByStoredSessionIdRef.current.has('stored-A')).toBe(false)
expect(cache.runtimeIdByStoredSessionIdRef.current.get('stored-A-next')).toBe('runtime-A')
})

it('does not publish a foreground-navigation event for a background runtime rotation', () => {
let cache!: Cache

setActiveSessionId('runtime-B')
render(<Harness activeSessionId="runtime-B" onReady={value => (cache = value)} selectedStoredSessionId="stored-B" />)

act(() => {
cache.ensureSessionState('runtime-A', 'stored-A')
cache.ensureSessionState('runtime-A', 'stored-A-next')
})

expect($activeSessionStoredIdRotation.get()).toBeNull()
expect(cache.runtimeIdByStoredSessionIdRef.current.has('stored-A')).toBe(false)
expect(cache.runtimeIdByStoredSessionIdRef.current.get('stored-A-next')).toBe('runtime-A')
})
})

function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }

Expand Down
Loading
Loading