fix(desktop): clear stale compaction status across session switches (#64127)

* fix(desktop): clear stale compaction status

Clear the compaction phase when a turn resumes with model or tool activity, and key response timers by session and turn so switching chats preserves elapsed time.\n\nSupersedes #48115 by porting its resumed-content approach to the current split stream hook and covering tool-first resumptions.\n\nCo-authored-by: liuhao1024 <sunsky.lau@gmail.com>

* fix(desktop): resume after thinking activity

* fix(desktop): clear turn timer on stop
This commit is contained in:
Gille 2026-07-14 06:48:48 -06:00 committed by GitHub
parent 444b5e96fa
commit 2d0f2185cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 215 additions and 5 deletions

View file

@ -0,0 +1,82 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $compactingSessions, setSessionCompacting } from '@/store/compaction'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
const SID = 'session-1'
const OTHER_SID = 'session-2'
let handleEvent: ((event: RpcEvent) => void) | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
act(() => handleEvent!({ payload, session_id: SID, type }))
}
describe('useMessageStream compaction lifecycle', () => {
beforeEach(() => {
handleEvent = null
$compactingSessions.set({})
})
afterEach(() => {
cleanup()
$compactingSessions.set({})
vi.restoreAllMocks()
})
it.each([
['message.delta', { text: 'resumed' }],
['thinking.delta', { text: 'still working' }],
['reasoning.delta', { text: 'thinking again' }],
['tool.start', { name: 'terminal', tool_id: 'tool-1' }]
] as const)('clears the stale compaction phase when %s resumes the turn', async (type, payload) => {
await mountStream()
setSessionCompacting(OTHER_SID, true)
emit('status.update', { kind: 'compacting' })
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true, [SID]: true })
emit(type, payload)
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
})
})

View file

@ -50,6 +50,19 @@ import type { ClientSessionState } from '../../../types'
import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
const COMPACTION_RESUME_EVENT_TYPES = new Set([
'message.delta',
'thinking.delta',
'reasoning.delta',
'reasoning.available',
'moa.reference',
'moa.aggregating',
'tool.start',
'tool.progress',
'tool.generating',
'tool.complete'
])
interface GatewayEventDeps {
activeSessionIdRef: MutableRefObject<string | null>
compactedTurnRef: MutableRefObject<Set<string>>
@ -118,6 +131,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const sessionId = route.sessionId
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
// Mid-turn compaction does not emit another message.start. The first
// model output or tool event proves summarization has finished and the
// turn has resumed, so retire the phase label without waiting for the
// whole turn to complete.
if (
sessionId &&
COMPACTION_RESUME_EVENT_TYPES.has(event.type) &&
compactedTurnRef.current.has(sessionId)
) {
setSessionCompacting(sessionId, false)
}
if (event.type === 'gateway.ready') {
return
} else if (event.type === 'session.info') {

View file

@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { uploadComposerAttachment, usePromptActions } from '.'
@ -1075,6 +1075,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
afterEach(() => {
cleanup()
$turnStartedAt.set(null)
vi.restoreAllMocks()
})
@ -1168,6 +1169,32 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
})
it('clears the active and cached turn clocks when stopping a turn', async () => {
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async () => ({}) as never)
$turnStartedAt.set(1_700_000_000_000)
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => states.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.cancelRun()
expect($turnStartedAt.get()).toBeNull()
expect(states.at(-1)).toMatchObject({
awaitingResponse: false,
busy: false,
interrupted: true,
turnStartedAt: null
})
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []

View file

@ -21,7 +21,15 @@ import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearAllPrompts } from '@/store/prompts'
import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import {
$busy,
$connection,
$messages,
setAwaitingResponse,
setBusy,
setMessages,
setTurnStartedAt
} from '@/store/session'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
@ -501,6 +509,7 @@ export function usePromptActions({
}
setAwaitingResponse(false)
setTurnStartedAt(null)
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
@ -526,7 +535,8 @@ export function usePromptActions({
streamId: null,
pendingBranchGroup: null,
needsInput: false,
interrupted: true
interrupted: true,
turnStartedAt: null
}
})