fix: persist durable inflight reload snapshots

This commit is contained in:
Jordan SkyLF
2026-04-13 16:13:39 -07:00
parent 8ad112ea6c
commit 36051c0276
3 changed files with 87 additions and 6 deletions
+25 -6
View File
@@ -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');
}
+41
View File
@@ -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()}));
+21
View File
@@ -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"