mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
fix(tui): isolate turn state from app render
This commit is contained in:
46
ui-tui/src/__tests__/stateIsolation.test.ts
Normal file
46
ui-tui/src/__tests__/stateIsolation.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { patchTurnState, resetTurnState } from '../app/turnStore.js'
|
||||||
|
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||||
|
|
||||||
|
const shallowEqual = <T extends Record<string, unknown>>(a: T, b: T) =>
|
||||||
|
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is(a[key], b[key]))
|
||||||
|
|
||||||
|
const subscribeSelected = <T extends Record<string, unknown>>(selector: () => T) => {
|
||||||
|
let current = selector()
|
||||||
|
let calls = 0
|
||||||
|
|
||||||
|
const unsubscribe = $uiState.listen(() => {
|
||||||
|
const next = selector()
|
||||||
|
|
||||||
|
if (shallowEqual(next, current)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next
|
||||||
|
calls++
|
||||||
|
})
|
||||||
|
|
||||||
|
return { calls: () => calls, unsubscribe }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TUI state isolation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetUiState()
|
||||||
|
resetTurnState()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not notify ui/composer subscribers for high-frequency turn updates', () => {
|
||||||
|
const composerRelevant = subscribeSelected(() => ({ busy: $uiState.get().busy, sid: $uiState.get().sid }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
patchTurnState({ streaming: `token ${i}` })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
composerRelevant.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(composerRelevant.calls()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,8 @@ import { pick, toolTrailLabel } from '../lib/text.js'
|
|||||||
import type { ActiveTool } from '../types.js'
|
import type { ActiveTool } from '../types.js'
|
||||||
|
|
||||||
import { turnController } from './turnController.js'
|
import { turnController } from './turnController.js'
|
||||||
|
import { useTurnSelector } from './turnStore.js'
|
||||||
|
import { getUiState } from './uiStore.js'
|
||||||
|
|
||||||
const DELAY_MS = 8_000
|
const DELAY_MS = 8_000
|
||||||
const INTERVAL_MS = 10_000
|
const INTERVAL_MS = 10_000
|
||||||
@@ -15,21 +17,28 @@ interface Slot {
|
|||||||
lastAt: number
|
lastAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) {
|
export function useLongRunToolCharms() {
|
||||||
|
const tools = useTurnSelector(state => state.tools)
|
||||||
const slots = useRef(new Map<string, Slot>())
|
const slots = useRef(new Map<string, Slot>())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!busy || !tools.length) {
|
if (!getUiState().busy || !tools.length) {
|
||||||
slots.current.clear()
|
slots.current.clear()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
if (!getUiState().busy) {
|
||||||
|
slots.current.clear()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const liveIds = new Set(tools.map(t => t.id))
|
const liveIds = new Set(tools.map(t => t.id))
|
||||||
|
|
||||||
for (const key of [...slots.current.keys()]) {
|
for (const key of Array.from(slots.current.keys())) {
|
||||||
if (!liveIds.has(key)) {
|
if (!liveIds.has(key)) {
|
||||||
slots.current.delete(key)
|
slots.current.delete(key)
|
||||||
}
|
}
|
||||||
@@ -57,5 +66,5 @@ export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) {
|
|||||||
const id = setInterval(tick, 1000)
|
const id = setInterval(tick, 1000)
|
||||||
|
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [busy, tools])
|
}, [tools])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
|
|||||||
import { $overlayState, patchOverlayState } from './overlayStore.js'
|
import { $overlayState, patchOverlayState } from './overlayStore.js'
|
||||||
import { scrollWithSelectionBy } from './scroll.js'
|
import { scrollWithSelectionBy } from './scroll.js'
|
||||||
import { turnController } from './turnController.js'
|
import { turnController } from './turnController.js'
|
||||||
import { $turnState, patchTurnState, useTurnSelector } from './turnStore.js'
|
import { patchTurnState, useTurnSelector } from './turnStore.js'
|
||||||
import { $uiState, getUiState, patchUiState } from './uiStore.js'
|
import { $uiState, getUiState, patchUiState } from './uiStore.js'
|
||||||
import { useComposerState } from './useComposerState.js'
|
import { useComposerState } from './useComposerState.js'
|
||||||
import { useConfigSync } from './useConfigSync.js'
|
import { useConfigSync } from './useConfigSync.js'
|
||||||
@@ -107,8 +107,6 @@ export function useMainApp(gw: GatewayClient) {
|
|||||||
|
|
||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
const overlay = useStore($overlayState)
|
const overlay = useStore($overlayState)
|
||||||
const turn = useStore($turnState)
|
|
||||||
|
|
||||||
const turnLiveTailActive = useTurnSelector(state =>
|
const turnLiveTailActive = useTurnSelector(state =>
|
||||||
Boolean(
|
Boolean(
|
||||||
state.streaming ||
|
state.streaming ||
|
||||||
@@ -503,7 +501,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||||||
}
|
}
|
||||||
}, [gw, sys])
|
}, [gw, sys])
|
||||||
|
|
||||||
useLongRunToolCharms(ui.busy, turn.tools)
|
useLongRunToolCharms()
|
||||||
|
|
||||||
const slash = useMemo(
|
const slash = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user