Skip to content
Merged
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 @@ -599,14 +599,20 @@ 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,
// running=true from the backend is turn-live proof, same as
// message.start (e.g. resuming an already-running session
// that never replays its start event).
turnLive: true,
turnStartedAt: state.turnStartedAt ?? Date.now()
turnStartedAt: state.turnStartedAt ?? gatewayTurnStartedAt ?? Date.now()
}
}

Expand Down
201 changes: 196 additions & 5 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 @@ -631,8 +638,10 @@ function ResumeHarness({
selectedStoredSessionIdRef: ref<string | null>(selectedStoredSessionId),
sessionStateByRuntimeIdRef: stateMapRef,
syncSessionStateToView: vi.fn(),
updateSessionState: (sessionId, updater) => {
const current = stateMapRef.current.get(sessionId) ?? ({} as ClientSessionState)
updateSessionState: (sessionId, updater, storedSessionId) => {
// Full default shape (not a bare {} cast) so seeded/derived fields like
// turnStartedAt behave as in production state updates.
const current = stateMapRef.current.get(sessionId) ?? createClientSessionState(storedSessionId ?? null)
const next = updater(current)

stateMapRef.current.set(sessionId, next)
Expand All @@ -649,6 +658,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 @@ -793,6 +845,7 @@ describe('resumeSession failure recovery', () => {
message_count: compressedRuntimeMessages.length,
messages: compressedRuntimeMessages,
running: true,
turn_started_at: 1_700_000_000,
inflight: {
user: 'current prompt',
assistant: 'partial answer',
Expand Down Expand Up @@ -823,6 +876,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('preserves a runtime-cache delta that arrives while cold resume waits for REST', async () => {
Expand Down Expand Up @@ -1108,6 +1162,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 @@ -1733,6 +1866,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 @@ -854,6 +854,11 @@ export function useSessionActions({
Boolean(sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)?.busy)
)

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

// The persisted REST transcript is the display authority: a live
// runtime may carry only the agent's compressed context projection,
// which is intentionally smaller than the user-visible conversation.
Expand Down Expand Up @@ -927,7 +932,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 @@ -1195,6 +1201,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 @@ -1214,10 +1228,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 @@ -71,6 +71,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 @@ -658,6 +658,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
29 changes: 29 additions & 0 deletions tests/fixtures/session-resume-active-turn.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"info": {
"cwd": "/workspace",
"lazy": false,
"model": "test/model",
"skills": {},
"tools": {}
},
"inflight": {
"assistant": "partial answer",
"streaming": true,
"user": "current prompt"
},
"message_count": 1,
"messages": [
{
"role": "user",
"text": "earlier prompt"
}
],
"messages_omitted": false,
"resumed": "stored-running",
"running": true,
"session_id": "rt-running",
"session_key": "stored-running",
"started_at": 1700000000.0,
"status": "working",
"turn_started_at": 1700000123.5
}
Loading
Loading