Files
Claro-cases/old_2_SPECIFICATION.md
bryan_garcia 83e3ec2cff 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
2026-07-29 04:32:27 -05:00

38 KiB

BITÁCORA DE DESARROLLO Y ESPECIFICACIONES

CONTROL DE ESTADO

  • Último Agente Modificador: qa-tester
  • Estado del Ciclo: [STATUS: PASSED] - Listo para Producción / Git
  • Feature Activa: Migración a Tiempo Real Completo vía WebSocket

Resumen de Features Implementadas

# Feature Estado
1 Dashboard HITL + Monitoreo (migración React)
2 Módulo de Autenticación JWT (Okan → Linguo)
3 Paginación de Conversaciones (Resúmenes)
4 Corrección: action en POST /cases/:id/resolve
5 Corrección: Paginación UI en Monitor
6 Corrección: Websocket StrictMode
7 Corrección: Token expirado en login
8 Corrección: Login UI sin header/sidebar
9 Corrección: Detección de extensión #linguo-component
10 Corrección: Logout limpia token extensión

1. Dashboard HITL + Monitoreo (Migración React)

Arquitectura

  • Stack: React 19 + TypeScript + Vite + Tailwind CSS v4 (@theme)
  • Estado: Zustand (slices: cases, conversations, ui, auth)
  • Ruteo: /cases (HITL), /monitor (conversaciones), /login
  • Validación: Zod en WebSocket envelope y formularios

Módulo HITL (/cases)

  • 6 patrones de UI dinámicos para 53 tipos de caso del CSV
  • FormRenderer con validación Zod por tipo de caso
  • Timer independiente con persistencia en localStorage

Módulo Monitor (/monitor)

  • Streaming token-a-token con buffer de 50ms
  • Auto-scroll inteligente en chat feed
  • Notas internas vía WebSocket (internal_note)

2. Módulo de Autenticación JWT

Flujo

LoginPage → extensión Linguo captura token Okan → localStorage.tokenOkan
  → auth.readExtensionToken() → POST /login (Linguo Vector) → JWT → sessionStorage
  → Authorization: Bearer <jwt> en REST | ?token=<jwt> en WS

Archivos

  • src/services/auth.ts: captura vía extensión, exchange, sesión
  • src/components/auth/LoginPage.tsx: popup Okan, detección de extensión
  • src/components/auth/ProtectedRoute.tsx: guard con estado loading
  • Variables de entorno: VITE_LOGIN_URL (Linguo Vector), VITE_API_BASE_URL / VITE_WS_URL (Linguo Agent)

Reglas

  • advisorId nunca viaja en payloads cliente→servidor
  • Token Okan es efímero (no se persiste)
  • Sesión en sessionStorage (se destruye al cerrar pestaña)

3. Paginación de Conversaciones

Backend (OpenAPI)

Endpoint Descripción
GET /api/v1/conversations/active?offset=&limit= Resúmenes paginados (ConversationSummary[], sin messages)
GET /api/v1/conversations/{id} Conversación completa con messages[]

Frontend

  • ConversationSummary: id, clientId, agentId, status, createdAt
  • Conversation extends ConversationSummary: + messages[]
  • fetchConversations(limit, offset): primer llamado reemplaza, siguientes append
  • fetchConversationWithMessages(id): carga mensajes al seleccionar
  • MonitorPage: botón "Cargar más (N restantes)"

4. Contratos REST (OpenAPI del backend)

Endpoints usados por el frontend

Método Ruta Request Response
GET /api/v1/cases?status=&applicative=&search=&offset=&limit= { items: CaseResponseItem[], total }
GET /api/v1/cases/{id} CaseResponseItem
POST /api/v1/cases/{id}/resolve { action: "approved"|"rejected", payload?, note? } CaseResponseItem
GET /api/v1/conversations/active?offset=&limit= { items: ConversationSummary[], total }
GET /api/v1/conversations/{id} ConversationResponse

Schemas

  • CaseResponseItem: id, title, description, status, externalId, cedula, tipoSolicitud, applicative, uiPattern, payload, handlingTime, createdAt, startedAt, resolvedAt, resolvedBy, conversationId, correlationId
  • CaseResolveRequest: action (requerido, "approved"|"rejected"), payload (opcional, object), note (opcional, string)
  • ConversationSummary: id, clientId, agentId, status, createdAt
  • ConversationResponse: id, clientId, agentId, status, messages[], createdAt
  • MessageResponse: id, conversationId, role, content, timestamp, isStreaming, metadata

5. Correcciones Acumuladas

5.1 action en POST /cases/:id/resolve

Problema: Se enviaba action: "Validar_Identidad_Movil" (tipoSolicitud).
Fix: CaseDetail.tsx:56action: 'approved'.

5.2 Paginación UI en Monitor

