«Chat out of date»: cuando el escritorio rechazaba cada envío

Llevas horas trabajando en una misma sesión de escritorio, llamada a herramienta tras llamada a herramienta. Pulsas Enter y recibes «Chat out of date»: la ventana se había quedado por detrás de otra vista del mismo chat, los últimos mensajes ya estaban cargados, envía otra vez si aún quieres hacerlo. Envías otra vez. El mismo mensaje. Reiniciar la app no cambia nada. No te falta ningún dato, pero esa conversación ya no puede aceptar un mensaje nuevo.
El PR #125523, fusionado el 2026-09-27, corrige el bucle. El issue #123909 contiene la cronología completa de reproducción.
El síntoma: un bucle de rechazo que no puede converger
- Un desencadenante parece del todo inocente: mover una sesión a un proyecto. Después del movimiento, el store en memoria de la ventana se congela en la frontera previa al movimiento y, a partir de ahí, cada envío se bloquea en el lado del cliente.
- No fue un caso aislado: #123033 y #123856 reportaron el mismo guard disparándose sin ningún desencadenante evidente.
- Hay una segunda víctima más silenciosa: hacer fork de un chat afectado siembra el fork desde ese store congelado, descartando en silencio todo lo posterior al movimiento. Sin ningún error: la nueva sesión simplemente no contiene ese historial.
Causa raíz: dos consumidores en desacuerdo sobre un mismo store
Dos componentes leen el mismo store de sesión y tenían ideas distintas sobre qué significa «actual»:
- La retención (
transcript-retention.ts, introducida para #77311) libera a propósito la cabeza del store, conservando la ventana activa más una página de peso de margen. CadaChatMessage.partslleva salida de herramientas ya renderizada, previsualizaciones de archivos, diffs e imágenes, y así es como crece una ventana de larga vida. Un transcript de coordinador cargado de herramientas agota ese presupuesto enseguida, así que el store recortado contiene muchos menos mensajes renderizados de los que la sesión tiene de verdad. - El guard antiguo (
stale-transcript-guard.ts) comparaba recuentos: la última página de 120 filas (includeCompacted: true) se plegaba a, digamos, 85 mensajes de chat — más de los que contenía el store recortado — así que la vista se declaraba por detrás, el envío se rechazaba y se instalaba la página refrescada. El siguiente movimiento del ancla de ventana recortaba otra vez, y el siguiente envío se rechazaba de nuevo. No convergente por construcción, y sobrevivía a recargas y reinicios porque la hidratación vuelve a recortar en cuanto el corte de ventana queda establecido.
Los chats cortos nunca cruzan ese presupuesto. Por eso esto solo mordía en conversaciones largas y pesadas, incluido el Bot Chat canónico, que por diseño es un chat eterno.
La solución: comparar la cola, no el total
La nueva lógica cambia qué se compara:
- Un nuevo
lastDurableRowIdretrocede desde el final de la vista hasta el primer mensaje que lleva unrowIdpersistido: la cola de la vista. La retención solo puede liberar la cabeza, así que la última fila durable es siempre la cola activa; las filas no persistidas (prompts optimistas, respuestas en streaming) quedan más allá de ella. - Cuando el último
rowIddurable de la vista local coincide con el últimorowIddurable de la página que acaba de devolver el backend, la vista está al día, por mucho que la retención haya paginado fuera de la cabeza. Una ventana par con filas genuinamente más nuevas sí cambia la punta, así que la obsolescencia real se sigue rechazando. - Con ello desapareció una fuente de falsos positivos: las filas
systemNoticeescritas por el backend se renderizan en la línea de tiempo pero no pertenecen a ninguna vista, así que contarlas reportaba una segunda ventana que no existía; bastaba con un cambio de modelo en el sitio para que cada envío pareciera ir por detrás.
Qué puedes hacer
- Actualizar es la respuesta directa. En cuanto la corrección esté en tu instalación, este bucle deja de rechazar los chats largos (la app de escritorio sigue a
hermes update). - En una máquina que todavía no se ha actualizado, el apaño práctico cuando veas el aviso es continuar en una sesión nueva, o seguir desde la CLI (
hermes sessions browsete permite elegir una sesión pasada y retomarla). No sigas pulsando enviar. - Si ya has hecho fork de una sesión por este motivo, trata ese fork como una copia poco fiable: la sesión original sigue existiendo con todo su historial, así que no uses el fork como copia de seguridad.
- Ese mismo día aterrizó otra corrección para conversaciones largas que merece la pena leer a continuación: por qué la compactación a mitad de turno no debe reescribir el historial. Para la propia app de escritorio, consulta los modos de interfaz y Hermes Desktop en nuestra sección de ecosistema.
Estado del release
El parche se fusionó el 2026-09-27, después de la etiqueta de release v0.21.5 (v2026.9.24), así que por ahora solo está en main.
Conclusión
La lección aquí no está en ninguna línea de código concreta: ambos módulos eran «correctos». Uno libera historial por peso para acotar la memoria; el otro juzgaba la frescura contando filas; puestos juntos, la comparación no podía salir bien nunca. La corrección no ajusta el presupuesto. Elige un hecho en el que ambas partes coinciden: la última fila durable. Si las puntas coinciden, la vista está al día; si no coinciden, es que realmente va por detrás. La memoria sigue acotada y el envío vuelve a funcionar.