/** * @file replay.ts * @description Pure derivations behind the session Replay tab: events in * playback order, the lifecycle markers on its mini-map, and the state a * session was in at a cursor (status, tool in flight, tool counts). * @author Nguyễn Ngọc Trí Vĩ */ import type { AgentStatus, DashboardEvent } from "./types"; import { statusFromEventType } from "./event-grouping"; export const REPLAY_SPEEDS = [0.5, 1, 2, 5] as const; /** One event per this many ms at 1× — playback walks the sequence, not the * wall clock, so an hour-long pause does not stall the replay. */ export const REPLAY_STEP_MS = 500; /** Event types drawn on the mini-map, in legend order. */ export const LIFECYCLE_TYPES = [ "SessionStart", "UserPromptSubmit", "Compaction", "SubagentStop", "APIError", "Stop", ] as const; /** Oldest first; `id` breaks timestamp ties. */ export function chronological(events: DashboardEvent[]): DashboardEvent[] { return [...events].sort( (a, b) => a.created_at.localeCompare(b.created_at) || (a.id ?? 0) - (b.id ?? 0) ); } export interface ReplayMarker { index: number; type: string; /** Position on the mini-map, 0–100, matching the slider's scale. */ pct: number; } export function lifecycleMarkers(events: DashboardEvent[]): ReplayMarker[] { const kinds = new Set(LIFECYCLE_TYPES); const last = Math.max(1, events.length - 1); const out: ReplayMarker[] = []; events.forEach((e, index) => { if (kinds.has(e.event_type)) out.push({ index, type: e.event_type, pct: (index / last) * 100 }); }); return out; } export interface ReplayState { status: AgentStatus | null; /** The newest tool started and not yet finished at the cursor. */ currentTool: string | null; /** Tool calls (PreToolUse) so far, most used first. */ toolCounts: { tool: string; count: number }[]; prompts: number; errors: number; compactions: number; /** From the first event to the cursor's. */ elapsedMs: number; } /** The session as it stood after `events[cursor]` (events in playback order). */ export function replayState(events: DashboardEvent[], cursor: number): ReplayState { const end = Math.min(cursor, events.length - 1); const open: { agent: string; tool: string }[] = []; const counts = new Map(); let prompts = 0; let errors = 0; let compactions = 0; for (let i = 0; i <= end; i++) { const e = events[i]!; const agent = e.agent_id ?? ""; switch (e.event_type) { case "PreToolUse": if (e.tool_name) { open.push({ agent, tool: e.tool_name }); counts.set(e.tool_name, (counts.get(e.tool_name) ?? 0) + 1); } break; case "PostToolUse": case "PostToolUseFailure": { for (let j = open.length - 1; j >= 0; j--) { if (open[j]!.agent === agent && open[j]!.tool === e.tool_name) { open.splice(j, 1); break; } } if (e.event_type === "PostToolUseFailure") errors++; break; } case "Stop": case "SubagentStop": case "SessionEnd": // A turn that ended leaves nothing of that agent in flight. for (let j = open.length - 1; j >= 0; j--) if (open[j]!.agent === agent) open.splice(j, 1); break; case "UserPromptSubmit": prompts++; break; case "APIError": errors++; break; case "Compaction": compactions++; break; } } const first = events[0]; const at = end >= 0 ? events[end] : undefined; return { status: at ? statusFromEventType(at.event_type) : null, currentTool: open[open.length - 1]?.tool ?? null, toolCounts: [...counts] .map(([tool, count]) => ({ tool, count })) .sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)), prompts, errors, compactions, elapsedMs: first && at ? Math.max(0, Date.parse(at.created_at) - Date.parse(first.created_at)) : 0, }; }