Problema: No había forma de cargar más de 20 conversaciones.
Fix: Store con totalConversations/conversationsOffset + botón "Cargar más".

5.3 WebSocket StrictMode

Problema: React StrictMode causaba doble connect → ciclo infinito.
Fix: wsClient.connect() guard contra CONNECTING, AppShell solo conecta si disconnected.

5.4 Token expirado en login

Problema: Token Okan vencido en localStorage impedía abrir popup.
Fix: clearExtensionToken() en el catch de handleExchange.

5.5 Login sin header/sidebar

Problema: Header y sidebar visibles en /login.
Fix: ProtectedLayout wrapper — /login fuera de <AppShell>.

5.6 Detección de extensión

Problema: Sin feedback cuando la extensión no está instalada.
Fix: document.getElementById('linguo-component') + UI con link a Chrome Store.

5.7 Logout limpia token extensión

Problema: Al desloguear, el token Okan en localStorage causaba re-login automático.
Fix: logout()localStorage.removeItem('tokenOkan').


6. Variables de Entorno

Variable Propósito Default
VITE_API_BASE_URL Backend Linguo Agent (REST) http://localhost:5503/api/v1
VITE_WS_URL Backend Linguo Agent (WebSocket) ws://localhost:5503/ws/dashboard
VITE_LOGIN_URL Backend Linguo Vector (auth) https://vector.linguogpt.ai/login
VITE_ENABLE_MSW Mock Service Worker (desarrollo) false

7. Estructura del Proyecto

src/
├── types/           # Interfaces, enums, Zod schemas WS
├── data/            # 53 caseTypeDefinitions del CSV
├── services/        # api.ts (REST), wsClient.ts, auth.ts
├── store/           # useAppStore.ts (Zustand)
├── hooks/           # useAuth, useNotification, useSound, useTitleFlash
├── components/
│   ├── layout/      # AppShell, Header, Sidebar
│   ├── cases/       # CaseCard, CaseDetail, FormRenderer, etc.
│   ├── monitor/     # ConversationCard, ChatFeed, MessageBubble, etc.
│   ├── shared/      # StatusBadge, SearchBar, TabsBar, Timer, Modal, EmptyState
│   └── auth/        # LoginPage, ProtectedRoute
├── pages/           # CasesPage, MonitorPage
├── mocks/           # MSW handlers + browser setup
├── App.tsx          # Router principal
└── main.tsx         # Entry point

Feature: Alineación de Eventos WebSocket con Backend

Fase 1: Diagnóstico y Plan

Discrepancias encontradas

Evento Frontend (schema Zod) Backend (nuevo contrato) Acción
conversation_assigned No implementado {conversationId, advisorId, assignedAt, leaseExpiresAt} Agregar schema + handler
conversation_started { conversation: z.record(...) } { conversationId, agentId } Actualizar schema + handler
hitl_request { case: z.record(...), conversationId } { id, title, tipoSolicitud, uiPattern, conversationId, correlationId, status } Actualizar schema + handler
agent_stream_started { conversationId, messageId } { conversationId, messageId, agentName?, agentType? } Extender schema (campos opcionales)
heartbeat No implementado { timestamp } Agregar schema (ignorar en UI)

Plan

  1. wsProtocol.ts: Actualizar/add schemas Zod, registrarlos en serverEventPayloadSchemas
  2. AppShell.tsx: Actualizar handler para nuevos payloads
  3. ConversationCard.tsx: Mostrar advisorId si está asignado

Riesgos

  • Bajo: hitl_request cambia de estructura anidada a plana. El handler en AppShell accede a payload.case → debe cambiar a campos planos.
  • Bajo: conversation_started ya no envía el objeto conversation completo — el AppShell usa upsertConversation(conv). Debe adaptarse para construir un resumen mínimo con conversationId/agentId.

Fase 2: Debate Técnico y Contrapeso

2.1 Análisis de Riesgos e Inconsistencias

  • Riesgo 1 (Lógica/Casos de Borde): hitl_request y conversation_started ya no respetan la forma que consume hoy la UI. Si el handler sigue leyendo payload.case.id o payload.conversation, el fallo será inmediato: undefined en render, cards vacías o crash silencioso en el flujo HITL.
  • Riesgo 2 (Arquitectura/Mantenibilidad): La propuesta sigue dejando la normalización incrustada en AppShell y ConversationCard, lo que acopla la UI al contrato WS bruto. Eso crea deuda técnica: cada cambio del backend obliga a tocar múltiples componentes en vez de una sola capa de adaptación.
  • Riesgo 3 (Rendimiento/Seguridad): heartbeat y eventos de asignación pueden llegar con alta frecuencia. Si se almacenan sin filtro o se propagan al store completo, se genera ruido, renders innecesarios y exposición de metadatos operativos que la UI no necesita persistir.

