feat(realtime): make chat state durable across refresh, reconnect, and tabs - #113
Merged
Conversation
binsarjr
marked this pull request as ready for review
August 14, 2026 04:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Durable realtime chat across refresh, reconnect, and tabs
This is intentionally a larger-than-usual change, but it solves one problem end to end: Evonic's live chat state currently has no durable source of truth. A refresh during a long turn can make active Thinking or tool progress disappear from the UI, and a message sent in one tab may not appear in another until the page is refreshed.
The diff crosses runtime, storage, SSE, and the browser because those layers currently own different parts of the same turn. Fixing only one layer would leave the race in another.
Summary
This change gives browser-facing realtime state a durable, ordered source of truth. Chat history still stores the conversation itself, while queued/running turns and mid-turn telemetry live in the dedicated
shared/db/realtime.dbjournal and reach every open tab through one SSE gateway.The goal is not simply to add SSE. The current flow already has SSE and a unified endpoint. The problem is that history, live telemetry, busy state, reconnect recovery, and cross-tab rendering still depend on different sources with different lifetimes.
With this change, a page can load stable history, continue from the exact event cursor captured with that history, rebuild an active turn, and then stay on the same ordered stream.
How the current flow works
The current design splits chat state across three paths:
EventStreamkeeps at most 500 telemetry entries per session in process memory and schedules that buffer for deletion 30 seconds after a turn;The browser combines those paths using history requests, content polling,
/chat/events,/busy, and SSE. The unified SSE route reduces the number of browser connections, but its chat replay still comes from the process-local buffer. It unifies transport, not the underlying source of truth.What this flow does well
For one tab that remains connected through a normal, short turn, this is a reasonable and fast design.
Why the failures are intermittent
Refresh during a long turn
Mid-turn Thinking and tool activity are not part of stable chat history. After a refresh, the page has to reconstruct the UI by independently reading history, the process-local event buffer, and the busy endpoint.
Those reads do not share one cursor or snapshot, so the turn can change between them. There are also two time-based assumptions: the frontend can auto-finalize a turn after five minutes without an event, while the in-memory busy entry expires after ten minutes. A long silent model call or tool can therefore still be running even though a refreshed page no longer has enough authoritative state to display it.
A process restart is a harder boundary: the session buffer, its sequence counter, and the busy tracker disappear together. Chat history survives, but the live representation does not.
A message sent in another tab
Tab A renders its own message optimistically, then the runtime saves it and emits
message_received. The normal agent-detail handler only reloads history for escalated messages. Its idle poll also advances over user entries but filters them from rendering because it assumes they are local echoes.That assumption is correct for Tab A and wrong for Tab B. Tab B can receive the surrounding realtime activity while still not rendering the new user message; a full history refresh finally makes it visible.
Why this is one cohesive change
The symptoms appear in the browser, but the missing state begins earlier in the lifecycle. Queue acceptance, busy state, runtime telemetry, saved messages, reconnect cursors, and rendering all use different ownership and timing rules. A frontend-only patch cannot replay state the server no longer has; persisting events without changing the history handoff still leaves a race; replacing the busy tracker without updating queue transitions can report idle while accepted work is waiting.
This change therefore follows one turn through the complete path:
The breadth is a consequence of closing that lifecycle, not a collection of unrelated features.
The durable flow
Browser-facing events are now normalized and appended to
shared/db/realtime.dbbefore asynchronous plugin listeners run. Each row receives one global event ID and timestamp. Keeping this journal global, rather than creating one database per agent, gives the gateway one ordering domain for cross-agent status, approvals, agent-to-agent activity, and chat events while isolating the write load from the main application database.The responsibilities are explicit:
realtime_eventsstores ordered browser-facing telemetry;active_turnsprojects queued and running work;/api/realtime/streamperforms initial replay, reconnect replay, snapshots, and live delivery.Payloads are sanitized before they enter the journal and capped at 256 KiB. Oversized fields keep a bounded preview and explicit truncation metadata instead of allowing one tool result to grow the database without limit.
This keeps history and telemetry separate without making live state disposable.
Before and after
/chat/events, and/busyreadsLast-Event-IDresumes from the durable global sequenceLive message and cross-tab flow
client_message_idcorrelates the sender's optimistic bubble with the durablemessage_receivedevent. The sender keeps one bubble, while every other tab renders the same message immediately. Stablemessage_idvalues prevent duplicates when history, the POST response, and SSE overlap.The same stream carries
turn_queued,turn_begin, Thinking updates, tool progress, response chunks, anddone, so all tabs follow the same turn rather than independently guessing its state.Agent-to-agent delivery
Agent-to-agent calls use the same queued/running/terminal lifecycle. The sender now registers its completion waiter before dispatching the target agent, preventing a fast target from completing before the sender is ready to observe the result. A target can wake the waiting agent even when no human-facing channel route exists, while any browser viewing the session receives the same durable activity through the gateway.
Refresh and reconnect
A fresh page renders history and captures
X-Evonic-Realtime-Cursorfrom that response. It opens SSE from the cursor, replays any active-turn telemetry represented by the journal, closes the history-to-stream gap, receives a current-state snapshot, and continues with live events.A transient disconnect within the retained window uses native
EventSourcereconnection andLast-Event-ID. The gateway returns only the missing journal rows and does not need a second content-polling recovery path.If a tab reconnects with a cursor older than the available journal, or with an invalid or future cursor, the gateway emits
history_resync_requiredand closes that stream. The agent-detail and sessions views reload stable chat history, take its new cursor, and reconnect automatically. An expired cursor therefore becomes an explicit recovery path instead of looking like an empty replay.Turn lifecycle and retention
Events belonging to an active turn do not expire. Once a turn reaches a terminal state, its journal remains replayable for one hour and is then eligible for cleanup. Events that do not belong to a turn use the same one-hour window from their occurrence. Session clear removes completed realtime history without deleting an active turn that is still needed to finish safely.
On startup, abandoned active-turn records are emitted as interrupted and closed. If a terminal
doneevent was already journaled before the process died, recovery closes the stale projection without writing a duplicate terminal event. This restores an honest UI state; it does not claim to resume an LLM call or tool process that died with the server.Trade-offs and limits
The durable design intentionally accepts several costs:
The implementation keeps that cost bounded: it uses a dedicated SQLite WAL database instead of adding a broker, caps each stored payload at 256 KiB, retains completed telemetry for one hour, preserves active events until termination, tracks replay floors per session, validates cursors, and deduplicates with event and message IDs.
Compatibility and interface changes
GET /api/realtime/streammultiplexeschat,status,approvals,workplace, andupdateevents.cursor_version=2identifies durable global cursors;snapshot=1requests initial state.Last-Event-IDand suppress duplicate initial snapshots.history_resync_requiredwhen a versioned cursor is expired, invalid, or ahead of the journal; both chat views recover automatically.X-Evonic-Realtime-Cursorfor the history-to-stream handoff.client_message_id; durable events expose stablemessage_idvalues.EventStreamplugin listeners continue to receive runtime events.Result
Testing
The focused suite covers durable ordering and scoping, the dedicated database path, one-hour terminal retention, active-turn replay, monotonic cursors after cleanup, history resync, payload bounds, history-cursor handoff, invalid and future cursors,
Last-Event-ID, cross-tab delivery, optimistic deduplication, long-turn UI behavior, atomic queued-turn cancellation, agent-to-agent completion races, restart recovery without duplicate terminal events, session cleanup, approval snapshots, cache busting, test isolation, and legacy route compatibility.The full local unit suite completed with
2158 passed,84 skipped, and five failures. Four deterministic failures reproduce on the cleandevbase: two artifact-path expectations hard-coded to another checkout, one SFTP mock incompatibility, and one pre-existing token-budget assertion. The fifth is an environment-dependent classifier expectation that appears when the optional local ONNX model is installed.GitHub Actions on Python 3.11 completed with
2150 passedand93 skipped, then reported the same four deterministic base failures. The Go job passed. No failure specific to this branch appeared; the unrelated base failures are intentionally not changed here to keep this PR scoped to durable realtime chat.Manual browser validation covered two tabs on the same session, optimistic message deduplication, queued and split turns, refresh while a turn is active, reconnect replay, agent switching, stop/cancel behavior, and agent-to-agent delivery without a human-facing route.