diff --git a/navi/api/websocket.py b/navi/api/websocket.py index caa47aa..65dddb2 100644 --- a/navi/api/websocket.py +++ b/navi/api/websocket.py @@ -149,15 +149,30 @@ _runs.pop(session_id, None) +_HEARTBEAT_INTERVAL = 20.0 # seconds — keeps the browser from dropping idle connections + + async def _stream_to_client(websocket: WebSocket, queue: asyncio.Queue) -> bool: """ Forward queue items to the WebSocket until ("done", None). Returns True if client stayed connected, False if it disconnected mid-stream. On disconnect, continues draining silently so the agent task can finish cleanly. + Sends periodic heartbeat pings while waiting so the browser doesn't time out + during long-running silent operations (e.g. AIHelper LLM calls). """ client_alive = True while True: - kind, payload = await queue.get() + try: + kind, payload = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_INTERVAL) + except asyncio.TimeoutError: + # No event arrived — send a heartbeat to keep the WS alive + if client_alive: + try: + await websocket.send_json({"type": "heartbeat"}) + except Exception: + client_alive = False + continue + if kind == "done": return client_alive if not client_alive: @@ -223,6 +238,13 @@ current_run = None if not connected: return # client disconnected again — stop here + # Stream finished — tell the client to sync session history so it sees + # the full saved response (handles any events missed during disconnect). + await websocket.send_json({"type": "session_sync"}) + else: + # No active run — if this is a reconnect after the agent already finished, + # the client needs to reload session history to see the saved response. + await websocket.send_json({"type": "session_sync"}) while True: raw = await websocket.receive_text()