fix(desktop): refetch the context meter when compression completes without a turn (#94001)
Sandbox verification of the first fix exposed the remaining gap: manual /compress runs the session.compress RPC OUTSIDE any turn, so busy never flips and useContextBreakdown's effect deps (attempt/busy/enabled/ requestGateway/sessionId) never change — the meter kept serving the pre-compression breakdown (441.2k/1M) after the transcript shrank 1220 → 202 messages (~5.7x), until the status-bar item was unmounted and remounted, which reset useState and forced a fresh fetch. Add an invalidation seam: invalidateContextBreakdown(sessionId) bumps a per-session generation and notifies subscribers; the hook subscribes for its own session and re-runs the fetch effect on a bump. Two call sites: slash.ts after a successful session.compress (the no-turn path), and lifecycle.ts on session.reclaimed (belt-and-braces with the retry ladder — invalidation makes the refetch immediate, retries cover failures). Also verified live: the pre-compression value was wrong by 5.7x after compression, and unmount/remount of the footer item (the user's manual workaround) is exactly the cache-reset the fix now does automatically.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
@@ -236,4 +240,57 @@ describe('useContextBreakdown (#94001)', () => {
|
||||
// s1's 91% must NOT appear under s2.
|
||||
expect(result.current.breakdown).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, number>()
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user