# 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 en REST | ?token= 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:56` → `action: '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 ``. ### 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 `id` ← `payload.conversationId`, `agentId` ← `payload.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 > claro-cases@2.0.0 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`) 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 > claro-cases@2.0.0 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 > claro-cases@2.0.0 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 ```