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

489 lines
38 KiB
Markdown

# 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: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 `<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 `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
> [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
```