diff --git a/static/messages.js b/static/messages.js index cddb9f34..1422236f 100644 --- a/static/messages.js +++ b/static/messages.js @@ -40,6 +40,9 @@ async function send(){ clearLiveToolCards(); // clear any leftover live cards from last turn S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true); INFLIGHT[activeSid]={messages:[...S.messages],uploaded,toolCalls:[]}; + if(typeof saveInflightState==='function'){ + saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:[]}); + } startApprovalPolling(activeSid); S.activeStreamId = null; // will be set after stream starts @@ -69,6 +72,9 @@ async function send(){ streamId=startData.stream_id; S.activeStreamId = streamId; markInflight(activeSid, streamId); + if(typeof saveInflightState==='function'){ + saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:INFLIGHT[activeSid].toolCalls||[]}); + } // Show Cancel button const cancelBtn=$('btnCancel'); if(cancelBtn) cancelBtn.style.display='inline-flex'; @@ -120,6 +126,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _isActiveSession(){ return !!(S.session&&S.session.session_id===activeSid); } + function persistInflightState(){ + const inflight=INFLIGHT[activeSid]; + if(!inflight||typeof saveInflightState!=='function') return; + saveInflightState(activeSid,{ + streamId, + messages:inflight.messages||[], + uploaded:inflight.uploaded||[...uploaded], + toolCalls:inflight.toolCalls||[], + }); + } function _closeSource(){ closeLiveStream(activeSid, streamId); } @@ -137,9 +153,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ inflight.messages[assistantIdx].content=assistantText; inflight.messages[assistantIdx].reasoning=reasoningText||undefined; inflight.messages[assistantIdx]._ts=inflight.messages[assistantIdx]._ts||ts; + persistInflightState(); return; } inflight.messages.push({role:'assistant',content:assistantText,reasoning:reasoningText||undefined,_live:true,_ts:ts}); + persistInflightState(); } function ensureAssistantRow(){ if(!_isActiveSession()) return; @@ -276,6 +294,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[]; INFLIGHT[activeSid].toolCalls.push(tc); S.toolCalls=INFLIGHT[activeSid].toolCalls; + persistInflightState(); if(!S.session||S.session.session_id!==activeSid) return; removeThinking(); @@ -307,6 +326,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ tc.is_error=!!d.is_error; if(d.duration!==undefined) tc.duration=d.duration; S.toolCalls=inflight.toolCalls; + persistInflightState(); if(!S.session||S.session.session_id!==activeSid) return; appendLiveToolCard(tc); scrollIfPinned(); @@ -324,7 +344,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.close(); const d=JSON.parse(e.data); delete INFLIGHT[activeSid]; - clearInflight(); + clearInflight();clearInflightState(activeSid); stopApprovalPolling(); if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true); if(S.session&&S.session.session_id===activeSid){ @@ -371,7 +391,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Application-level error sent explicitly by the server (rate limit, crash, etc.) // This is distinct from the SSE network 'error' event below. source.close(); - delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling(); + delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling(); if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true); if(S.session&&S.session.session_id===activeSid){ S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none'; @@ -431,7 +451,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('cancel',e=>{ source.close(); - delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling(); + delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling(); if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true); if(S.session&&S.session.session_id===activeSid){ S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none'; @@ -446,16 +466,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } function _handleStreamError(){ - delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling(); + delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling(); + _closeSource(); if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true); if(S.session&&S.session.session_id===activeSid){ S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none'; clearLiveToolCards();if(!assistantText)removeThinking(); S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages(); }else{ - // User switched away — show background error banner if(typeof trackBackgroundError==='function'){ - // Look up session title from the session list cache so the banner names it correctly const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null; trackBackgroundError(activeSid,_errTitle,'Connection lost'); } diff --git a/static/ui.js b/static/ui.js index 8a20353e..ac4c1286 100644 --- a/static/ui.js +++ b/static/ui.js @@ -666,6 +666,47 @@ function copyMsg(btn){ // ── Reconnect banner (B4/B5: reload resilience) ── const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking +const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery + +function _readInflightStateMap(){ + try{ + const raw=localStorage.getItem(INFLIGHT_STATE_KEY); + const parsed=raw?JSON.parse(raw):{}; + return parsed&&typeof parsed==='object'?parsed:{}; + }catch(_){ + return {}; + } +} +function saveInflightState(sid, state){ + if(!sid||!state) return; + try{ + const all=_readInflightStateMap(); + all[sid]={...state,updated_at:Date.now()}; + localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all)); + }catch(_){ } +} +function loadInflightState(sid, streamId){ + if(!sid) return null; + const all=_readInflightStateMap(); + const entry=all[sid]; + if(!entry) return null; + if(streamId&&entry.streamId&&entry.streamId!==streamId) return null; + if(entry.updated_at&&Date.now()-entry.updated_at>10*60*1000){ + clearInflightState(sid); + return null; + } + return entry; +} +function clearInflightState(sid){ + if(!sid) return; + try{ + const all=_readInflightStateMap(); + if(!(sid in all)) return; + delete all[sid]; + if(Object.keys(all).length) localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all)); + else localStorage.removeItem(INFLIGHT_STATE_KEY); + }catch(_){ } +} function markInflight(sid, streamId) { localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()})); diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 0e1c0f6b..06df9c95 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -670,3 +670,24 @@ def test_skills_slash_command_defined(): # 3. i18n key cmd_skills must be referenced (wired to COMMANDS entry) assert "cmd_skills" in src, \ "cmd_skills i18n key must be referenced in commands.js" + + +def test_reload_recovery_persists_durable_inflight_state(cleanup_test_sessions): + """Reload recovery must persist a durable per-session inflight snapshot. + Without these helpers, loadSession() references loadInflightState() but a full + browser reload has no saved state to hydrate, so recovery silently no-ops. + """ + ui_src = (REPO_ROOT / "static/ui.js").read_text() + messages_src = (REPO_ROOT / "static/messages.js").read_text() + sessions_src = (REPO_ROOT / "static/sessions.js").read_text() + + assert "const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'" in ui_src + assert "function saveInflightState(sid, state)" in ui_src + assert "function loadInflightState(sid, streamId)" in ui_src + assert "function clearInflightState(sid)" in ui_src + assert "saveInflightState(activeSid" in messages_src, \ + "messages.js must persist live stream snapshots while a turn is in flight" + assert "clearInflightState(activeSid)" in messages_src, \ + "messages.js must clear durable inflight snapshots when the run ends/errors/cancels" + assert "const stored=loadInflightState(sid, activeStreamId);" in sessions_src, \ + "loadSession() must hydrate in-flight state from durable browser storage on reload"