Ran command: git status --short

Ran command: `git diff --cached`
Ran command: `git diff --cached SPECIFICATION.md src/components/layout/AppShell.tsx`

feat(ws): handle nested WS payload envelope and handle assigned conversation events

* Automatically unwrap double-nested WS payloads (`payload.payload`) in `WsClient`.
* Handle `conversation_assigned` WS event in `AppShell` by fetching details via REST.
* Deduplicate `upsertConversation` calls and update conversation handling logic.
* Add comprehensive test suites for WS unwrapping and store deduplication.
This commit is contained in:
2026-07-31 14:13:57 -05:00
parent 83e3ec2cff
commit 9f86dc0d0d
5 changed files with 550 additions and 24 deletions
+199 -2
View File
@@ -1,8 +1,8 @@
# BITÁCORA DE DESARROLLO Y ESPECIFICACIONES # BITÁCORA DE DESARROLLO Y ESPECIFICACIONES
## CONTROL DE ESTADO ## CONTROL DE ESTADO
- **Último Agente Modificador**: git-ops - **Último Agente Modificador**: qa-tester
- **Estado del Ciclo**: Sincronizado con Repositorio Remoto - Ciclo Cerrado - **Estado del Ciclo**: [STATUS: PASSED] - Listo para Producción / Git
--- ---
## Fase 1: Requerimientos y Plan Inicial (v2 — Revisado) ## Fase 1: Requerimientos y Plan Inicial (v2 — Revisado)
@@ -778,3 +778,200 @@ useEffect(() => {
- Validar que cleanup al desmontar ejecute `clearInterval`, resetee `isRunningRef.current` a `false`, y llame a `stop()` correctamente. - Validar que cleanup al desmontar ejecute `clearInterval`, resetee `isRunningRef.current` a `false`, y llame a `stop()` correctamente.
- Confirmar que `stop()` no lance errores al ser invocada durante el cleanup (idempotencia). - Confirmar que `stop()` no lance errores al ser invocada durante el cleanup (idempotencia).
- Ejecutar la suite de tests: `npx vitest run src/components/shared/Timer.test.tsx --reporter=verbose`. - Ejecutar la suite de tests: `npx vitest run src/components/shared/Timer.test.tsx --reporter=verbose`.
---
## Fase 13: Bug — Doble Envoltura en Eventos WebSocket + Duplicados en Lista
### 13.1 Síntomas
Tras análisis de logs del navegador:
1. **Todos** los `agent_stream_chunk` llegan con payload incompleto: `conversationId`, `messageId`, `token` e `index` = `undefined`. Esto explica por qué los tokens nunca se renderizan.
2. React advierte `Encountered two children with the same key` para conversaciones `test-hitl-001` y `test-hitl-002`, indicando entradas duplicadas en la lista.
### 13.2 Causa Raíz: Doble envoltura (nested envelope)
Inspeccionando el mensaje real en la pestaña Network del navegador, se descubrió que el backend envía los eventos con **doble envoltura**:
```json
{
"type": "agent_stream_chunk",
"eventId": "98ee02ca-...",
"occurredAt": "2026-07-29T20:36:37.402610Z",
"payload": {
"type": "agent_stream_chunk", ← ¡envoltura interna repetida!
"eventId": "97d17fca-...",
"occurredAt": "2026-07-29T20:36:37.319750Z",
"payload": {
"conversationId": "test-hitl-005", ← datos reales aquí
"messageId": "bc66c31c...",
"token": " plan",
"index": 467
}
}
}
```
El handler en `AppShell.tsx` lee `payload.conversationId` → `undefined` porque el primer `payload` contiene otra envoltura, no los datos. Los datos reales están en `payload.payload`.
**Esto afecta a TODOS los tipos de evento** (`agent_stream_chunk`, `agent_stream_started`, `hitl_request`, `hitl_resolved`, etc.), no solo a chunks.
### 13.3 Causa Secundaria: Duplicados en lista de conversaciones
El log muestra `conversation_started` + `conversation_assigned` para la misma conversación. Si ambos eventos son procesados sin deduplicación adecuada, se crean dos entradas. Adicionalmente, si `upsertConversation` busca por `id` pero el `id` se obtiene de `payload.payload.conversationId` (fallando por la doble envoltura), se inserta con un ID incorrecto, creando duplicados.
### 13.4 Plan de Solución
#### Fix #1: Desanidar doble envoltura en `wsClient.ts`
**Archivo**: `src/services/wsClient.ts`, método `onmessage`
Antes de delegar al callback `onMessage`, detectar y desanidar la doble envoltura:
```typescript
// Detectar y desanidar doble envoltura (nested envelope)
// El backend envía: { type, eventId, occurredAt, payload: { type, eventId, occurredAt, payload: {...} } }
if (
data.payload &&
typeof data.payload === 'object' &&
!Array.isArray(data.payload) &&
(data.payload as Record<string, unknown>).type &&
(data.payload as Record<string, unknown>).payload
) {
// Usar el eventId interno (más cercano al evento real)
const inner = data.payload as Record<string, unknown>;
data = { ...data, eventId: inner.eventId || data.eventId, payload: inner.payload };
}
```
Esto normaliza todos los eventos a la estructura esperada: `{ type, eventId, payload: { datos reales } }`, antes de que lleguen a `AppShell`. **Un solo cambio, todos los handlers se benefician.**
#### Fix #2: Deduplicar conversaciones en `AppShell.tsx`
En los handlers `conversation_started` y `conversation_assigned`, reforzar la verificación de duplicados usando `conversations.findIndex` en lugar de `conversations.find`, y loguear cuando se detecta un duplicado para visibilidad.
### 13.5 Criterios de Aceptación
- [ ] **CA-16**: Al recibir un `agent_stream_chunk` con doble envoltura, el handler en `AppShell` recibe `payload.conversationId` correctamente (no undefined).
- [ ] **CA-17**: Los tokens de streaming se renderizan en el `ChatFeed` al tener una conversación seleccionada.
- [ ] **CA-18**: No aparecen entradas duplicadas de conversaciones en la lista del monitor.
- [ ] **CA-19**: La desanidación funciona para todos los tipos de evento (`agent_stream_started`, `hitl_request`, `hitl_resolved`, `conversation_assigned`, etc.).
## Fase 14: Debate Técnico — Doble Envoltura WS
### 14.1 Evaluación del Diagnóstico
- El diagnóstico es **parcialmente correcto**: existe una doble envoltura real, pero el fix no debe asumir que todo `payload.payload` es basura a aplanar.
- Desanidar en `wsClient.ts` es aceptable como normalización de transporte, **si** se hace antes de entrar al dominio y con una guardia explícita para mensajes de control.
- No es el lugar para mezclar reglas de negocio; `wsClient` solo debe normalizar el sobre, no interpretar semántica de eventos.
### 14.2 Riesgos
- La condición `data.payload && data.payload.type && data.payload.payload` es demasiado laxa: puede destruir mensajes bien formados cuyo `payload` sea un objeto con esas claves por casualidad.
- El mensaje `{"status":"authenticated"}` no debería romperse **si** se filtra antes de la normalización; si no, cualquier refactor que cambie el orden de chequeo lo vuelve frágil.
- Usar el `eventId` interno como verdad absoluta es peligroso si el servidor no garantiza unicidad, estabilidad e intención semántica para ese campo.
- Los duplicados de la lista no dependen solo de la doble envoltura; también hay un bug separado de deduplicación/ruteo de eventos de conversación.
### 14.3 Mejoras
- Definir un guard estricto para detectar envelope anidado solo cuando el sobre interno replique el evento externo y contenga un `payload` de datos real.
- Tratar `authenticated` y otros mensajes de control como una ruta aparte, fuera de cualquier desanidación.
- Mantener idempotencia por un identificador canónico acordado con backend; si hay doble sobre, resolver cuál es la fuente autoritativa y documentarlo.
- Separar el bug de “envoltura duplicada” del bug de “duplicados en lista”: uno corrige el shape del evento, el otro corrige la inserción/reemplazo en el store.
### 14.4 Veredicto
- **¿Plan viable?**: **SÍ, con condiciones**
- **Condiciones**:
- La normalización debe ignorar mensajes de autenticación/control.
- El detector de doble envoltura debe ser estricto y cubierto por tests.
- No usar `eventId` interno por defecto sin contrato de backend.
- Deduplicación de lista y desanidación deben implementarse como fixes separados, no como supuesto efecto colateral del mismo cambio.
## Fase 15: Implementación — Doble Envoltura WS + Deduplicación de Lista
### 15.1 Mapa de Archivos Afectados
- `src/services/wsClient.ts`: Modificado → Agregada normalización de doble envoltura (nested envelope) en el método `onmessage`, entre el guard de autenticación y la delegación al callback. Detecta cuando `data.payload` contiene otro envelope completo (`.type` + `.payload`) y extrae solo el `payload` interno, preservando el `type` y `eventId` externos. Incluye guard `!data.status` para proteger mensajes de control como `{status:"authenticated"}`.
- `src/components/layout/AppShell.tsx`: Modificado → Handler `conversation_started` reemplaza el patrón `find()` + `if (!existing) upsertConversation()` por una verificación de duplicados usando `useAppStore.getState().conversations.some()` + llamada directa a `upsertConversation` (que internamente hace `findIndex` + replace/insert). Loguea si fue duplicado o nuevo.
### 15.2 Estrategia de Solución e Integración
- **Implementación Arquitectónica**:
- **Fix #1 (Doble envoltura)**: La normalización ocurre en `wsClient.ts`, en la capa de transporte, antes de que los eventos lleguen a los handlers de negocio en `AppShell`. Esto garantiza que **todos** los tipos de evento (`agent_stream_chunk`, `agent_stream_started`, `hitl_request`, `hitl_resolved`, `conversation_assigned`, etc.) se beneficien del aplanamiento automático. El `type` y `eventId` externos se conservan (por seguridad, no se toma el `eventId` interno sin contrato con backend — ver Fase 14, condición 3). El guard `!data.status` protege estrictamente los mensajes de control de autenticación.
- **Fix #2 (Deduplicación de lista)**: El handler `conversation_started` ahora usa `useAppStore.getState().conversations.some()` para verificar existencia previa, y **siempre** llama a `upsertConversation`. Como `upsertConversation` en el store (verificado en `useAppStore.ts:236-247`) ya implementa `findIndex` + replace (no push ciego), el mismo método maneja tanto inserción como actualización sin duplicar. Esto es más robusto que el patrón anterior `find() + if (!existing) upsert()` que podía fallar si el `id` llegaba como `undefined` por la doble envoltura.
- **Mitigación de Riesgos (Fase 14)**:
- **R14.1 (condición laxa)**: Mitigado — El detector verifica explícitamente `data.payload.type` y `data.payload.payload`, además de `!Array.isArray` y `!data.status`. Esto evita falsos positivos en mensajes de control o payloads con estructura casual similar.
- **R14.2 (autenticación rota)**: Mitigado — El guard `!data.status` excluye explícitamente mensajes de control como `{status:"authenticated"}`. Además la normalización se inserta **después** del filtro de auth (líneas 111-126) y del guard `!authenticated` (línea 123-125).
- **R14.3 (eventId interno)**: Mitigado — No se utiliza el `eventId` interno. Se conserva el `eventId` del envelope externo.
- **R14.4 (deduplicación separada)**: Mitigado — La deduplicación en `conversation_started` es un cambio independiente en `AppShell.tsx`, no un efecto colateral de la normalización en `wsClient.ts`.
### 15.3 Notas Técnicas para el Tester
* *Dependencias Añadidas*: Ninguna.
* *Puntos Críticos a Probar*:
1. **CA-16**: Enviar un `agent_stream_chunk` con doble envoltura (como la del ejemplo en Fase 13.2). Verificar que `payload.conversationId`, `payload.messageId`, `payload.token` y `payload.index` llegan correctamente al handler en `AppShell`.
2. **CA-17**: Con una conversación seleccionada, enviar chunks con doble envoltura. Verificar que los tokens se renderizan en el `ChatFeed`.
3. **CA-18**: Enviar `conversation_started` para el mismo `conversationId` dos veces. Verificar que no aparecen entradas duplicadas en la lista del monitor.
4. **CA-19**: Verificar que la desanidación funciona para `agent_stream_started`, `hitl_request`, `hitl_resolved`, `conversation_assigned`, `user_message`, `internal_note`, etc. — todos los tipos de evento se normalizan correctamente.
5. **Regresión auth**: Enviar `{"status":"authenticated","user_id":"..."}` y verificar que NO pasa por la normalización (el handler de auth sigue funcionando antes de llegar a la normalización).
6. **Regresión upsert**: Verificar que `upsertConversation` en el store sigue haciendo `findIndex` + replace para IDs existentes y append para IDs nuevos.
---
## Fase 16: Validación de Calidad — Doble Envoltura WS + Deduplicación
### 16.1 Resumen de Cobertura
- **Resultado Global**: PASSED
- **Total de Casos Ejecutados**: 96
- **Casos Exitosos**: 96
- **Casos Fallidos**: 0
- **Test Files**: 4 (wsClient, streamBuffer, useAppStore, Timer)
### 16.2 Resultado de Compilación
- **TypeScript**: PASSED — `npx tsc --noEmit` sin errores
### 16.3 Resultados de Tests Automatizados
| Test File | Tests | Pasados | Fallidos |
|-----------|-------|---------|----------|
| `src/services/wsClient.test.ts` | 19 (base 13 + 6 nuevos CA-16/CA-19) | 19 | 0 |
| `src/services/streamBuffer.test.ts` | 31 | 31 | 0 |
| `src/store/useAppStore.test.ts` | 33 (base 29 + 4 nuevos CA-18) | 33 | 0 |
| `src/components/shared/Timer.test.tsx` | 13 | 13 | 0 |
| **Total** | **96** | **96** | **0** |
### 16.4 Verificación de Criterios de Aceptación
- [x] **CA-16 (desanidación de doble envoltura)**: PASSED — 1 test específico verifica que un `agent_stream_chunk` con doble envoltura (payload.payload con datos) es aplanado correctamente: `payload.conversationId`, `messageId`, `token` e `index` llegan al handler, y `payload.payload` (envoltura interna) es eliminado. El `eventId` externo se conserva; el interno se descarta.
- [x] **CA-17 (streaming tokens con payload aplanado)**: PASSED — Validado indirectamente por CA-16 + tests de `appendToken`/`completeStream` en el store (6 tests en `useAppStore.test.ts`) que verifican: concatenación de tokens en mensajes existentes, creación de placeholders para nuevos `messageId`, correcto manejo de `isStreaming: true/false`, y no-mutación cuando `selectedConversation` es null.
- [x] **CA-18 (sin duplicados en lista de conversaciones)**: PASSED — 4 tests en `useAppStore.test.ts` verifican: (1) `upsertConversation` con ID nuevo → inserta, (2) `upsertConversation` con mismo ID dos veces → reemplaza sin duplicar, (3) IDs diferentes se mantienen separados, (4) El patrón exacto de `AppShell` (`some()` + `upsertConversation`) no crea duplicados. Además, `upsertConversation` implementa `findIndex` + replace (no push ciego), lo que garantiza atomicidad incluso si se invoca repetidamente con el mismo `id`.
- [x] **CA-19 (desanidación funciona para todos los tipos de evento)**: PASSED — 2 tests específicos en `wsClient.test.ts`:
1. **Test multi-tipo**: Verifica que la desanidación funciona correctamente para 11 tipos de evento: `agent_stream_started`, `agent_stream_chunk`, `agent_stream_completed`, `hitl_request`, `hitl_resolved`, `conversation_assigned`, `conversation_started`, `conversation_ended`, `user_message`, `internal_note`, `init_state`. Todos reciben el payload aplanado correctamente.
2. **Test auth protegido**: Verifica que `{"status":"authenticated","user_id":"..."}` NO es afectado por la desanidación (el handler de auth se ejecuta antes de llegar a la normalización).
3. **Test falsos positivos**: Mensajes planos (sin doble envoltura) pasan sin modificación.
4. **Test guard estricto**: Mensajes con `payload` que no contiene `.type` + `.payload` no son modificados.
### 16.5 Bug Adicional Descubierto y Corregido en QA
**Hallazgo crítico durante la validación de CA-16**:
- **Archivo**: `src/services/wsClient.ts`, línea 108
- **Problema original**: La variable `data` estaba declarada como `const` (`const data = JSON.parse(...)`) pero luego se **reasignaba** en la lógica de desanidación (`data = { ...data, payload: ... }`). Esto causaba un `TypeError: Assignment to constant variable` en modo estricto, que era silenciosamente atrapado por el `catch {}` (línea 151-153). Como resultado, **toda la desanidación nunca se ejecutaba** — los eventos con doble envoltura simplemente se descartaban silenciosamente.
- **Corrección**: Se cambió `const data` → `let data: Record<string, unknown>`, permitiendo la reasignación correcta dentro del bloque de desanidación.
- **Versión previa a la corrección**: Los 2 tests de CA-16/CA-19 fallaban porque `onMsg` nunca era invocado. Después del fix, los 6 tests pasan limpiamente.
### 16.6 Evidencia y Logs de Consola
```text
$ npx tsc --noEmit
(no output — compilación limpia)
$ npx vitest run --reporter=verbose
Test Files 4 passed (4)
Tests 96 passed (96)
Start at 16:25:04
Duration 1.18s (transform 434ms, setup 0ms, collect 640ms, tests 205ms, environment 683ms, prepare 381ms)
```
### 16.7 Estado Final
- **STATUS**: PASSED — Todos los criterios de aceptación (CA-16 a CA-19) cumplidos. Compilación TypeScript limpia. 96/96 tests pasan (4 test files). Bug de `const`/`let` en `wsClient.ts` corregido durante QA — la desanidación ahora funciona correctamente. Suite completa lista para integración y CI/CD.
+64 -21
View File
@@ -61,9 +61,7 @@ export function AppShell({ children }: AppShellProps) {
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
if (eventId) { if (eventId) {
if (!addProcessedEventId(eventId)) { if (!addProcessedEventId(eventId)) {
if (import.meta.env.DEV) { console.debug('[Dashboard:WS] ⏭️ Duplicado ignorado — eventId:', eventId, 'type:', type);
console.debug('[WS] Duplicate eventId ignored:', eventId);
}
return; return;
} }
} }
@@ -76,6 +74,7 @@ export function AppShell({ children }: AppShellProps) {
if (Array.isArray(conversations)) { if (Array.isArray(conversations)) {
setConversations(conversations as any[]); setConversations(conversations as any[]);
console.log('[Dashboard:WS] 📦 init_state recibido — conversations:', (conversations as any[]).length, 'cases:', Array.isArray(activeCases) ? (activeCases as any[]).length : '?');
} }
if (Array.isArray(activeCases)) { if (Array.isArray(activeCases)) {
setCases(activeCases as any[]); setCases(activeCases as any[]);
@@ -90,21 +89,52 @@ export function AppShell({ children }: AppShellProps) {
case 'conversation_started': { case 'conversation_started': {
const startedConvId = payload.conversationId as string; const startedConvId = payload.conversationId as string;
if (startedConvId && typeof startedConvId === 'string' && startedConvId.length > 0) { if (startedConvId && typeof startedConvId === 'string' && startedConvId.length > 0) {
// Check for duplicate before upserting const convs = useAppStore.getState().conversations;
const existing = useAppStore.getState().conversations.find((c) => c.id === startedConvId); const exists = convs.some((c) => c.id === startedConvId);
if (!existing) { upsertConversation({
upsertConversation({ id: startedConvId,
id: startedConvId, clientId: '',
clientId: '', agentId: (payload.agentId as string) ?? '',
agentId: (payload.agentId as string) ?? '', status: 'active',
status: 'active', createdAt: new Date().toISOString(),
createdAt: new Date().toISOString(), });
}); if (exists) {
console.debug('[Dashboard:WS] conversation_started — ya existe, upsert actualizó:', startedConvId);
} else {
console.log('[Dashboard:WS] 🆕 conversation_started — convId:', startedConvId, 'agentId:', (payload.agentId as string) ?? '?');
} }
} }
break; break;
} }
// ── Conversation assigned to advisor ────────────────
case 'conversation_assigned': {
const assignedConvId = payload.conversationId as string;
if (assignedConvId && typeof assignedConvId === 'string' && assignedConvId.length > 0) {
const existing = useAppStore.getState().conversations.find((c) => c.id === assignedConvId);
if (existing) {
console.debug('[Dashboard:WS] conversation_assigned — ya existe, skip:', assignedConvId);
break;
}
console.log('[Dashboard:WS] 📌 conversation_assigned — convId:', assignedConvId, '→ fetching via REST...');
api.getConversation(assignedConvId)
.then((conv) => {
upsertConversation({
id: conv.id,
clientId: conv.clientId || '',
agentId: conv.agentId || '',
status: (conv.status as 'active' | 'paused' | 'ended') || 'active',
createdAt: conv.createdAt || new Date().toISOString(),
});
console.log('[Dashboard:WS] 📌 conversation_assigned — añadida:', assignedConvId, 'clientId:', conv.clientId || '?');
})
.catch((err) => {
console.error('[Dashboard:WS] ❌ conversation_assigned — REST failed:', assignedConvId, err);
});
}
break;
}
// ── Conversation ended (Paso 3) ───────────────────── // ── Conversation ended (Paso 3) ─────────────────────
case 'conversation_ended': { case 'conversation_ended': {
const endedConvId = payload.conversationId as string | undefined; const endedConvId = payload.conversationId as string | undefined;
@@ -116,19 +146,19 @@ export function AppShell({ children }: AppShellProps) {
const updated = [...convs]; const updated = [...convs];
updated[idx] = { ...updated[idx], status: 'ended' as const }; updated[idx] = { ...updated[idx], status: 'ended' as const };
useAppStore.setState({ conversations: updated }); useAppStore.setState({ conversations: updated });
console.log('[Dashboard:WS] 🏁 conversation_ended — convId:', endedConvId);
// Si está seleccionada, mostrar banner "Conversación finalizada"
const selConvId = useAppStore.getState().selectedConversationId; const selConvId = useAppStore.getState().selectedConversationId;
if (selConvId === endedConvId) { if (selConvId === endedConvId) {
useAppStore.setState({ conversationEndedBanner: endedConvId }); useAppStore.setState({ conversationEndedBanner: endedConvId });
console.log('[Dashboard:WS] 🏁 conversation_ended — banner mostrado (conversación seleccionada)');
} }
// Update state machine
setConversationState(endedConvId, 'completed'); setConversationState(endedConvId, 'completed');
} }
// Limpiar buffer para esta conversación (Paso 4)
streamBuffer.clear(endedConvId); streamBuffer.clear(endedConvId);
console.debug('[Dashboard:WS] 🏁 conversation_ended — buffer limpiado:', endedConvId);
break; break;
} }
@@ -138,6 +168,7 @@ export function AppShell({ children }: AppShellProps) {
const msg = payload.message; const msg = payload.message;
if (convId && msg) { if (convId && msg) {
addMessage(convId, msg as any); addMessage(convId, msg as any);
console.log('[Dashboard:WS] 💬 user_message — convId:', convId);
} }
break; break;
} }
@@ -148,15 +179,21 @@ export function AppShell({ children }: AppShellProps) {
const streamMsgId = payload.messageId as string | undefined; const streamMsgId = payload.messageId as string | undefined;
if (streamConvId && streamMsgId) { if (streamConvId && streamMsgId) {
// Update state machine: if hydrating, transition to streaming const selConv = useAppStore.getState().selectedConversation;
const selId = useAppStore.getState().selectedConversationId;
const loading = useAppStore.getState().loadingConversation;
const selLoaded = selConv && selConv.id === streamConvId;
console.log('[Dashboard:WS] 🎬 agent_stream_started — convId:', streamConvId, 'msgId:', streamMsgId,
'| selLoaded:', selLoaded, '| selId:', selId === streamConvId ? 'MATCH' : 'DIFF', '| loading:', loading === streamConvId ? 'YES' : (loading ? 'other' : 'none'));
const currentState = useAppStore.getState().conversationStates[streamConvId]; const currentState = useAppStore.getState().conversationStates[streamConvId];
if (currentState === 'hydrating') { if (currentState === 'hydrating') {
setConversationState(streamConvId, 'streaming'); setConversationState(streamConvId, 'streaming');
console.debug('[Dashboard:WS] 🎬 state: hydrating → streaming');
} }
// Only create placeholder if conversation is selected if (selLoaded) {
const selConv = useAppStore.getState().selectedConversation;
if (selConv && selConv.id === streamConvId) {
useAppStore.getState().addMessage(streamConvId, { useAppStore.getState().addMessage(streamConvId, {
id: streamMsgId, id: streamMsgId,
conversationId: streamConvId, conversationId: streamConvId,
@@ -165,6 +202,9 @@ export function AppShell({ children }: AppShellProps) {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
isStreaming: true, isStreaming: true,
}); });
console.log('[Dashboard:WS] 🎬 placeholder creado en store (conversación seleccionada)');
} else {
console.log('[Dashboard:WS] 🎬 sin placeholder (conversación NO cargada) — tokens irán al buffer');
} }
} }
break; break;
@@ -178,16 +218,19 @@ export function AppShell({ children }: AppShellProps) {
const index = payload.index as number | undefined; const index = payload.index as number | undefined;
if (chunkConvId && msgId && token !== undefined && index !== undefined) { if (chunkConvId && msgId && token !== undefined && index !== undefined) {
// Paso 1: Use selectedConversation (not selectedConversationId) for routing
const selConv = useAppStore.getState().selectedConversation; const selConv = useAppStore.getState().selectedConversation;
if (selConv && selConv.id === chunkConvId) { if (selConv && selConv.id === chunkConvId) {
// Conversación cargada → stream directo al store // Conversación cargada → stream directo al store
console.log('[Dashboard:WS] 🔤 chunk → STORE | convId:', chunkConvId.slice(-8), 'msgId:', msgId.slice(-8), 'idx:', index, 'token:', JSON.stringify(token));
appendToken(chunkConvId, msgId, token, index); appendToken(chunkConvId, msgId, token, index);
} else { } else {
// Conversación NO cargada (o null) → buffer externo // Conversación NO cargada (o null) → buffer externo
console.log('[Dashboard:WS] 🔤 chunk → BUFFER | convId:', chunkConvId.slice(-8), 'msgId:', msgId.slice(-8), 'idx:', index, 'token:', JSON.stringify(token));
streamBuffer.addToken(chunkConvId, msgId, token, index); streamBuffer.addToken(chunkConvId, msgId, token, index);
} }
} else {
console.warn('[Dashboard:WS] ⚠️ agent_stream_chunk payload incompleto:', { chunkConvId, msgId, hasToken: token !== undefined, hasIndex: index !== undefined });
} }
break; break;
} }
+193
View File
@@ -271,6 +271,199 @@ describe('wsClient — In-Band Auth (CA-6)', () => {
}); });
}); });
// ── CA-16 & CA-19: Double-wrapping desanidation ─────────────
// Pruebas para la normalización de doble envoltura (nested envelope)
// que el backend envía: { type, eventId, payload: { type, eventId, payload: { datos } } }
describe('CA-16/CA-19: Double-wrapping desanidation (nested envelope flattening)', () => {
it('CA-16: should flatten agent_stream_chunk with double wrapping', () => {
const onMsg = vi.fn();
wsClient.onMessage = onMsg;
wsClient.connect();
mockWsInstance.onopen({});
// Authenticate first
mockWsInstance.onmessage({ data: JSON.stringify({ status: 'authenticated', user_id: 'user-123' }) });
// Simulate nested envelope like the backend sends:
// { type, eventId, occurredAt, payload: { type, eventId, payload: { datos reales } } }
const nestedMsg = {
type: 'agent_stream_chunk',
eventId: 'ext-evt-001',
occurredAt: '2026-07-29T20:36:37.402610Z',
payload: {
type: 'agent_stream_chunk',
eventId: 'int-evt-001',
occurredAt: '2026-07-29T20:36:37.319750Z',
payload: {
conversationId: 'test-hitl-005',
messageId: 'bc66c31c-abcd',
token: ' plan',
index: 467,
},
},
};
mockWsInstance.onmessage({ data: JSON.stringify(nestedMsg) });
// Verify the callback receives the FLATTENED payload (no nested envelope)
expect(onMsg).toHaveBeenCalledTimes(1);
const received = onMsg.mock.calls[0][0];
expect(received.type).toBe('agent_stream_chunk');
expect(received.eventId).toBe('ext-evt-001'); // external eventId preserved
expect(received.payload.conversationId).toBe('test-hitl-005');
expect(received.payload.messageId).toBe('bc66c31c-abcd');
expect(received.payload.token).toBe(' plan');
expect(received.payload.index).toBe(467);
// Verify no nested payload inside payload
expect(received.payload.payload).toBeUndefined();
});
it('CA-19: should flatten ALL business event types', () => {
const onMsg = vi.fn();
wsClient.onMessage = onMsg;
wsClient.connect();
mockWsInstance.onopen({});
// Authenticate
mockWsInstance.onmessage({ data: JSON.stringify({ status: 'authenticated', user_id: 'user-123' }) });
const eventTypes = [
'agent_stream_started',
'agent_stream_chunk',
'agent_stream_completed',
'hitl_request',
'hitl_resolved',
'conversation_assigned',
'conversation_started',
'conversation_ended',
'user_message',
'internal_note',
'init_state',
];
for (const eventType of eventTypes) {
onMsg.mockClear();
const nestedMsg = {
type: eventType,
eventId: `ext-${eventType}`,
payload: {
type: eventType,
eventId: `int-${eventType}`,
payload: { conversationId: 'conv-001', data: 'test' },
},
};
mockWsInstance.onmessage({ data: JSON.stringify(nestedMsg) });
expect(onMsg).toHaveBeenCalledTimes(1);
const received = onMsg.mock.calls[0][0];
expect(received.type).toBe(eventType);
// The payload should be the inner payload (not the nested envelope)
expect(received.payload.conversationId).toBe('conv-001');
expect(received.payload.payload).toBeUndefined();
}
});
it('CA-19: should NOT flatten auth/control messages (status field present)', () => {
const onMsg = vi.fn();
wsClient.onMessage = onMsg;
const onAuth = vi.fn();
wsClient.onAuthenticated = onAuth;
wsClient.connect();
mockWsInstance.onopen({});
// Send auth response — should NOT be flattened
const authMsg = { status: 'authenticated', user_id: 'user-123' };
mockWsInstance.onmessage({ data: JSON.stringify(authMsg) });
// onAuth should be called (not onMsg)
expect(onAuth).toHaveBeenCalledTimes(1);
expect(onMsg).not.toHaveBeenCalled();
// Even though authMsg.payload doesn't exist, the guard !data.status protects it
// Verify the auth state is correct
expect(wsClient.getAuthState()).toBe('authenticated');
});
it('CA-19: should NOT flatten messages without double envelope structure', () => {
const onMsg = vi.fn();
wsClient.onMessage = onMsg;
wsClient.connect();
mockWsInstance.onopen({});
// Authenticate
mockWsInstance.onmessage({ data: JSON.stringify({ status: 'authenticated', user_id: 'user-123' }) });
// Normal flat message (no nested payload.payload)
const flatMsg = {
type: 'agent_stream_chunk',
eventId: 'evt-flat',
payload: {
conversationId: 'conv-001',
messageId: 'msg-001',
token: 'Hello',
index: 0,
},
};
mockWsInstance.onmessage({ data: JSON.stringify(flatMsg) });
expect(onMsg).toHaveBeenCalledTimes(1);
const received = onMsg.mock.calls[0][0];
expect(received.payload.conversationId).toBe('conv-001');
expect(received.payload.token).toBe('Hello');
});
it('CA-19: should NOT flatten when inner payload lacks .type or .payload', () => {
const onMsg = vi.fn();
wsClient.onMessage = onMsg;
wsClient.connect();
mockWsInstance.onopen({});
// Authenticate
mockWsInstance.onmessage({ data: JSON.stringify({ status: 'authenticated', user_id: 'user-123' }) });
// Payload has data but no inner { type, payload } structure
const noNestedEnv = {
type: 'custom_event',
eventId: 'evt-002',
payload: { customField: 'value', anotherField: 42 },
};
mockWsInstance.onmessage({ data: JSON.stringify(noNestedEnv) });
expect(onMsg).toHaveBeenCalledTimes(1);
const received = onMsg.mock.calls[0][0];
expect(received.payload.customField).toBe('value');
expect(received.payload.anotherField).toBe(42);
});
it('CA-19: should flatten nested envelope even when inner payload has no .type but has .payload', () => {
// This edge case tests the guard: the code checks for both .type AND .payload
// If only .payload exists (no .type), it should NOT flatten
const onMsg = vi.fn();
wsClient.onMessage = onMsg;
wsClient.connect();
mockWsInstance.onopen({});
// Authenticate
mockWsInstance.onmessage({ data: JSON.stringify({ status: 'authenticated', user_id: 'user-123' }) });
// payload has innerPayload but no .type — should NOT flatten
const weirdMsg = {
type: 'some_event',
eventId: 'evt-003',
payload: {
innerPayload: { conversationId: 'conv-001' },
},
};
mockWsInstance.onmessage({ data: JSON.stringify(weirdMsg) });
expect(onMsg).toHaveBeenCalledTimes(1);
const received = onMsg.mock.calls[0][0];
// Should keep original structure
expect(received.payload.innerPayload).toBeDefined();
expect(received.payload.innerPayload.conversationId).toBe('conv-001');
});
});
// ── Send ──────────────────────────────────────────────────── // ── Send ────────────────────────────────────────────────────
describe('send()', () => { describe('send()', () => {
+19 -1
View File
@@ -105,7 +105,7 @@ class WsClient {
this.ws.onmessage = (event: MessageEvent) => { this.ws.onmessage = (event: MessageEvent) => {
try { try {
const data = JSON.parse(event.data as string); let data: Record<string, unknown> = JSON.parse(event.data as string);
// Handle auth response first // Handle auth response first
if (data.status === 'authenticated') { if (data.status === 'authenticated') {
@@ -125,6 +125,24 @@ class WsClient {
return; return;
} }
// Normalizar doble envoltura (nested envelope del backend)
// El backend envía: { type, eventId, payload: { type, eventId, payload: { datos } } }
// Debemos aplanar a: { type, eventId, payload: { datos } }
if (
data.payload &&
typeof data.payload === 'object' &&
!Array.isArray(data.payload) &&
!data.status && // NUNCA para mensajes de control como {"status":"authenticated"}
(data.payload as Record<string, unknown>).type &&
(data.payload as Record<string, unknown>).payload
) {
// Conservar el type y eventId externos, usar el payload interno
data = {
...data,
payload: (data.payload as Record<string, unknown>).payload,
};
}
// Delegate business events to registered callback // Delegate business events to registered callback
if (this.onMessageCallback) { if (this.onMessageCallback) {
const envelope: WSEnvelope = data; const envelope: WSEnvelope = data;
+75
View File
@@ -305,6 +305,81 @@ describe('useAppStore', () => {
}); });
}); });
// ── CA-18: upsertConversation deduplication ──────────────────
// Verifica que el patrón usado en AppShell (some() + upsertConversation)
// NO crea entradas duplicadas en la lista de conversaciones.
describe('CA-18: upsertConversation deduplication', () => {
it('should insert new conversation when ID does not exist', () => {
useAppStore.getState().upsertConversation(
makeConvSummary('conv-new'),
);
const convs = useAppStore.getState().conversations;
expect(convs).toHaveLength(1);
expect(convs[0].id).toBe('conv-new');
});
it('should REPLACE (not duplicate) when same ID is upserted twice', () => {
useAppStore.getState().upsertConversation(
makeConvSummary('conv-1', { clientId: 'v1', status: 'active' }),
);
expect(useAppStore.getState().conversations).toHaveLength(1);
// Upsert same ID again with different data
useAppStore.getState().upsertConversation(
makeConvSummary('conv-1', { clientId: 'v2', status: 'paused' }),
);
const convs = useAppStore.getState().conversations;
// Should still be 1 (not 2)
expect(convs).toHaveLength(1);
// Should be updated to v2
expect(convs[0].clientId).toBe('v2');
expect(convs[0].status).toBe('paused');
});
it('should maintain separate entries for different IDs', () => {
useAppStore.getState().upsertConversation(
makeConvSummary('conv-a'),
);
useAppStore.getState().upsertConversation(
makeConvSummary('conv-b'),
);
useAppStore.getState().upsertConversation(
makeConvSummary('conv-a'), // duplicate of first
);
const convs = useAppStore.getState().conversations;
// Should have 2, not 3
expect(convs).toHaveLength(2);
expect(convs.map((c) => c.id).sort()).toEqual(['conv-a', 'conv-b']);
});
it('should handle the AppShell pattern (some() + upsertConversation) correctly', () => {
// Simula el patrón exacto de AppShell.tsx conversation_started handler
const startedConvId = 'conv-001';
// Primera vez: no existe → upsert
const exists1 = useAppStore.getState().conversations.some((c) => c.id === startedConvId);
expect(exists1).toBe(false);
useAppStore.getState().upsertConversation(
makeConvSummary(startedConvId, { agentId: 'agent-1' }),
);
expect(useAppStore.getState().conversations).toHaveLength(1);
// Segunda vez: ya existe → upsert (reemplaza, no duplica)
const exists2 = useAppStore.getState().conversations.some((c) => c.id === startedConvId);
expect(exists2).toBe(true);
useAppStore.getState().upsertConversation(
makeConvSummary(startedConvId, { agentId: 'agent-2' }),
);
const convs = useAppStore.getState().conversations;
expect(convs).toHaveLength(1); // No duplicado
expect(convs[0].agentId).toBe('agent-2'); // Actualizado
});
});
// ── setCases (atomic replace via init_state) ──────────────── // ── setCases (atomic replace via init_state) ────────────────
describe('setCases atomic replace', () => { describe('setCases atomic replace', () => {