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 isLive: boolean completed: boolean finalStatus: 'completed' | 'failed' | 'cancelled' | null errorInfo: RunError | null select: (run: Run | null, opts?: { live?: boolean }) => void cancel: () => Promise refreshRun: () => Promise } /** * 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(null) const [state, dispatch] = useReducer(sessionReducer, initialSessionState) const wsRef = useRef(null) const pollRef = useRef(null) const selectedIdRef = useRef(null) const reconnectTimerRef = useRef(null) const reconnectCountRef = useRef(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() 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, } }