diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts index f448a38a77..8b9d9376ba 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts @@ -2,6 +2,7 @@ import type { GatewayEvent } from '@hermes/shared' import type { HermesSkin } from '@hermes/shared/skin' import { clearClarifyRequest } from '@/store/clarify' +import { invalidateContextBreakdown } from '@/app/shell/hooks/use-context-breakdown' import { notifyCronChanged, notifyPairingChanged, @@ -128,9 +129,18 @@ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { // conversation and reopening it resumes from the DB. const reclaimedRuntimeId = String((payload as { session_id?: string } | undefined)?.session_id ?? '') + // The compression/reclaim lifecycle invalidates the keyed context + // breakdown so the statusbar gauge refetches instead of serving the + // pre-compression figure (#94001). The breakdown cache keys on the + // STORED id; the reclaim payload carries it alongside the runtime id. + const reclaimedStoredId = String( + (payload as { stored_session_id?: string } | undefined)?.stored_session_id ?? '' + ) + if (reclaimedRuntimeId) { // Heal while the cached stored-id mapping is still intact, then drop. markRuntimeGone(reclaimedRuntimeId) + invalidateContextBreakdown(reclaimedStoredId || reclaimedRuntimeId) dropSessionState(reclaimedRuntimeId) // A prompt keyed to the dead runtime must not outlive it. The runtime id // rotates on every resume (cold/lazy/eager all mint a fresh sid), so the diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 1ef9e1dab5..e155006115 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -3,6 +3,7 @@ import { parseCommandDispatch, parseSlashCommand } from '@hermes/shared' import { type MutableRefObject, useCallback, useRef } from 'react' import { prepareDefaultNewSession } from '@/app/session/new-session-route' +import { invalidateContextBreakdown } from '@/app/shell/hooks/use-context-breakdown' import { getProfiles } from '@/hermes' import type { Translations } from '@/i18n' import { type ChatMessage, toChatMessages } from '@/lib/chat-messages' @@ -755,6 +756,13 @@ export function useSlashCommand(deps: SlashCommandDeps) { ) } + // The transcript just shrank by 5-10x outside any turn (busy never + // flipped), so the keyed context breakdown — if already fetched — + // is now wrong by that factor. Bump the invalidation generation: + // the statusbar gauge refetches immediately instead of serving the + // pre-compression figure until the session is switched (#94001). + invalidateContextBreakdown(sessionId) + const usage = { ...result?.usage, ...result?.info?.usage } if (Object.keys(usage).length && activeSessionIdRef.current === sessionId) { diff --git a/apps/desktop/src/app/shell/hooks/use-context-breakdown.test.ts b/apps/desktop/src/app/shell/hooks/use-context-breakdown.test.ts index 04f0c6f651..877819ab50 100644 --- a/apps/desktop/src/app/shell/hooks/use-context-breakdown.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-context-breakdown.test.ts @@ -5,7 +5,11 @@ import type { ContextBreakdown } from '@/types/hermes' import { deferred } from '../../../test/deferred' -import { useContextBreakdown } from './use-context-breakdown' +import { + _resetContextBreakdownInvalidationsForTests, + invalidateContextBreakdown, + useContextBreakdown +} from './use-context-breakdown' type GatewayRequester = (method: string, params?: Record) => Promise @@ -236,4 +240,57 @@ describe('useContextBreakdown (#94001)', () => { // s1's 91% must NOT appear under s2. expect(result.current.breakdown).toBeNull() }) -}) \ No newline at end of file +}) +describe('useContextBreakdown invalidation (#94001 follow-up)', () => { + beforeEach(() => { + _resetContextBreakdownInvalidationsForTests() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('refetches immediately when invalidateContextBreakdown bumps the session generation', async () => { + let answer = PRE_COMPRESSION_BREAKDOWN + const requestGateway = vi.fn(async () => answer) as unknown as GatewayRequester + + const { result, rerender } = renderHook(props => useContextBreakdown(props), { + initialProps: { busy: false, enabled: true, requestGateway, sessionId: 's1' as null | string } + }) + + await flushAsync() + expect(result.current.breakdown).toEqual(PRE_COMPRESSION_BREAKDOWN) + expect(requestGateway).toHaveBeenCalledTimes(1) + + // Compression happened outside any turn (no busy toggle) — invalidate. + answer = LIVE_BREAKDOWN + invalidateContextBreakdown('s1') + rerender({ busy: false, enabled: true, requestGateway, sessionId: 's1' }) + await flushAsync() + + // The meter must serve the POST-compression figure without a busy toggle + // or a session switch. + expect(result.current.breakdown).toEqual(LIVE_BREAKDOWN) + expect(requestGateway).toHaveBeenCalledTimes(2) + }) + + it('does not refetch a different session when an unrelated session is invalidated', async () => { + const requestGateway = vi.fn(async () => PRE_COMPRESSION_BREAKDOWN) as unknown as GatewayRequester + + const { result, rerender } = renderHook(props => useContextBreakdown(props), { + initialProps: { busy: false, enabled: true, requestGateway, sessionId: 's1' as null | string } + }) + + await flushAsync() + expect(requestGateway).toHaveBeenCalledTimes(1) + + invalidateContextBreakdown('other-session') + rerender({ busy: false, enabled: true, requestGateway, sessionId: 's1' }) + await flushAsync() + + expect(requestGateway).toHaveBeenCalledTimes(1) + expect(result.current.breakdown).toEqual(PRE_COMPRESSION_BREAKDOWN) + }) +}) diff --git a/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts b/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts index e1b867f4dd..293cbcbaa0 100644 --- a/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts +++ b/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts @@ -25,16 +25,25 @@ interface ContextBreakdownOptions { * sessions drops the previous numbers instead of painting them under the new * session's name. * - * The refetch after a turn end can land in windows where the answer is not - * trustworthy (#94001): right after a `session.reclaimed` resume the new - * agent may still be building (the backend answers a ZEROED breakdown, - * which would blank the meter), or the RPC can fail outright during the - * rebind. Neither may freeze the gauge on pre-compression numbers: a failed - * or agentless answer is retried on a bounded backoff, and once retries are - * exhausted the cached breakdown is EVICTED — the meter shows nothing rather - * than numbers known to be stale. Without eviction the stale value would - * survive until the session is switched, which is the manual recovery every - * report describes. */ + * Two events change the transcript WITHOUT a busy toggle, so the refetch + * must be driven explicitly: + * + * - **Compression** — manual `/compress` runs the `session.compress` RPC + * outside any turn (busy never flips), and auto-compression can commit + * mid-turn. The post-compression transcript measures a fraction of the + * pre-compression size, so a served pre-compression breakdown is not + * merely stale, it is wrong by 5-10x (#94001). + * - **Reclaim** (`session.reclaimed`) — the runtime is reaped and the + * session re-resumes; the first refetch can race the agent rebuild. + * + * `invalidateContextBreakdown` bumps a per-session generation that both + * call sites (slash.ts after a successful compress, lifecycle.ts on + * reclaim) fire, forcing an immediate refetch. An untrustworthy answer — + * an outright failure, or a ZEROED breakdown from the backend's + * agent-is-None branch (`context_max: 0`) — retries on a bounded backoff; + * once the ladder is exhausted the cached breakdown is EVICTED so the + * meter goes dark honestly rather than showing numbers known to be + * stale. */ /** A breakdown the backend computed from a live agent carries a real * context_max (the compressor's context_length). Zero means the @@ -45,15 +54,73 @@ function isZeroedBreakdown(breakdown: ContextBreakdown): boolean { const RETRY_DELAYS_MS = [1_000, 4_000, 12_000] +/** Per-session invalidation generations. `invalidateContextBreakdown` bumps + * the generation for one session and notifies subscribers; the hook + * subscribes for its own session, so a bump re-runs the fetch immediately. */ +const invalidationGenerations = new Map() +const invalidationListeners = new Set<(sessionId: string, generation: number) => void>() + +/** Force a refetch of the context breakdown for `sessionId` on the next + * render. Call when the transcript is known to have changed outside a + * turn: after a successful `session.compress`, and on + * `session.reclaimed`. */ +export function invalidateContextBreakdown(sessionId: string): void { + const id = sessionId.trim() + + if (id) { + const generation = (invalidationGenerations.get(id) ?? 0) + 1 + + invalidationGenerations.set(id, generation) + + for (const listener of invalidationListeners) { + listener(id, generation) + } + } +} + +/** Clear all invalidation generations (test isolation — the Map is + * module-level and would otherwise leak across tests). */ +export function _resetContextBreakdownInvalidationsForTests(): void { + invalidationGenerations.clear() +} + export function useContextBreakdown({ busy, enabled, requestGateway, sessionId }: ContextBreakdownOptions) { const [fetched, setFetched] = useState<{ breakdown: ContextBreakdown; sessionId: string } | null>(null) const [loading, setLoading] = useState(false) // Bounded retry: `attempt` indexes RETRY_DELAYS_MS; advancing it re-runs the // fetch effect. `exhausted` marks the end state — cached numbers evicted, // backoff stopped, meter dark until the next legitimate trigger (session - // change or turn end) starts a fresh ladder. + // change, turn end, or invalidation) starts a fresh ladder. const [attempt, setAttempt] = useState(0) const [exhausted, setExhausted] = useState(false) + const [generation, setGeneration] = useState(0) + + // Subscribe to invalidation bumps for THIS session: a bump from + // invalidateContextBreakdown (post-compress, reclaim) re-runs the fetch + // effect immediately, without waiting for a busy toggle or session switch. + useEffect(() => { + if (!sessionId) { + return + } + + const listener = (id: string, next: number) => { + if (id === sessionId) { + setGeneration(current => (current === next ? current : next)) + } + } + + invalidationListeners.add(listener) + + // Adopt any generation bumped before this effect subscribed (e.g. the + // invalidation fired while the meter was hidden, or between sessions). + const pending = invalidationGenerations.get(sessionId) ?? 0 + + setGeneration(current => (current === pending ? current : pending)) + + return () => { + invalidationListeners.delete(listener) + } + }, [sessionId]) // A session switch must not inherit the previous session's retry state — // its first successful fetch is authoritative for it. (The fetch effect's @@ -127,12 +194,13 @@ export function useContextBreakdown({ busy, enabled, requestGateway, sessionId } clearTimeout(timer) } } - // `attempt` drives the bounded-retry re-runs; everything else is a trigger. - }, [attempt, busy, enabled, requestGateway, sessionId]) + // `attempt` + `generation` drive the refetch re-runs; everything else is a trigger. + }, [attempt, busy, enabled, generation, requestGateway, sessionId]) // While the retry ladder is still running, the last good breakdown stays on - // screen (the meter ticking down beats flickering dark); once exhausted the - // cache is gone, so this returns null and the meter goes dark honestly. + // screen (the meter ticking down beats flickering dark for a transient window); + // once exhausted the cache is gone, so this returns null and the meter goes + // dark honestly. return { // The effect clears `fetched` only after commit, so gate on `busy` here too: // the first busy render must not hand out the pre-turn snapshot.