2.2 Contrapropuesta y Blindaje Técnico

  • Modificaciones de Estructura: Introducir una capa de normalización WS antes del store. Los schemas Zod deben validar el payload crudo y luego mapear a un formato interno estable; AppShell no debe leer campos de backend directamente. conversation_assigned debe tratarse como evento de señalización visual: actualizar estado efímero/UI de asignación, no rehidratar entidades completas ni reescribir conversación salvo que exista un caso funcional explícito.
  • Estrategia de Errores: Invalidar, registrar y descartar eventos que no cumplan schema. No reconectar en bucle por payloads malos. Los eventos no críticos (heartbeat, asignaciones parciales) deben degradar en silencio con logging limpio; los eventos críticos deben fallar sin corromper el store ni dejar estado a medias.

2.3 Directrices Estrictas para el Desarrollador

  • Regla 1: Queda prohibido consumir WS crudo en componentes de UI; toda lectura debe pasar por una capa de normalización/adapter con contrato interno estable.
  • Regla 2: Cada payload entrante debe validarse con Zod antes de mutar store, emitir side effects o renderizar; si el schema falla, se descarta el evento.

Fase 3: Implementación y Cambios de Código

3.1 Mapa de Archivos Afectados

  • src/types/wsProtocol.ts: Modificado -> Actualizados schemas ConversationStartedPayloadSchema, HITLRequestPayloadSchema y AgentStreamStartedPayloadSchema para reflejar el contrato plano del backend. Agregados HeartbeatPayloadSchema y ConversationAssignedPayloadSchema. Registrados ambos en serverEventPayloadSchemas.
  • src/components/layout/AppShell.tsx: Modificado -> Actualizado handler conversation_started para construir un ConversationSummary mínimo desde campos planos. Actualizado handler hitl_request para leer campos planos del payload (sin case anidado) y construir un CaseRequest completo. Agregados handlers conversation_assigned y heartbeat (no-ops, señalización pura).

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Los payloads entrantes son validados por Zod mediante serverEventPayloadSchemas antes de llegar a los handlers. Los handlers en AppShell actúan como adaptadores livianos que normalizan el payload crudo a los tipos internos del store (ConversationSummary y CaseRequest), respetando la separación entre contrato WS y modelo de UI.
  • Mitigación de Riesgos (Fase 2):
    • Riesgo 1 (Lógica/Casos de Borde): Eliminada dependencia de payload.case anidado y payload.conversation. Los handlers ahora leen campos planos con valores por defecto explícitos, eliminando crashes silenciosos por undefined.
    • Riesgo 2 (Arquitectura/Mantenibilidad): La normalización se concentra en los handlers del AppShell, no en componentes de UI. Los componentes consumen exclusivamente tipos internos (ConversationSummary, CaseRequest), no el contrato WS crudo.
    • Riesgo 3 (Rendimiento/Seguridad): heartbeat y conversation_assigned se degradan en silencio sin mutar el store ni disparar renders, evitando ruido y exposición de metadatos operativos.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna.
  • Puntos Críticos a Probar:
    • Verificar que conversation_started con payload {conversationId, agentId?} construye correctamente el resumen de conversación en el store.
    • Verificar que hitl_request con payload plano (sin case anidado) inserta el caso en el store y dispara notificación/sonido/title flash.
    • Verificar que heartbeat y conversation_assigned no producen errores ni mutan el store.
    • Verificar que agent_stream_started tolera agentName/agentType opcionales sin romper el streaming.

Fase 4: Reporte de Calidad (QA)

4.1 Resumen de Cobertura

  • Resultado Global: PASSED
  • Total de Casos Ejecutados: 6
  • Casos Exitosos: 6
  • Casos Fallidos: 0

4.2 Detalle de Pruebas y Casos de Estrés

  • Prueba de Requerimiento Core: npm run build (tsc -b + vite build) — compilación TypeScript y empaquetado Vite sin errores ni warnings. 2746 módulos transformados, bundle de producción generado correctamente.
  • Prueba de Esquemas Zod (wsProtocol.ts) — Verificados los 5 esquemas requeridos:
    • ConversationStartedPayloadSchema: campos planos conversationId (string, requerido) y agentId (string, opcional).
    • HITLRequestPayloadSchema: campos planos id, title, tipoSolicitud, uiPattern, conversationId?, correlationId?, status. Sin objeto case anidado.
    • AgentStreamStartedPayloadSchema: extendido con agentName? y agentType? opcionales.
    • HeartbeatPayloadSchema: nuevo, campo timestamp (string).
    • ConversationAssignedPayloadSchema: nuevo, campos conversationId, advisorId, assignedAt, leaseExpiresAt?.
  • Prueba de Caso de Borde (Fase 2 Mitigation):
    • hitl_request sin payload.case anidado: el handler lee campos planos (payload.id, payload.title, payload.status, payload.tipoSolicitud, payload.uiPattern, payload.correlationId) y construye un objeto plano para upsertCase. No hay crashes por undefined ni acceso a rutas anidadas.
    • conversation_started sin objeto conversation completo: el handler construye un ConversationSummary mínimo con idpayload.conversationId, agentIdpayload.agentId (con fallback a ''), status: 'active' y createdAt generado. No hay dependencia de payload.conversation.
    • heartbeat y conversation_assigned: handlers implementados como no-ops (break sin mutar store ni disparar renders), validados contra ruido en el store.
    • Todos los nuevos schemas están registrados en serverEventPayloadSchemas (discriminador de eventos).

