AgentEvalTool/frontend/web/src/hooks/useRunSession.ts
sinohqb 983a58d013 refactor(verdict): unify read path on authoritative case_outcomes
Read paths recomputed per-case pass/connectivity independently — report
generation, the logs endpoint, and the frontend each derived it, and the
frontend's every(passed) recompute ignored the engine's authoritative
verdict. Extract resolve_case_verdicts: a single pure seam that prefers
stored case_outcomes verbatim and approximates only for legacy runs. The
logs endpoint now surfaces case_verdicts so the frontend reads instead of
recomputing.
2026-07-31 14:11:58 +08:00

283 lines
8.0 KiB
TypeScript

import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { runsApi, type CaseSnapshot, type Run } from '../api'
import {
initialSessionState,
sessionReducer,
type WsEvent,
} from './sessionReducer'
// WebSocket reconnect config
const WS_MAX_RETRIES = 5
const WS_BASE_DELAY_MS = 1000 // 1s, 2s, 4s, 8s, 16s (capped at 30s)
const WS_MAX_DELAY_MS = 30000
export interface TurnState {
roundIndex: number
message: string
reply?: string
latency_ms?: number | null
pending?: boolean
error?: string
}
export interface CaseError {
round?: number
message: string
}
export interface RuleResultState {
rule_type: string
passed: boolean
score?: number | null
reason?: string | null
}
export interface CaseState {
caseId: string
status: 'waiting' | 'running' | 'done'
turns: TurnState[]
ruleResults: RuleResultState[]
generatedMessages?: string[]
errors: CaseError[]
isDynamic?: boolean
passed?: boolean
}
export interface RunProgress {
done: number
total: number
currentIndex: number
}
export interface RunError {
code?: string
message: string
}
export interface RunSession {
run: Run | null
cases: CaseState[]
progress: RunProgress | null
scenarioSnapshot: Record<string, CaseSnapshot>
isLive: boolean
completed: boolean
finalStatus: 'completed' | 'failed' | 'cancelled' | null
errorInfo: RunError | null
select: (run: Run | null, opts?: { live?: boolean }) => void
cancel: () => Promise<void>
refreshRun: () => Promise<void>
}
/**
* Manages the lifecycle of one evaluation run: WebSocket events, historical
* log loading, and polling. State transitions are driven by a pure reducer
* (see `sessionReducer.ts`) so the logic is testable and free of the
* stale-closure hazards that bit the previous useState-based implementation.
*/
export function useRunSession(): RunSession {
// Run object is managed separately because it's driven by REST, not by
// the WebSocket event stream.
const [run, setRun] = useState<Run | null>(null)
const [state, dispatch] = useReducer(sessionReducer, initialSessionState)
const wsRef = useRef<WebSocket | null>(null)
const pollRef = useRef<number | null>(null)
const selectedIdRef = useRef<string | null>(null)
const reconnectTimerRef = useRef<number | null>(null)
const reconnectCountRef = useRef<number>(0)
const clearPolling = () => {
if (pollRef.current) {
window.clearInterval(pollRef.current)
pollRef.current = null
}
}
const clearReconnect = () => {
if (reconnectTimerRef.current) {
window.clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
}
const closeWs = () => {
clearReconnect()
if (wsRef.current) {
try { wsRef.current.close() } catch { /* noop */ }
wsRef.current = null
}
}
// Side effect of `completed` transitioning to true: close the WS and
// refresh the Run object so the UI shows the final status/summary.
useEffect(() => {
if (!state.completed) return
closeWs()
const id = selectedIdRef.current
if (!id) return
runsApi.get(id).then((res) => setRun(res.data)).catch(() => { /* noop */ })
}, [state.completed])
const loadHistoricalLogs = useCallback(async (runId: string) => {
try {
const res = await runsApi.logs(runId)
const data = res.data
const snapshot = data.scenario_snapshot ?? {}
const caseMap = new Map<string, CaseState>()
const ensure = (caseId: string): CaseState => {
let cs = caseMap.get(caseId)
if (!cs) {
const snap = snapshot[caseId]
cs = {
caseId,
status: 'done',
turns: [],
ruleResults: [],
errors: [],
isDynamic: snap?.type === 'dynamic',
}
caseMap.set(caseId, cs)
}
return cs
}
for (const t of data.turns) {
const cs = ensure(t.case_id)
cs.turns.push({
roundIndex: t.round_index,
message: t.sent_text,
reply: t.reply_text,
latency_ms: t.latency_ms,
})
}
for (const r of data.results) {
const cs = ensure(r.case_id)
cs.ruleResults.push({
rule_type: r.rule_type,
passed: r.passed,
score: r.score,
reason: r.reason,
})
}
const verdicts = data.case_verdicts ?? {}
for (const cs of caseMap.values()) {
cs.turns.sort((a, b) => a.roundIndex - b.roundIndex)
cs.passed = verdicts[cs.caseId]?.passed ?? false
}
const cases = Array.from(caseMap.values())
const total = cases.length
dispatch({ type: 'LOAD_HISTORY', cases, progress: { done: total, total, currentIndex: total }, snapshot })
} catch { /* noop */ }
}, [])
const select = useCallback((r: Run | null, opts?: { live?: boolean }) => {
closeWs()
clearPolling()
dispatch({ type: 'RESET' })
setRun(r)
selectedIdRef.current = r?.id ?? null
reconnectCountRef.current = 0
if (!r) return
const live = opts?.live ?? (r.status === 'running' || r.status === 'pending')
dispatch({ type: 'SET_LIVE', live })
if (live) {
connectWs(r.id)
} else {
loadHistoricalLogs(r.id)
dispatch({ type: 'FINALIZE_FROM_RUN', status: r.status, summary: r.summary })
}
}, [loadHistoricalLogs]) // eslint-disable-line react-hooks/exhaustive-deps
function connectWs(runId: string) {
if (selectedIdRef.current !== runId) return
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/ws/runs/${runId}`)
wsRef.current = ws
ws.onmessage = (msg) => {
try {
const ev = JSON.parse(msg.data) as WsEvent
dispatch({ type: 'WS_EVENT', event: ev })
} catch { /* noop */ }
}
ws.onclose = (e) => {
if (selectedIdRef.current !== runId) return
// Normal close (code 1000) or run already completed → finalize
if (e.code === 1000 || e.wasClean) {
dispatch({ type: 'SET_LIVE', live: false })
runsApi.get(runId).then((res) => {
setRun(res.data)
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
}).catch(() => { /* noop */ })
return
}
// Abnormal close → attempt exponential backoff reconnect
const retries = reconnectCountRef.current
if (retries >= WS_MAX_RETRIES) {
dispatch({ type: 'SET_LIVE', live: false })
runsApi.get(runId).then((res) => {
setRun(res.data)
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
}).catch(() => { /* noop */ })
return
}
const delay = Math.min(WS_BASE_DELAY_MS * Math.pow(2, retries), WS_MAX_DELAY_MS)
reconnectCountRef.current = retries + 1
reconnectTimerRef.current = window.setTimeout(() => connectWs(runId), delay)
}
}
const cancel = useCallback(async () => {
if (!run) return
await runsApi.cancel(run.id)
}, [run])
const refreshRun = useCallback(async () => {
if (!run) return
try {
const res = await runsApi.get(run.id)
setRun(res.data)
} catch { /* noop */ }
}, [run])
// Poll during live runs to keep the Run object fresh (status/summary/completed_at)
useEffect(() => {
if (!state.isLive || !run) {
clearPolling()
return
}
pollRef.current = window.setInterval(() => {
runsApi.get(run.id).then((res) => {
if (selectedIdRef.current === run.id) setRun(res.data)
}).catch(() => { /* noop */ })
}, 3000)
return () => clearPolling()
}, [state.isLive, run?.id])
useEffect(() => () => { closeWs(); clearPolling(); clearReconnect() }, [])
return {
run,
cases: state.cases,
progress: state.progress,
scenarioSnapshot: state.scenarioSnapshot,
isLive: state.isLive,
completed: state.completed,
finalStatus: state.finalStatus,
errorInfo: state.errorInfo,
select,
cancel,
refreshRun,
}
}