fix: durable inflight reload snapshots via localStorage (#367)
* fix: persist durable inflight reload snapshots * fix: remove duplicate loadInflightState stub, update CHANGELOG test count The stub added in the previous review branch is superseded by the author's real localStorage-backed implementation in the cherry-picked commit 36051c0. Remove the duplicate. Update CHANGELOG to 961 tests and document the durable inflight state feature. --------- Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
## [v0.50.21] Live reasoning, tool progress, and in-flight session recovery (PR #367)
|
||||
|
||||
- **Durable inflight reload recovery** (`static/ui.js`, `static/messages.js`): `saveInflightState` / `loadInflightState` / `clearInflightState` backed by `localStorage` (`hermes-webui-inflight-state` key, per-session, 10-minute TTL). Snapshots are saved on every token, tool event, and tool completion, and cleared when the run ends/errors/cancels. On a full page reload with an active stream, `loadSession()` hydrates from the snapshot before calling `attachLiveStream(..., {reconnecting:true})` — partial messages, live tool cards, and reasoning text all survive the reload.
|
||||
- **Live reasoning cards during streaming** (`static/ui.js`, `static/messages.js`): The generic thinking spinner now upgrades to a live reasoning card when the backend streams reasoning text. `_thinkingMarkup(text)` and `updateThinking(text)` centralize the markup so the spinner and card share the same DOM slot. Works with models that emit reasoning via the agent's `reasoning_callback` or `tool_progress_callback`.
|
||||
- **`tool_complete` SSE events** (`api/streaming.py`, `static/messages.js`): Tool progress callback now accepts the current agent signature `on_tool(*cb_args, **cb_kwargs)` — handles both the old 3-arg `(name, preview, args)` form and the new 4-arg `(event_type, name, preview, args)` form. `tool.completed` events transition live tool cards from running to done cleanly.
|
||||
- **In-flight session state stable across switches** (`static/messages.js`, `static/sessions.js`): `attachLiveStream` refactored out of `send()` into a standalone function; partial assistant text mirrored into `INFLIGHT` state on every token; `data-live-assistant` DOM anchor preserved across `renderMessages()` calls so switching away and back doesn't lose or duplicate live output.
|
||||
@@ -19,7 +20,7 @@
|
||||
- **Session-scoped message queue** (`static/ui.js`, `static/messages.js`): Global `MSG_QUEUE` replaced with `SESSION_QUEUES` keyed by session ID. Queued follow-up messages are associated with the session they were typed in and only drained when that session becomes idle — no cross-session bleed.
|
||||
- **`newSession()` idle reset** (`static/sessions.js`): Sets `S.busy=false`, `S.activeStreamId=null`, clears the cancel button, resets composer status — ensures a fresh chat is immediately usable even if another session's stream is still running.
|
||||
- **Todos survive session reload** (`static/panels.js`): `loadTodos()` now reads from `S.session.messages` (raw, includes tool-role messages) rather than `S.messages` (filtered display), so todo state reconstructed from tool outputs survives reloads.
|
||||
- 12 new regression tests in `tests/test_regressions.py`; 960 tests total (up from 949)
|
||||
- 12 new regression tests in `tests/test_regressions.py`; 961 tests total (up from 949)
|
||||
|
||||
## [v0.50.20] Silent error fix, stale model cleanup, live model fetching (fixes #373, #374, #375)
|
||||
|
||||
|
||||
@@ -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){
|
||||
@@ -373,7 +393,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';
|
||||
@@ -434,7 +454,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';
|
||||
@@ -449,16 +469,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');
|
||||
}
|
||||
|
||||
53
static/ui.js
53
static/ui.js
@@ -723,6 +723,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()}));
|
||||
@@ -815,18 +856,6 @@ function getPendingSessionMessage(session){
|
||||
_pending:true,
|
||||
};
|
||||
}
|
||||
// loadInflightState — retrieve in-memory inflight state for a session.
|
||||
// Called by loadSession() when active_stream_id is set on the server session
|
||||
// but no INFLIGHT[sid] entry exists (e.g. after a session switch back).
|
||||
// Returns the stored state dict or null. The else-path in loadSession handles
|
||||
// page reloads directly via attachLiveStream when this returns null.
|
||||
function loadInflightState(sid, streamId) {
|
||||
// In-memory store: only survives within the same page load.
|
||||
// If INFLIGHT[sid] exists but the caller already checked !INFLIGHT[sid],
|
||||
// this won't be reached. Return null — the else path handles page reloads.
|
||||
return null;
|
||||
}
|
||||
|
||||
async function checkInflightOnBoot(sid) {
|
||||
const raw = localStorage.getItem(INFLIGHT_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user