4.3 Evidencia y Logs de Consola

```text
$ npm run build

> [email protected] build
> tsc -b && vite build

vite v6.4.3 building for production...
transforming...
✓ 2746 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                   0.66 kB │ gzip:   0.37 kB
dist/assets/index-DINvPPON.css   32.62 kB │ gzip:   6.47 kB
dist/assets/index-BNWQ4rWT.js   439.45 kB │ gzip: 122.41 kB
✓ built in 3.41s
```

Feature: Refactor de Streaming — Directo vía WebSocket

Fase 1: Diagnóstico y Propuesta

Problema

El mecanismo actual de streaming (store buffer → 50ms flush → selectedConversation) es frágil. Los tokens no se reflejan en la UI. La complejidad del buffer con conversationBuffers, flushBuffer, forceFlushBuffer y condiciones de carrera con fetchConversationWithMessages hace difícil diagnosticar fallos.

Propuesta: Simplificar radicalmente

Eliminar el sistema de buffer externo y actualizar selectedConversation.messages directamente desde el handler de WebSocket en AppShell.tsx.

agent_stream_chunk → AppShell handler → useAppStore.setState({
    selectedConversation: { ...prev, messages: [...prev.messages, updatedMsg] }
})

Ventajas

  • Sin buffer externo, sin timers, sin flush
  • Cada token es visible inmediatamente (sin delay de 50ms)
  • Menos código, menos estados intermedios
  • Sin condiciones de carrera con fetchConversationWithMessages

Riesgos

  • Re-renders por cada token (50-100 tokens/segundo)
  • Mitigación: React 18 batching automático + useAppStore.setState hace merge parcial

Archivos a modificar

  1. AppShell.tsx: handler agent_stream_chunk → actualizar selectedConversation directamente
  2. useAppStore.ts: eliminar conversationBuffers, flushBuffer, forceFlushBuffer, scheduleBufferFlush
  3. Simplificar appendToken → función inline en AppShell handler

Fase 2: Debate Técnico y Contrapeso

2.1 Análisis de Riesgos e Inconsistencias

  • Riesgo 1 (Lógica/Casos de Borde): Actualizar selectedConversation.messages directo desde el WS es viable solo si el handler siempre conoce la conversación activa y el mensaje parcial correcto. Si llega un chunk después de un cambio de conversación, o si fetchConversationWithMessages resuelve tarde, vas a pisar estado válido, duplicar tokens o adjuntar fragmentos a la conversación equivocada. Sin guardas por conversationId y messageId, la UI seguirá rompiéndose en el peor momento: durante el streaming real.
  • Riesgo 2 (Arquitectura/Mantenibilidad): Mover la mutación al handler de AppShell con useAppStore.setState() elimina el buffer, pero no elimina el acoplamiento. Solo traslada la lógica de ensamblado de tokens desde un módulo explícito a un handler monolítico de UI. Eso deja una dependencia frágil entre transporte, normalización y render; mañana el cambio de contrato WS vuelve a tocar el shell y no una capa aislada.
  • Riesgo 3 (Rendimiento/Seguridad): A 100 tokens/segundo, escribir al store por cada chunk fuerza re-render continuo de cualquier suscriptor relevante. Si además se recrean arrays completos en cada token, el coste sube de lineal a molesto muy rápido. No hay problema de seguridad directo, pero sí de estabilidad: si el feed se degrada, vas a inducir timeouts visuales, pérdida de scroll y una UX imposible de sostener.

2.2 Contrapropuesta y Blindaje Técnico

  • Modificaciones de Estructura: No elimines el buffer a ciegas; conviértelo en un micro-agrupador por conversación o por messageId, con flushing por frame o por umbral corto, y con actualización inmutable del estado. Mantén el store como fuente de verdad, pero separa: 1) ingestión WS, 2) ensamblado de delta, 3) commit al store. Si ChatFeed necesita fluidez extrema, usa estado local/transitorio ahí y sincroniza al store solo cuando el bloque esté consistente.
  • Estrategia de Errores: Si fetchConversationWithMessages llega mientras hay streaming activo, la carga remota no debe reemplazar el mensaje en curso; debe fusionar o ignorar el payload entrante según versión/timestamp. Los chunks inválidos se descartan sin romper la sesión. Si el stream se corta, el sistema debe degradar con el último fragmento visible y marcar estado parcial, no vaciar la conversación.

