fix(dashboard): resolver bugs críticos de tiempo real en HITL — race conditions, In-Band Auth y multi-stream buffer

- AppShell: corregir condición de carrera REST/WS que perdía tokens de agent_stream_chunk
- init_state atómico + eliminación de doble fuente REST/WS para actualización en tiempo real
- conversation_ended e idempotencia de eventos en máquina de estados por conversación
- Seguridad: migrar JWT de query param a In-Band Auth (primer mensaje {action:auth}) con timeout 5s y cierre 1008
- Multi-stream buffer: reemplazar buffer plano por TTL LRU (200 entradas, 60s TTL) para evitar pisado de tokens entre agentes
- agent_stream_completed ya no borra buffer incondicionalmente — delega purge a la política LRU
- Timer: corregir display de 00:00 en estado PENDING con visualización inmediata + cleanup en stop()
- Tests: 8 tests multi-stream, tests In-Band Auth, tests idempotencia y máquina de estados, tests Timer
- Resultado: 86/86 tests pasan | TypeScript 0 errores
This commit is contained in:
2026-07-29 04:32:27 -05:00
parent 8f044567c0
commit 83e3ec2cff
37 changed files with 6979 additions and 1129 deletions
+222 -16
View File
@@ -1,11 +1,83 @@
import { useEffect, useMemo } from 'react';
import { MessageSquare } from 'lucide-react';
import { useCallback } from 'react';
import { MessageSquare, RefreshCw, Loader2 } from 'lucide-react';
import { useAppStore } from '@/store/useAppStore';
import { wsClient } from '@/services/wsClient';
import { streamBuffer } from '@/services/streamBuffer';
import ConversationCard from '@/components/monitor/ConversationCard';
import ChatFeed from '@/components/monitor/ChatFeed';
import InternalNoteBanner from '@/components/monitor/InternalNoteBanner';
import EmptyState from '@/components/shared/EmptyState';
// ─────────────────────────────────────────────────────────────
// ConnectingPlaceholder — shown when init_state hasn't arrived
// ─────────────────────────────────────────────────────────────
function ConnectingPlaceholder() {
const wsStatus = useAppStore((s) => s.wsStatus);
const handleRetry = () => {
wsClient.disconnect();
wsClient.connect();
};
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3 text-text-muted">
<Loader2 size={32} className="animate-spin text-accent-orange" />
<p className="text-[13px] font-medium">Conectando...</p>
<p className="text-[11px]">Esperando datos del servidor</p>
<span className="text-[10px] text-text-disabled">
Estado: {wsStatus}
</span>
<button
type="button"
onClick={handleRetry}
className="flex items-center gap-1.5 px-3 py-1.5 mt-1 text-[12px] font-medium
text-accent-blue border border-accent-blue/30 rounded-lg
hover:bg-accent-blue/5 transition-colors"
>
<RefreshCw size={12} />
Reintentar conexión
</button>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// ConversationEndedBanner
// ─────────────────────────────────────────────────────────────
function ConversationEndedBanner({ conversationId }: { conversationId: string }) {
const conversationEndedBanner = useAppStore((s) => s.conversationEndedBanner);
if (conversationEndedBanner !== conversationId) return null;
return (
<div className="shrink-0 px-4 py-2 bg-accent-yellow/10 border-b border-accent-yellow/20">
<p className="text-[12px] font-medium text-accent-yellow-dark flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-accent-yellow" />
Conversación finalizada
</p>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// LoadingConversationOverlay
// ─────────────────────────────────────────────────────────────
function LoadingConversationOverlay() {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2 text-text-muted">
<Loader2 size={24} className="animate-spin text-accent-orange" />
<p className="text-[13px]">Cargando conversación...</p>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
@@ -15,23 +87,138 @@ export default function MonitorPage() {
const conversations = useAppStore((s) => s.conversations);
const selectedConversationId = useAppStore((s) => s.selectedConversationId);
const fetchConversations = useAppStore((s) => s.fetchConversations);
const fetchConversationWithMessages = useAppStore((s) => s.fetchConversationWithMessages);
const setSelectedConversationId = useAppStore((s) => s.setSelectedConversationId);
const totalConversations = useAppStore((s) => s.totalConversations);
const conversationsOffset = useAppStore((s) => s.conversationsOffset);
const selectedConversation = useAppStore((s) => s.selectedConversation);
const loadingConversation = useAppStore((s) => s.loadingConversation);
const setConversationState = useAppStore((s) => s.setConversationState);
const initStateReceived = useAppStore((s) => s.initStateReceived);
// ── Fetch conversations on mount ────────────────────────
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
// ── No fetchConversations on mount — list comes only from WS init_state (Paso 2)
// ── Selected conversation object ─────────────────────────
const selectedConversation = useMemo(
() =>
conversations.find((c) => c.id === selectedConversationId) ?? null,
[conversations, selectedConversationId],
// ── Conversation click handler (Paso 6) ──────────────────
const handleLoadMore = useCallback(() => {
fetchConversations(20, conversationsOffset);
}, [fetchConversations, conversationsOffset]);
const handleConversationClick = useCallback(
async (id: string) => {
// Generate requestId for correlation (Paso 6 — ignore stale responses)
const requestId = crypto.randomUUID();
// Update state machine: idle → hydrating (Paso 7)
setConversationState(id, 'hydrating');
// Set loading state BEFORE async fetch (no flicker — Paso 6)
useAppStore.setState({
selectedConversationId: id,
selectedConversation: null,
loadingConversation: id,
currentRequestId: requestId,
conversationEndedBanner: null,
});
try {
// 1. Cargar mensajes históricos vía REST
await fetchConversationWithMessages(id);
// Paso 6: Correlation check — if requestId changed, ignore stale response
const stateAfter = useAppStore.getState();
if (stateAfter.currentRequestId !== requestId) {
console.debug('[MonitorPage] Stale REST response ignored for', id);
return; // User switched conversation, discard
}
// 2. Verificar si hay streams en buffer para esta conversación
// getBufferEntry ahora retorna un ARRAY (multi-stream) — iterar sobre todos
const bufferedStreams = streamBuffer.getBufferEntry(id);
if (bufferedStreams && bufferedStreams.length > 0) {
const sel = useAppStore.getState().selectedConversation;
if (sel && sel.id === id) {
// Mergear cada stream completado en el store
let updatedMessages = [...sel.messages];
for (const stream of bufferedStreams) {
// Ordenar tokens por index y construir contenido completo
const sorted = [...stream.tokens].sort(
(a, b) => a.index - b.index,
);
const content = sorted.map((t) => t.token).join('');
// Verificar si ya existe un mensaje con ese messageId en el store
const existingIdx = updatedMessages.findIndex(
(m) => m.id === stream.messageId,
);
if (existingIdx >= 0) {
const existing = updatedMessages[existingIdx];
if (existing.isStreaming || !existing.content) {
updatedMessages[existingIdx] = {
...existing,
content: existing.content || content,
isStreaming: false,
};
}
} else {
// Insertar como nuevo mensaje (ya completo)
updatedMessages.push({
id: stream.messageId,
conversationId: id,
role: 'agent' as any,
content,
timestamp: new Date().toISOString(),
isStreaming: false,
});
}
}
useAppStore.setState({
selectedConversation: { ...sel, messages: updatedMessages },
});
}
}
// 3. Limpiar buffer para esta conversación
streamBuffer.clear(id);
// 4. Update state machine: check if stream is active
const currentState = useAppStore.getState().conversationStates[id];
if (currentState === 'hydrating') {
// No stream started during hydration → idle
setConversationState(id, 'idle');
}
// If stream already started (via agent_stream_started handler), state is already 'streaming'
} catch (err) {
console.error('[MonitorPage] Failed to load conversation:', err);
// Check correlation before resetting
const stateAfter = useAppStore.getState();
if (stateAfter.currentRequestId === requestId) {
setConversationState(id, 'idle');
}
} finally {
// Clear loading state if still current
const stateAfter = useAppStore.getState();
if (stateAfter.currentRequestId === requestId) {
useAppStore.setState({
loadingConversation: null,
currentRequestId: null,
});
}
}
},
[fetchConversationWithMessages, setConversationState],
);
// ── Conversation click handler ────────────────────────────
const handleConversationClick = (id: string) => {
useAppStore.setState({ selectedConversationId: id });
};
// ── Show connecting placeholder if init_state hasn't arrived ──
if (!initStateReceived) {
return <ConnectingPlaceholder />;
}
// Determine what to render in the right panel
const isCurrentlyLoading = loadingConversation !== null;
const showConversation = selectedConversation && !isCurrentlyLoading;
return (
<div className="flex h-full overflow-hidden">
@@ -68,13 +255,32 @@ export default function MonitorPage() {
/>
))
)}
{conversationsOffset < totalConversations && (
<div className="px-3 pb-3">
<button
type="button"
onClick={handleLoadMore}
className="w-full px-3 py-2 text-[12px] font-medium text-text-secondary
border border-border rounded-lg hover:bg-bg-hover
transition-colors"
>
Cargar más ({totalConversations - conversationsOffset} restantes)
</button>
</div>
)}
</div>
</aside>
{/* ── Right panel (flex-1) ──────────────────────────────── */}
<main className="flex-1 flex flex-col bg-bg-base overflow-hidden">
{selectedConversation ? (
{isCurrentlyLoading ? (
<LoadingConversationOverlay />
) : showConversation ? (
<>
{/* Conversation ended banner (Paso 3) */}
<ConversationEndedBanner conversationId={selectedConversation.id} />
{/* Chat header */}
<div className="shrink-0 px-4 py-2.5 border-b border-border bg-surface flex items-center gap-2">
<span className="text-[13px] font-semibold text-text-primary">