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
Original file line number Diff line number Diff line change
Expand Up @@ -531,10 +531,16 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
return state
}

// Prefer the gateway-reported turn_started_at so the timer
// survives session switches and session.info heartbeats.
const gatewayTurnStartedAt =
typeof payload!.turn_started_at === 'number' && payload!.turn_started_at > 0
? payload!.turn_started_at * 1000
: null
return {
...state,
busy,
turnStartedAt: state.turnStartedAt ?? Date.now()
turnStartedAt: state.turnStartedAt ?? gatewayTurnStartedAt ?? Date.now()
}
}

Expand Down
204 changes: 198 additions & 6 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useStore } from '@nanostores/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { noteActiveTreeGroup, revealTreePane } from '@/components/pane-shell/tree/store'
Expand All @@ -22,8 +23,11 @@ import {
$newChatWorkspaceTarget,
$resumeFailedSessionId,
$selectedStoredSessionId,
$turnStartedAt,
setActiveSessionId,
setActiveSessionStoredIdRotation,
setAwaitingResponse,
setBusy,
setCurrentCwd,
setCurrentFastMode,
setCurrentModel,
Expand All @@ -33,14 +37,17 @@ import {
setNewChatWorkspaceTarget,
setResumeFailedSessionId,
setSelectedStoredSessionId,
setSessions
setSessions,
setTurnStartedAt
} from '@/store/session'
import { $sessionTiles } from '@/store/session-states'

import sessionResumeActiveTurn from '../../../../../../tests/fixtures/session-resume-active-turn.json'
import { sessionRoute } from '../../routes'
import type { ClientSessionState } from '../../types'

import { useSessionActions } from './use-session-actions'
import { useSessionStateCache } from './use-session-state-cache'

vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
Expand Down Expand Up @@ -612,6 +619,7 @@ function ResumeHarness({
sessionStateByRuntimeIdRef?: MutableRefObject<Map<string, ClientSessionState>>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const sessionStatesRef = sessionStateByRuntimeIdRef ?? ref(new Map<string, ClientSessionState>())

const actions = useSessionActions({
activeSessionId: null,
Expand All @@ -627,10 +635,12 @@ function ResumeHarness({
runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map<string, string>()),
selectedStoredSessionId,
selectedStoredSessionIdRef: ref<string | null>(selectedStoredSessionId),
sessionStateByRuntimeIdRef: sessionStateByRuntimeIdRef ?? ref(new Map<string, ClientSessionState>()),
sessionStateByRuntimeIdRef: sessionStatesRef,
syncSessionStateToView: vi.fn(),
updateSessionState: (sessionId, updater) => {
const next = updater({} as ClientSessionState)
updateSessionState: (sessionId, updater, storedSessionId) => {
const current = sessionStatesRef.current.get(sessionId) ?? createClientSessionState(storedSessionId ?? null)
const next = updater(current)
sessionStatesRef.current.set(sessionId, next)
onStateUpdate?.(sessionId, next)

return next
Expand All @@ -644,6 +654,49 @@ function ResumeHarness({
return null
}

function ResumeTimerHarness({
onReady,
requestGateway
}: {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const activeSessionId = useStore($activeSessionId)
const busyRef = useRef(false)
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId: null,
setAwaitingResponse,
setBusy,
setMessages
})
const actions = useSessionActions({
activeSessionId,
activeSessionIdRef: cache.activeSessionIdRef,
busyRef,
creatingSessionRef: useRef(false),
ensureSessionState: cache.ensureSessionState,
getRouteToken: () => 'timer-contract',
navigate: vi.fn() as never,
requestGateway,
resetViewSync: cache.resetViewSync,
runtimeIdByStoredSessionIdRef: cache.runtimeIdByStoredSessionIdRef,
selectedStoredSessionId: null,
selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef,
syncSessionStateToView: cache.syncSessionStateToView,
getRoutedStoredSessionId: () => null,
updateSessionState: cache.updateSessionState
})

useEffect(() => {
onReady(actions.resumeSession)
}, [actions.resumeSession, onReady])

return null
}

describe('resumeSession failure recovery', () => {
afterEach(() => {
cleanup()
Expand Down Expand Up @@ -784,6 +837,7 @@ describe('resumeSession failure recovery', () => {
message_count: storedMessages.length,
messages: storedMessages,
running: true,
turn_started_at: 1_700_000_000,
inflight: {
user: 'current prompt',
assistant: 'partial answer',
Expand Down Expand Up @@ -813,6 +867,7 @@ describe('resumeSession failure recovery', () => {
expect(renderedMessages).toContain('current prompt')
expect(renderedMessages).toContain('partial answer')
expect(renderedMessages).toContain('newest prompt')
expect(resumedState?.turnStartedAt).toBe(1_700_000_000_000)
})

it('uses the continuation projection when resume rotates an equal-length stored transcript', async () => {
Expand Down Expand Up @@ -1010,6 +1065,85 @@ describe('resumeSession failure recovery', () => {
})
})

describe('session.resume turn timer contract', () => {
beforeEach(() => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
callback(0)

return null as unknown as number
})
setActiveSessionId(null)
setAwaitingResponse(false)
setBusy(false)
setMessages([])
setSessions([])
setTurnStartedAt(null)
})

afterEach(() => {
cleanup()
setActiveSessionId(null)
setAwaitingResponse(false)
setBusy(false)
setMessages([])
setSessions([])
setTurnStartedAt(null)
vi.restoreAllMocks()
})

async function resumeFrom(response: unknown): Promise<void> {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
// Model the JSON-RPC serialization/deserialization boundary. The shared
// fixture is asserted against the real gateway response in Python.
return JSON.parse(JSON.stringify(response)) as never
}

return {} as never
})
vi.mocked(getAllSessionMessages).mockResolvedValue({ messages: [], session_id: 'stored-running' } as never)

let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeTimerHarness onReady={ready => (resume = ready)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
await act(async () => {
await resume!('stored-running', true)
})
}

it('restores the canonical gateway turn timestamp in milliseconds', async () => {
await resumeFrom(sessionResumeActiveTurn)

expect($turnStartedAt.get()).toBe(sessionResumeActiveTurn.turn_started_at * 1000)
})

it('clears a stale timer when the gateway response is not running', async () => {
setTurnStartedAt(1_600_000_000_000)

await resumeFrom({ ...sessionResumeActiveTurn, running: false })

expect($turnStartedAt.get()).toBeNull()
})

it('clears a stale timer when the running gateway response omits its timestamp', async () => {
const missingTimestamp: Record<string, unknown> = JSON.parse(JSON.stringify(sessionResumeActiveTurn))
delete missingTimestamp.turn_started_at
setTurnStartedAt(1_600_000_000_000)

await resumeFrom(missingTimestamp)

expect($turnStartedAt.get()).toBeNull()
})

it('clears a stale timer when the running gateway response has a non-numeric timestamp', async () => {
setTurnStartedAt(1_600_000_000_000)

await resumeFrom({ ...sessionResumeActiveTurn, turn_started_at: 'not-a-timestamp' })

expect($turnStartedAt.get()).toBeNull()
})
})

function BranchHarness({
activeSessionId = null,
navigate = vi.fn(),
Expand Down Expand Up @@ -1535,6 +1669,64 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(resumedState?.messages[0]?.attachmentRefs).toEqual(['@image:/tmp/photo.png'])
})

it('restores the warm reconnect turn clock from session.activate', async () => {
const turnStartedAtSeconds = 1_700_000_123
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const cachedState = clientState('stored-A')
cachedState.busy = true
cachedState.turnStartedAt = null
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', cachedState]])
}

const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
return {
session_id: 'rt-A',
session_key: 'stored-A',
resumed: 'stored-A',
message_count: 0,
messages: [],
running: true,
turn_started_at: turnStartedAtSeconds,
inflight: {
user: 'current prompt',
assistant: 'partial answer',
streaming: true
},
info: {}
} as never
}

return {} as never
})

vi.mocked(getAllSessionMessages).mockResolvedValue({ messages: [], session_id: 'stored-A' } as never)

let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, state) => (resumedState = state)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)

expect(resumedState).toMatchObject({
awaitingResponse: true,
busy: true,
turnStartedAt: turnStartedAtSeconds * 1000
})
expect(JSON.stringify(resumedState?.messages)).toContain('partial answer')
})

it('repairs an idle warm cache from a divergent equal-length persisted transcript', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
Expand Down
25 changes: 21 additions & 4 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,11 @@ export function useSessionActions({

const running = Boolean(activated.running ?? cachedViewState.busy)

const activatedTurnStartedAt =
typeof activated.turn_started_at === 'number' && activated.turn_started_at > 0
? activated.turn_started_at * 1000
: null

// While idle, the persisted REST transcript is the display
// authority: session.activate returns the runtime's compressed
// context projection, not necessarily the complete conversation.
Expand Down Expand Up @@ -820,7 +825,8 @@ export function useSessionActions({
// Adopting someone else's turn: we'll stream its reply
// without ever having received its prompt, so the settle
// path must not take the "I saw it all" shortcut.
adoptedRunningTurn: state.adoptedRunningTurn || running
adoptedRunningTurn: state.adoptedRunningTurn || running,
turnStartedAt: running ? (activatedTurnStartedAt ?? state.turnStartedAt ?? Date.now()) : null
}),
storedSessionId
)
Expand Down Expand Up @@ -1037,6 +1043,14 @@ export function useSessionActions({

patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd)

// Preserve the turn-elapsed timer across cold resume: the gateway
// reports when the in-flight turn started so the desktop can restore
// the clock instead of resetting it to 0:00.
const resumedTurnStartedAt =
typeof resumed.turn_started_at === 'number' && resumed.turn_started_at > 0
? resumed.turn_started_at * 1000
: null

updateSessionState(
resumed.session_id,
state => ({
Expand All @@ -1053,10 +1067,13 @@ export function useSessionActions({
// still mid-turn; a settled recovery keeps the stream idle.
streamId: resumedRunning ? inFlightRecovery.streamId : null,
turnStartedAt: resumedRunning
? (inFlightRecovery.turnStartedAt ?? state.turnStartedAt ?? Date.now())
: state.turnStartedAt
? (inFlightRecovery.turnStartedAt ?? resumedTurnStartedAt)
: null
}
: {})
: {
turnStartedAt:
resumedRunning && resumedTurnStartedAt !== null ? resumedTurnStartedAt : null
})
}),
storedSessionId
)
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/chat-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type GatewayEventPayload = {
approval_mode?: string
yolo?: boolean
running?: boolean
turn_started_at?: number | null
cwd?: string
branch?: string
terminal_backend?: string
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,8 @@ export interface SessionResumeResponse {
session_key?: string
started_at?: number
status?: string
/** Epoch seconds the current turn started, or null when idle. */
turn_started_at?: number | null
}

export interface SessionRuntimeInfo {
Expand Down
Loading
Loading