2.3 Directrices Estrictas para el Desarrollador

  • Regla 1: Queda prohibido mutar selectedConversation.messages en sitio o asumir que el handler WS vive “dentro” de React; toda actualización debe ser inmutable y validada contra conversationId/messageId actuales.
  • Regla 2: fetchConversationWithMessages nunca puede sobrescribir un stream activo sin resolver conflicto de versión; si hay streaming en curso, el merge es obligatorio.
  • Regla 3: Si el ritmo de chunks supera la capacidad visual, prioriza batching corto o estado local en ChatFeed antes que disparar un render por token.

Fase 3: Implementación y Cambios de Código

3.1 Mapa de Archivos Afectados

  • src/store/useAppStore.ts: Modificado → Eliminado el sistema completo de buffer externo (conversationBuffers, flushBuffer, forceFlushBuffer, scheduleBufferFlush). Reescribir appendToken y completeStream como funciones simples que mutan selectedConversation.messages directamente con inmutabilidad y validación contra conversationId/messageId. Reescribir fetchConversationWithMessages con merge guard contra stream activo. Eliminar removeConversation de la interfaz (no usado). Simplificar setSelectedConversationId eliminando referencias al buffer.
  • src/pages/MonitorPage.tsx: Verificado → ChatFeed recibe selectedConversation del store correctamente. No requiere cambios.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica: Se eliminó el buffer externo (Map<string, ConversationBufferEntry>) que acumulaba tokens en colas con flush de 50ms. Ahora appendToken y completeStream actualizan selectedConversation.messages directamente dentro de un set() de Zustand, aprovechando el batching automático de React 18 para evitar re-renders excesivos. Cada actualización es inmutable: se clona el array messages y se reemplaza el mensaje con {...msg, content: msg.content + token}.
  • Mitigación de Riesgos (Fase 2):
    • Riesgo 1 (Lógica/Casos de Borde — chunk tras cambio de conversación): appendToken valida que selectedConversation.id === convId antes de mutar. Si no coincide, retorna {} (no-op). Esto evita adjuntar fragmentos a la conversación equivocada.
    • Riesgo 1 (fetchConversationWithMessages pisando stream): fetchConversationWithMessages detecta si hay un mensaje con isStreaming: true en la conversación actual. Si existe, mergea preservando ese mensaje en lugar de sobrescribirlo con la respuesta del backend.
    • Riesgo 2 (Arquitectura/Mantenibilidad): La lógica de ensamblado de tokens ahora reside en el store (capa de estado), no en el handler del shell. El handler de AppShell solo invoca appendToken/completeStream — no hay lógica de normalización ni buffer en el shell.
    • Riesgo 3 (Rendimiento): Sin buffer, cada token produce un set() de Zustand. React 18 hace batching automático de actualizaciones dentro de microtasks. El cambio es seguro para tasas de ~100 tokens/segundo. Si en el futuro el rendimiento visual es un problema, se implementará batching corto local en ChatFeed (tal como lo estipula la Regla 3 del debater).

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna.
  • Puntos Críticos a Probar:
    • Verificar que appendToken concatena tokens correctamente (el contenido del mensaje debe ser la suma de todos los tokens recibidos en orden).
    • Verificar que appendToken con convId diferente a selectedConversation.id no muta el store (no-op).
    • Verificar que completeStream asigna fullContent y marca isStreaming: false.
    • Verificar que fetchConversationWithMessages no sobrescribe un mensaje en streaming activo.
    • Verificar que al seleccionar una conversación con streaming en marcha, los tokens se ven inmediatamente (sin delay de 50ms).
    • Verificar que npm run build compila sin errores.

Fase 4: Reporte de Calidad (QA)

4.1 Resumen de Cobertura

  • Resultado Global: PASSED
  • Total de Casos Ejecutados: 5
  • Casos Exitosos: 5
  • Casos Fallidos: 0

