mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(desktop): make the running-turn timer per-session (#41182)
The desktop statusbar turn timer read a single process-global $turnStartedAt, set/cleared only for the active session. With multiple same-profile sessions running at once, switching to session B reset the one shared clock, so session A's still-running turn "restarted from zero" the moment you left it — exactly the behaviour @Da7_Tech reported after the profile-scoped session work. Move turnStartedAt onto ClientSessionState so each session owns its own turn clock. The global atom now just mirrors whichever session is focused, written on view-sync (the flush that already stages the active session's state). A backgrounded turn keeps counting in its own cache entry, and focusing it restores its real elapsed time instead of zeroing it. Set/clear sites: message.start (seed), message.complete + error + interrupted bail (clear), and the session.info running-state path (seed if missing / clear on stop) so a turn that goes busy via session.info — e.g. resuming a session that's already running — also gets a clock. Note: the agent loop itself never froze — every same-profile session runs in its own backend thread and background deltas are buffered per-session. This fixes the timer-reset symptom; the "no live progress until you return" is inherent to a single-view transcript and is out of scope here.
This commit is contained in:
parent
1a4010edf5
commit
9d72680ca3
5 changed files with 142 additions and 8 deletions
|
|
@ -451,7 +451,8 @@ export function useMessageStream({
|
|||
busy: false,
|
||||
needsInput: false,
|
||||
pendingBranchGroup: null,
|
||||
streamId: null
|
||||
streamId: null,
|
||||
turnStartedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -541,7 +542,8 @@ export function useMessageStream({
|
|||
pendingBranchGroup: null,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -599,7 +601,8 @@ export function useMessageStream({
|
|||
sawAssistantPayload: true,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -683,7 +686,8 @@ export function useMessageStream({
|
|||
if (busy) {
|
||||
return {
|
||||
...state,
|
||||
busy
|
||||
busy,
|
||||
turnStartedAt: state.turnStartedAt ?? Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -696,7 +700,8 @@ export function useMessageStream({
|
|||
awaitingResponse: false,
|
||||
busy,
|
||||
pendingBranchGroup: null,
|
||||
streamId: null
|
||||
streamId: null,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -735,7 +740,8 @@ export function useMessageStream({
|
|||
busy: true,
|
||||
awaitingResponse: true,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false
|
||||
interrupted: false,
|
||||
turnStartedAt: Date.now()
|
||||
}))
|
||||
|
||||
if (isActiveEvent) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
|
||||
|
||||
import { useSessionStateCache } from './use-session-state-cache'
|
||||
|
||||
type Cache = ReturnType<typeof useSessionStateCache>
|
||||
|
||||
interface HarnessProps {
|
||||
activeSessionId: string | null
|
||||
onReady: (cache: Cache) => void
|
||||
selectedStoredSessionId: string | null
|
||||
}
|
||||
|
||||
function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) {
|
||||
const busyRef: MutableRefObject<boolean> = { current: false }
|
||||
const cache = useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
selectedStoredSessionId,
|
||||
setAwaitingResponse: () => undefined,
|
||||
setBusy: () => undefined,
|
||||
setMessages: () => undefined
|
||||
})
|
||||
|
||||
onReady(cache)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('useSessionStateCache — per-session turn timer', () => {
|
||||
beforeEach(() => {
|
||||
// The view-sync flush runs on a real rAF in the browser path; in jsdom we
|
||||
// want it synchronous so the global mirror is observable immediately. The
|
||||
// hook closes over `window.requestAnimationFrame`, so stub that exact ref.
|
||||
// Return null (not a handle) so the hook's `viewSyncRafRef.current = rAF(...)`
|
||||
// assignment doesn't overwrite the null the synchronous callback just set —
|
||||
// otherwise the ref reads truthy and the NEXT sync is suppressed (a real
|
||||
// browser returns a handle but runs the callback async, so this race is a
|
||||
// test-only artifact of firing synchronously).
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
|
||||
return null as unknown as number
|
||||
})
|
||||
setTurnStartedAt(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
setTurnStartedAt(null)
|
||||
})
|
||||
|
||||
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
|
||||
let cache!: Cache
|
||||
// Active session is "fg-runtime"; the turn starts on the BACKGROUND session.
|
||||
render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
const startedAt = 1_700_000_000_000
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'bg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
|
||||
'bg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
// The background session's own cache entry holds the clock...
|
||||
expect(cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')?.turnStartedAt).toBe(startedAt)
|
||||
// ...but the global atom (statusbar timer) is untouched — a background turn
|
||||
// must not drive the foreground timer.
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
|
||||
it("mirrors the focused session's turn clock into the global atom on view-sync", () => {
|
||||
let cache!: Cache
|
||||
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
|
||||
|
||||
const startedAt = 1_700_000_111_000
|
||||
|
||||
// A turn on the ACTIVE session stages into the view; the flush mirrors its
|
||||
// turnStartedAt into the global atom the statusbar reads.
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'fg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
|
||||
'fg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
expect($turnStartedAt.get()).toBe(startedAt)
|
||||
})
|
||||
|
||||
it('clears the global clock when the focused turn ends', () => {
|
||||
let cache!: Cache
|
||||
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'fg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: 1_700_000_222_000 }),
|
||||
'fg-stored'
|
||||
)
|
||||
})
|
||||
expect($turnStartedAt.get()).toBe(1_700_000_222_000)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState('fg-runtime', state => ({ ...state, busy: false, turnStartedAt: null }))
|
||||
})
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@ import type { ChatMessage } from '@/lib/chat-messages'
|
|||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
|
|
@ -92,6 +92,10 @@ export function useSessionStateCache({
|
|||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
// Mirror the focused session's per-session turn clock into the global
|
||||
// atom the statusbar timer reads. Keeps a backgrounded turn's elapsed
|
||||
// time intact on focus instead of zeroing it (the "timer restarts" bug).
|
||||
setTurnStartedAt(pending.state.turnStartedAt)
|
||||
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
|
|
|
|||
|
|
@ -91,4 +91,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 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.
|
||||
* The global $turnStartedAt mirrors whichever session is currently viewed. */
|
||||
turnStartedAt: number | null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ export function createClientSessionState(
|
|||
sawAssistantPayload: false,
|
||||
pendingBranchGroup: null,
|
||||
interrupted: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue