fix(desktop): preserve runtime session timer across warm switches
This commit is contained in:
committed by
brooklyn!
parent
e2ee9a6978
commit
868ca90218
@@ -50,6 +50,7 @@ import {
|
||||
$resumeFailedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
_resetSessionOwnerHintsForTests,
|
||||
getSessionOwnerHint,
|
||||
@@ -74,6 +75,7 @@ import {
|
||||
setResumeFailedSessionId,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt,
|
||||
setUnlistedSessionOwnerRows
|
||||
} from '@/store/session'
|
||||
@@ -1107,6 +1109,7 @@ describe('resumeSession failure recovery', () => {
|
||||
cleanup()
|
||||
setActiveSessionId(null)
|
||||
setResumeFailedSessionId(null)
|
||||
setSessionStartedAt(null)
|
||||
setMessages([])
|
||||
setSessions([])
|
||||
$removedSessionIds.set(new Set())
|
||||
@@ -1774,6 +1777,7 @@ describe('resumeSession failure recovery', () => {
|
||||
personality: '',
|
||||
provider: '',
|
||||
reasoningEffort: '',
|
||||
runtimeStartedAt: 0,
|
||||
sawAssistantPayload: false,
|
||||
serviceTier: '',
|
||||
storedSessionId: 'stored-1',
|
||||
@@ -1892,6 +1896,7 @@ function BranchHarness({
|
||||
navigate = vi.fn(),
|
||||
onCurrentReady,
|
||||
onReady,
|
||||
onStateUpdate,
|
||||
onRefs,
|
||||
requestGateway,
|
||||
selectedStoredSessionId = null
|
||||
@@ -1900,6 +1905,7 @@ function BranchHarness({
|
||||
navigate?: ReturnType<typeof vi.fn>
|
||||
onCurrentReady?: (branchCurrentSession: (messageId?: string) => Promise<boolean>) => void
|
||||
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
|
||||
onStateUpdate?: (sessionId: string, state: ClientSessionState) => void
|
||||
onRefs?: (refs: {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
@@ -1929,7 +1935,12 @@ function BranchHarness({
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
|
||||
syncSessionStateToView: vi.fn(),
|
||||
updateSessionState: () => ({}) as ClientSessionState
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const next = updater(createClientSessionState(null))
|
||||
onStateUpdate?.(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2053,6 +2064,46 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the branch runtime elapsed anchor in its tile state without stealing the primary session', async () => {
|
||||
const runtimeStartedAt = 1_700_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(runtimeStartedAt)
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.create') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
const stateUpdates: Array<{ sessionId: string; state: ClientSessionState }> = []
|
||||
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
|
||||
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
|
||||
setSelectedStoredSessionId('stored-parent')
|
||||
vi.mocked(getAllSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
render(
|
||||
<BranchHarness
|
||||
onReady={branch => (branchStoredSession = branch)}
|
||||
onStateUpdate={(sessionId, state) => stateUpdates.push({ sessionId, state })}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(stateUpdates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionId: 'branch-runtime',
|
||||
state: expect.objectContaining({ runtimeStartedAt })
|
||||
})
|
||||
)
|
||||
expect($selectedStoredSessionId.get()).toBe('stored-parent')
|
||||
})
|
||||
|
||||
// A branch belongs to the backend that OWNS its parent. Routing on profile
|
||||
// alone silently sends session.create to whatever socket is active, so a
|
||||
// remote-owned parent branched while another connection is active creates
|
||||
@@ -2077,6 +2128,7 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
setSessions([
|
||||
storedSession({ connection_id: 'pandora', id: 'stored-parent', message_count: 1, profile: 'default' })
|
||||
])
|
||||
|
||||
vi.mocked(getAllSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
@@ -2555,6 +2607,7 @@ describe('resumeSession warm-cache mapping integrity', () => {
|
||||
cleanup()
|
||||
setActiveSessionId(null)
|
||||
setResumeFailedSessionId(null)
|
||||
setSessionStartedAt(null)
|
||||
setMessages([])
|
||||
setSessions([])
|
||||
vi.mocked(getSession).mockReset()
|
||||
@@ -4633,6 +4686,42 @@ describe('resumeSession warm-cache mapping integrity', () => {
|
||||
`Tail-only warm-cache publication escaped: ${JSON.stringify(publications)}`
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the runtime elapsed anchor when a warm-cached session is reselected', async () => {
|
||||
const runtimeStartedAt = 1_700_000_000_000
|
||||
|
||||
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
|
||||
current: new Map([['stored-A', 'rt-A']])
|
||||
}
|
||||
|
||||
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
|
||||
current: new Map([['rt-A', { ...clientState('stored-A'), runtimeStartedAt }]])
|
||||
}
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.usage') {
|
||||
return { input: 0, output: 0, total: 0 } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
|
||||
render(
|
||||
<ResumeHarness
|
||||
onReady={r => (resume = r)}
|
||||
requestGateway={requestGateway}
|
||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
||||
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(resume).not.toBeNull())
|
||||
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000)
|
||||
await resume!('stored-A', true)
|
||||
|
||||
expect($sessionStartedAt.get()).toBe(runtimeStartedAt)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBackendSessionForSend workspace target', () => {
|
||||
|
||||
@@ -788,13 +788,16 @@ export function useSessionActions({
|
||||
setNewChatWorkspaceTarget(undefined)
|
||||
setActiveSessionId(created.session_id)
|
||||
setSelectedStoredSessionId(stored)
|
||||
setSessionStartedAt(Date.now())
|
||||
const runtimeStartedAt = Date.now()
|
||||
setSessionStartedAt(runtimeStartedAt)
|
||||
const yoloArmed = $yoloActive.get()
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
|
||||
if (runtimeInfo) {
|
||||
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
|
||||
}
|
||||
updateSessionState(
|
||||
created.session_id,
|
||||
state => ({ ...state, ...(runtimeInfo ?? {}), runtimeStartedAt }),
|
||||
stored
|
||||
)
|
||||
|
||||
// User may have armed YOLO on the new-chat draft before the runtime
|
||||
// session existed — apply it to the freshly created session.
|
||||
@@ -1349,7 +1352,7 @@ export function useSessionActions({
|
||||
// un-owned for the life of the session (#71254).
|
||||
setWorkspaceCwdOwner(storedSessionId)
|
||||
setCurrentBranch(cachedViewState.branch)
|
||||
setSessionStartedAt(Date.now())
|
||||
setSessionStartedAt(cachedViewState.runtimeStartedAt)
|
||||
|
||||
try {
|
||||
const replay = pendingSessionReplay(cachedRuntimeId)
|
||||
@@ -1818,7 +1821,8 @@ export function useSessionActions({
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setSessionStartedAt(Date.now())
|
||||
const runtimeStartedAt = Date.now()
|
||||
setSessionStartedAt(runtimeStartedAt)
|
||||
|
||||
const stored =
|
||||
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile
|
||||
@@ -2159,6 +2163,7 @@ export function useSessionActions({
|
||||
state => ({
|
||||
// The deferred build reports the session's own effort later (#79807).
|
||||
...markReasoningEffortPending({ ...state, ...(runtimeInfo ?? {}) }),
|
||||
runtimeStartedAt,
|
||||
messages: visibleMessagesForView,
|
||||
transcriptProvenance,
|
||||
busy: resumedRunning,
|
||||
@@ -2526,6 +2531,7 @@ export function useSessionActions({
|
||||
|
||||
const effectiveBranchMessages = responseBranchMessages.length ? responseBranchMessages : branchMessages
|
||||
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
||||
const runtimeStartedAt = Date.now()
|
||||
const preview = effectiveBranchMessages.map(({ content }) => content).find(Boolean) ?? null
|
||||
|
||||
// Record the exact owner and pin its socket THE MOMENT the create
|
||||
@@ -2573,6 +2579,7 @@ export function useSessionActions({
|
||||
branched.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
runtimeStartedAt,
|
||||
messages: effectiveBranchMessages.map(({ source }) => source),
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
|
||||
@@ -189,6 +189,9 @@ export interface ClientSessionState {
|
||||
/** A blocking clarify prompt is waiting on the user for this session. Drives
|
||||
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
|
||||
needsInput: boolean
|
||||
/** Epoch ms this renderer attached to the live runtime. Per-runtime so a
|
||||
* cached session keeps its elapsed anchor when it becomes foreground again. */
|
||||
runtimeStartedAt: number
|
||||
/** Epoch ms the current turn started, or null when idle. Per-session so a
|
||||
* background turn's elapsed timer keeps counting while another session is
|
||||
* focused, and switching sessions doesn't zero a still-running turn's clock.
|
||||
|
||||
@@ -43,6 +43,7 @@ export function createClientSessionState(
|
||||
interrupted: false,
|
||||
interimBoundaryPending: false,
|
||||
needsInput: false,
|
||||
runtimeStartedAt: Date.now(),
|
||||
turnStartedAt: null,
|
||||
turnLive: false,
|
||||
usage: null
|
||||
|
||||
Reference in New Issue
Block a user