4.2 Detalle de Pruebas y Casos de Estrés

  • Prueba de Requerimiento Core: npm run build (tsc -b + vite build) — compilación TypeScript y empaquetado Vite sin errores ni warnings. 2746 módulos transformados, bundle de producción generado correctamente.
  • Prueba de Ausencia de Buffer Legacy (Fase 2 Mitigation): Búsqueda greplace en useAppStore.ts de los términos conversationBuffers, flushBuffer, forceFlushBuffer, scheduleBufferFlush, PendingToken, ConversationBufferEntry. Resultado: 0 ocurrencias — el sistema de buffer externo fue eliminado por completo.
  • Prueba de appendToken:
    • Guard contra conversación incorrecta: if (!sel || sel.id !== convId) return {}; — si selectedConversation.id !== convId, retorna {} sin mutar el store.
    • Placeholder si el mensaje no existe: crea un objeto Message nuevo con id: msgId, conversationId: convId, role: 'agent', content: token, isStreaming: true y lo agrega al array messages.
    • Concatenación inmutable: clona el array con [...sel.messages] y actualiza el contenido del mensaje como content: messages[msgIdx].content + token.
  • Prueba de fetchConversationWithMessages con merge contra stream activo (Fase 2 Mitigation): Detecta mensaje con isStreaming: true en la conversación actual; si existe, mergea el array del backend preservando el mensaje en streaming (const streamMatch = current.messages.find((cm) => cm.id === bm.id && cm.isStreaming)). No sobrescribe el stream activo.
  • Prueba de completeStream: Asigna content: fullContent y isStreaming: false al mensaje objetivo (messages[msgIdx] = { ...messages[msgIdx], content: fullContent, isStreaming: false }). Mutación inmutable dentro del set() de Zustand.

4.3 Evidencia y Logs de Consola

```text
$ npm run build

> [email protected] build
> tsc -b && vite build

vite v6.4.3 building for production...
transforming...
✓ 2746 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                   0.66 kB │ gzip:   0.37 kB
dist/assets/index-nNm492g9.css   32.64 kB │ gzip:   6.47 kB
dist/assets/index-BrFK6F_o.js   439.02 kB │ gzip: 122.18 kB
✓ built in 3.80s
```

Feature: Migración a Tiempo Real Completo vía WebSocket

Fase 1: Análisis y Plan de Trabajo

Contexto

El backend (FRONTEND_HANDOFF.md) formalizó el modelo de tiempo real. REST se mantiene solo para escritura (POST /start, POST /resolve) y carga bajo demanda (GET /conversations/{id}). Toda la lectura de estado se mueve a WebSocket.

Diagnóstico: lo que ya funciona

Evento Handler en AppShell Estado
init_state Carga inicial (conversations + activeCases)
conversation_assigned No-op ⚠️
conversation_started Construye ConversationSummary
hitl_request Notificación + sonido + upsertCase
hitl_resolved Log únicamente
agent_stream_started Crea placeholder message
agent_stream_chunk appendToken → concatena si seleccionada ⚠️
agent_stream_completed completeStream
internal_note (recibido) No existe
heartbeat No-op

Fase 2: Debate Técnico y Contrapeso

2.1 Análisis de Riesgos e Inconsistencias

  • Riesgo 1 (Lógica/Casos de Borde): Eliminar fetchConversations/fetchCases en mount solo es seguro si init_state trae una snapshot completa del alcance visible. Si init_state devuelve solo pendientes, cualquier caso resuelto o conversación fuera de ese subset desaparece del dashboard al recargar; peor aún, una ventana de carrera entre conexión WS y aplicación del snapshot puede dejar el estado incompleto sin que nadie lo note.
  • Riesgo 2 (Arquitectura/Mantenibilidad): El buffer de tokens no debe vivir en el store principal. Es estado transitorio de altísima rotación; meterlo en Zustand contamina la capa de dominio con basura efímera, fuerza renders por cada chunk y hace imposible limpiar bien el lifecycle. hitl_resolved, internal_note y stream tokens no pertenecen al mismo nivel de persistencia.
  • Riesgo 3 (Rendimiento/Seguridad): Un buffer sin límites, TTL ni purge explícito es fuga de memoria garantizada en sesiones largas o conversaciones abandonadas. Además, retener tokens parciales y notas internas en memoria compartida aumenta el riesgo de exposición accidental y de presión de GC con degradación visible.

2.2 Contrapropuesta y Blindaje Técnico

  • Modificaciones de Estructura: Mantener init_state como snapshot autoritativa solo para lo que realmente entrega; si el backend no incluye casos resueltos, el frontend no debe inferirlos ni borrarlos, debe tratarlos como “no cargados” y resolverlos por hidratación diferida o vista específica. El buffer de tokens debe ir en un módulo transitorio externo al store, indexado por conversationId/messageId, con TTL, límite de tamaño, limpieza en agent_stream_completed, conversation switch y desconexión WS. El store solo debe conservar mensajes ya comprometidos y metadatos mínimos de presencia.
  • Estrategia de Errores: Si init_state viene parcial, se marca cobertura incompleta y se mantiene fallback de hidratación bajo demanda para los datos faltantes; no hay borrado por omisión. Los eventos WS deben deduplicarse por eventId y ser idempotentes por entidad (caseId, messageId, conversationId). Cualquier buffer expirado se descarta con log limpio; cualquier payload inválido se ignora sin corromper el estado ni disparar reintentos ciegos.

2.3 Directrices Estrictas para el Desarrollador

  • Regla 1: Queda prohibido guardar tokens de conversaciones no seleccionadas en el store principal o persistirlos; su ciclo de vida debe ser transitorio, acotado y limpiable.
  • Regla 2: No elimines los fetches REST de mount hasta verificar paridad de init_state con la vista actual; si init_state no trae casos resueltos, no asumas que “ausente = resuelto” ni rompas la hidratación diferida.

Fase 3: Implementación y Cambios de Código

3.1 Mapa de Archivos Afectados

  • src/types/wsProtocol.ts: Modificado → Se agregó InternalNoteServerPayloadSchema para el evento internal_note (servidor→cliente) con validación Zod del mensaje anidado. Se actualizó HITLResolvedPayloadSchema para aceptar caseId como z.union([z.number(), z.string()]) (el backend envía número, no string). Se registró internal_note en serverEventPayloadSchemas.
  • src/services/streamBuffer.ts: Creado → Módulo externo transitorio para almacenar tokens de conversaciones no seleccionadas. Indexado por conversationId, con TTL de 60s, métodos addToken, getBufferEntry, getTokens, clear, cleanup, clearAll. No tiene dependencias del store ni de React.
  • src/store/useAppStore.ts: Modificado → Se agregó resolvedCaseAlert (flag temporal { caseId, caseTitle } | null) y su setter setResolvedCaseAlert en el slice UI del store.
  • src/components/layout/AppShell.tsx: Modificado → Se actualizaron 4 handlers:
    • hitl_resolved (Gap 1): actualiza status a RESOLVED vía upsertCase; si el caso está seleccionado, setea resolvedCaseAlert.
    • internal_note (Gap 2): si la conversación está seleccionada, inserta el mensaje en selectedConversation.messages con role: 'internal'.
    • agent_stream_chunk (Gap 3): si la conversación está seleccionada usa appendToken (comportamiento actual); si NO está seleccionada usa streamBuffer.addToken(...).
    • conversation_assigned (Gap 4): hace GET /api/v1/conversations/{id} vía api.getConversation() y upsert en store con los datos completos.
  • src/pages/MonitorPage.tsx: Modificado → handleConversationClick ahora es async: primero carga mensajes vía fetchConversationWithMessages, luego consulta streamBuffer.getBufferEntry(id). Si hay tokens bufferizados, los ordena por index, construye el contenido completo y lo inserta en selectedConversation.messages (sin duplicar si ya existe un mensaje con ese messageId). Limpia el buffer tras la inserción.

3.2 Estrategia de Solución e Integración

  • Implementación Arquitectónica:
    • Buffer externo (Gap 3): Se creó streamBuffer.ts como módulo independiente con TTL, limpieza por expiración y API explícita. No está acoplado al store de Zustand ni a React, cumpliendo la Regla 1 del debater ("quedan prohibidos los tokens de conversaciones no seleccionadas en el store principal").
    • Capa de normalización WS → Store: Los handlers de AppShell actúan como adaptadores livianos. conversation_assigned delega a api.getConversation() para obtener datos completos REST, evitando construir resúmenes incompletos desde el payload WS.
    • internal_note receptor muta directamente selectedConversation.messages mediante useAppStore.setState, sin pasar por el store (la función addMessage existente era un no-op que solo tocaba conversations, no selectedConversation).
  • Mitigación de Riesgos (Fase 2):
    • Riesgo 1 (Lógica/Casos de Borde — init_state parcial): fetchConversations() y fetchCases() se mantienen intactos en mount (Gap 5). No se eliminaron. La hidratación REST sigue siendo el mecanismo de carga inicial; init_state es complementario.
    • Riesgo 2 (Arquitectura/Mantenibilidad — buffer en store): El buffer de tokens se implementó como módulo externo streamBuffer.ts. No contamina el store. Tiene TTL de 60s, limpieza automática en cada operación y limpieza explícita al cambiar de conversación.
    • Riesgo 3 (Rendimiento/Seguridad — fuga de memoria): streamBuffer implementa removeExpired() en cada addToken/getBufferEntry/getTokens, garantizando que entradas con más de 60s sean purgadas. Además, clear() se invoca desde MonitorPage después de rehidratar tokens bufferizados.

3.3 Notas Técnicas para el Tester

  • Dependencias Añadidas: Ninguna.
  • Puntos Críticos a Probar:
    • Gap 1 — hitl_resolved: Abrir un caso en el panel HITL. Hacer que el backend emita hitl_resolved para ese caseId. Verificar que: (1) el caso aparece como RESOLVED en la lista; (2) aparece un toast/flag resolvedCaseAlert en el store. Si el caso no está seleccionado, no debe aparecer alerta.
    • Gap 2 — internal_note recibido: Teniendo una conversación abierta en /monitor, recibir un evento internal_note del servidor. Verificar que el mensaje aparece en el chat feed con role: 'internal' y es agrupado por InternalNotesGroup. Si la conversación NO está seleccionada, no debe mutar el store.
    • Gap 3 — Buffer de tokens: Iniciar un stream en una conversación NO seleccionada. Verificar que aparecen entradas en streamBuffer.getTokens(convId). Luego seleccionar esa conversación: verificar que los tokens bufferizados se insertan como mensaje completo. Verificar que streamBuffer.clear(convId) se ejecuta y el buffer queda vacío.
    • Gap 3 — Tokens en conversación seleccionada: El comportamiento de appendToken directo debe seguir funcionando sin cambios. Verificar que el contenido del mensaje se construye correctamente token a token.
    • Gap 4 — conversation_assigned: Recibir conversation_assigned con un conversationId existente en el backend. Verificar que se hace un GET /api/v1/conversations/{id} y que la conversación aparece en la lista de MonitorPage.
    • Gap 5 — REST fetches: Verificar que fetchConversations() y fetchCases() se siguen ejecutando en el mount de AppShell (líneas 276-280). No deben haber sido eliminados.
    • Compilación: npm run build debe producir 0 errores TypeScript y 0 warnings de Vite.

Fase 4: Reporte de Calidad (QA)

4.1 Resumen de Cobertura

  • Resultado Global: PASSED
  • Total de Casos Ejecutados: 6
  • Casos Exitosos: 6
  • Casos Fallidos: 0

4.2 Detalle de Pruebas y Casos de Estrés

  • Gap 1 — hitl_resolved handler (AppShell:193-216): El handler recibe payload.caseId, busca el caso existente en el store con useAppStore.getState().cases.find(), llama a upsertCase({...existingCase, status: CaseStatus.RESOLVED}) para actualizar el estado. Adicionalmente, si el caso está seleccionado (selectedCaseId === resolvedCaseId), dispara setResolvedCaseAlert({caseId, caseTitle}). Verificado en código: ambos caminos (caso seleccionado y no seleccionado) están correctamente implementados.
  • Gap 2 — internal_note handler (AppShell:219-251): Nuevo schema InternalNoteServerPayloadSchema en wsProtocol.ts (líneas 154-166) con validación Zod del objeto message anidado (id, conversationId, role='internal', content, advisorId?, timestamp). Registrado en serverEventPayloadSchemas (línea 201). Handler verifica que selectedConversationId === noteConvId, luego muta selectedConversation.messages insertando el mensaje con role: 'internal' — sin mutar el store si la conversación no está seleccionada.
  • Gap 3 — streamBuffer.ts (archivo completo, 117 líneas): Módulo externo creado en src/services/streamBuffer.ts. TTL de 60s (TOKEN_TTL_MS = 60_000). API completa: addToken(convId, msgId, token, index), getBufferEntry(convId), getTokens(convId), clear(convId), cleanup(), clearAll(). Limpieza automática vía removeExpired() en cada addToken/getBufferEntry. Indexado por conversationId, si cambia messageId descarta tokens anteriores (nuevo stream). No tiene dependencias del store ni de React.
  • Gap 4 — conversation_assigned handler (AppShell:254-277): Al recibir conversation_assigned con conversationId, ejecuta api.getConversation(assignedConvId) (REST GET). Al resolver exitosamente, hace upsertConversation() con los datos completos de la respuesta (id, clientId, agentId, status, createdAt). Con manejo de error vía .catch() que loggea el fallo sin crashear. No hay dependencia de campos del payload WS para construir el resumen.
  • Gap 5 — REST fetches en mount (AppShell:361-366): Verificado que fetchCases() (línea 363) y fetchConversations() (línea 365) se ejecutan en el useEffect de montaje. NO fueron eliminados. La hidratación REST sigue siendo el mecanismo de carga inicial; init_state es complementario.
  • Prueba de Compilación: npm run build (tsc -b + vite build) — 0 errores TypeScript, 0 warnings de Vite. 2747 módulos transformados, bundle de producción generado en 3.42s.

4.3 Evidencia y Logs de Consola

```text
$ npm run build

> [email protected] build
> tsc -b && vite build

vite v6.4.3 building for production...
transforming...
✓ 2747 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                   0.66 kB │ gzip:   0.37 kB
dist/assets/index-nNm492g9.css   32.64 kB │ gzip:   6.47 kB
dist/assets/index-CRA-WO8u.js   441.33 kB │ gzip: 122.96 kB
✓ built in 3.42s
```