mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-06-06 08:50:58 +00:00
Merge remote-tracking branch 'origin/master' into fix/selective-durable-writeback
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -7,6 +7,14 @@
|
||||
|
||||
- WebUI's browser-session surface prompt now explicitly tells agents not to dump browser transcripts into external notes or durable memory by default; it limits saving to explicit captures and clearly reusable durable signals such as preferences, decisions, blockers, and runbook-worthy workflows.
|
||||
|
||||
## [v0.51.155] — 2026-05-28 — Release EA (stage-batch37 — 3-PR very low-risk cleanup: passive timeout toasts + sidecar order + subsecond timestamps)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Passive background refreshes such as sidebar/project polling, health checks, cron-status watches, and client-event logging no longer surface generic timeout toasts; explicit user actions still show timeout errors. (Related to #3024)
|
||||
- Messaging/session display merges now preserve sidecar transcript order when the sidecar already contains at least as many rows as the mirrored state store, avoiding role/content fallback sorting when timestamp precision collapses.
|
||||
- Gateway-backed turns and compacted/reconciled message batches now keep subsecond timestamp ordering instead of assigning the same integer-second timestamp to multiple transcript rows.
|
||||
|
||||
## [v0.51.154] — 2026-05-28 — Release DZ (stage-batch36 — 9-PR medium-risk cleanup: cron project chip + KaTeX streaming + recovery + .env keys + discoverability repair + media MEDIA tokens + gateway 401 + notes prefill + cron filter)
|
||||
|
||||
### Added
|
||||
|
||||
+7
-2
@@ -297,11 +297,16 @@ def _run_gateway_chat_streaming(
|
||||
s = get_session(session_id)
|
||||
if not _stream_writeback_is_current(s, stream_id):
|
||||
return
|
||||
now = int(time.time())
|
||||
now = time.time()
|
||||
# Preserve subsecond ordering for gateway-backed turns. Using an
|
||||
# integer seconds timestamp gives the user and assistant rows the
|
||||
# same sort key; later transcript merges can then fall back to
|
||||
# role/content ordering instead of turn order.
|
||||
assistant_ts = now + 0.000001
|
||||
user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now}
|
||||
if attachments:
|
||||
user_msg["attachments"] = list(attachments)
|
||||
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": now}
|
||||
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts}
|
||||
previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or [])
|
||||
s.context_messages = previous_context + [user_msg, assistant_msg]
|
||||
display = list(getattr(s, "messages", None) or [])
|
||||
|
||||
@@ -2263,6 +2263,12 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list:
|
||||
sidecar_messages = list(getattr(session, "messages", []) or [])
|
||||
if cli_messages:
|
||||
if sidecar_messages and sidecar_messages != cli_messages:
|
||||
if len(sidecar_messages) >= len(cli_messages):
|
||||
return merge_session_messages_append_only(
|
||||
sidecar_messages,
|
||||
cli_messages,
|
||||
truncation_watermark=getattr(session, "truncation_watermark", None),
|
||||
)
|
||||
merged_messages = []
|
||||
seen_message_keys = set()
|
||||
for msg in sorted(list(cli_messages) + list(sidecar_messages), key=lambda m: (
|
||||
|
||||
+19
-5
@@ -2983,6 +2983,22 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
||||
return merged
|
||||
|
||||
|
||||
def _stamp_missing_message_timestamps(messages, *, now: float | None = None) -> int:
|
||||
"""Stamp missing message timestamps without collapsing transcript order.
|
||||
|
||||
Compacted/reconciled rows can arrive without timestamps. Assigning one
|
||||
integer seconds value to the whole batch makes later timestamp-based display
|
||||
merges unstable; use a subsecond sequence instead.
|
||||
"""
|
||||
base = time.time() if now is None else float(now)
|
||||
stamped = 0
|
||||
for msg in messages or []:
|
||||
if isinstance(msg, dict) and not msg.get('timestamp') and not msg.get('_ts'):
|
||||
msg['timestamp'] = base + (stamped * 0.000001)
|
||||
stamped += 1
|
||||
return stamped
|
||||
|
||||
|
||||
def _assistant_reply_added_after_current_turn(result_messages, previous_context, msg_text) -> bool:
|
||||
"""Return True only when the just-finished turn produced assistant text."""
|
||||
result_messages = list(result_messages or [])
|
||||
@@ -5299,11 +5315,9 @@ def _run_agent_streaming(
|
||||
'usage': _live_usage_snapshot(),
|
||||
})
|
||||
|
||||
# Stamp 'timestamp' on any messages that don't have one yet
|
||||
_now = time.time()
|
||||
for _m in s.messages:
|
||||
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
|
||||
_m['timestamp'] = int(_now)
|
||||
# Stamp 'timestamp' on any messages that don't have one yet,
|
||||
# preserving transcript order across compacted/reconciled batches.
|
||||
_stamp_missing_message_timestamps(s.messages)
|
||||
# Only auto-generate title when still default; preserves user renames
|
||||
if s.title == 'Untitled' or s.title == 'New Chat' or not s.title:
|
||||
s.title = title_from(s.messages, s.title)
|
||||
|
||||
+2
-2
@@ -1145,7 +1145,7 @@ function _startCronWatch(jobId) {
|
||||
_cronWatchStart = Date.now();
|
||||
_cronWatchInterval = setInterval(async () => {
|
||||
try {
|
||||
const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`);
|
||||
const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false});
|
||||
if (!data.running) {
|
||||
_stopCronWatch();
|
||||
if (_currentCronDetail && _currentCronDetail.id === jobId) {
|
||||
@@ -1200,7 +1200,7 @@ function _formatElapsed(seconds) {
|
||||
|
||||
function _checkCronWatchOnDetail(jobId) {
|
||||
// When opening a detail view, check if job is running
|
||||
api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`).then(data => {
|
||||
api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false}).then(data => {
|
||||
if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) {
|
||||
_startCronWatch(jobId);
|
||||
}
|
||||
|
||||
+3
-3
@@ -2280,8 +2280,8 @@ async function renderSessionList(opts={}){
|
||||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||||
const allProfilesQS = _showAllProfiles ? '?all_profiles=1' : '';
|
||||
const [sessData, projData] = await Promise.all([
|
||||
api('/api/sessions' + allProfilesQS),
|
||||
api('/api/projects' + allProfilesQS),
|
||||
api('/api/sessions' + allProfilesQS,{timeoutToast:false}),
|
||||
api('/api/projects' + allProfilesQS,{timeoutToast:false}),
|
||||
]);
|
||||
// Discard stale response — a newer renderSessionList() call superseded us.
|
||||
if (_gen !== _renderSessionListGen) return;
|
||||
@@ -2354,7 +2354,7 @@ async function refreshActiveSessionIfExternallyUpdated(reason){
|
||||
const localLast = Number(S.session.last_message_at || S.session.updated_at || 0);
|
||||
_activeSessionExternalRefreshInFlight = true;
|
||||
try{
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false});
|
||||
if(!data || !data.session) return;
|
||||
if(!S.session || S.session.session_id !== sid) return;
|
||||
if(S.busy || S.activeStreamId) return;
|
||||
|
||||
+3
-3
@@ -475,7 +475,7 @@ async function refreshDashboardStatus(force=false){
|
||||
return _dashboardStatusCache;
|
||||
}
|
||||
try{
|
||||
const status=await api('/api/dashboard/status');
|
||||
const status=await api('/api/dashboard/status',{timeoutToast:false});
|
||||
_dashboardStatusCache=status||{running:false};
|
||||
}catch(_){
|
||||
_dashboardStatusCache={running:false};
|
||||
@@ -4588,7 +4588,7 @@ async function pollSystemHealth(){
|
||||
if(document.visibilityState !== 'visible') return;
|
||||
if(!_systemHealthPanelIsVisible()) return;
|
||||
try{
|
||||
const payload=await api('/api/system/health');
|
||||
const payload=await api('/api/system/health',{timeoutToast:false});
|
||||
renderSystemHealth(payload);
|
||||
}catch(_){
|
||||
setSystemHealthUnavailable('Unavailable');
|
||||
@@ -4654,7 +4654,7 @@ function dismissAgentHealthAlert(){
|
||||
async function pollAgentHealth(){
|
||||
if(document.visibilityState !== 'visible') return;
|
||||
try{
|
||||
const payload=await api('/api/health/agent');
|
||||
const payload=await api('/api/health/agent',{timeoutToast:false});
|
||||
if(payload.alive === true){
|
||||
_agentHealthLastState='alive';
|
||||
_setAgentHealthDismissed(false);
|
||||
|
||||
+5
-2
@@ -3,6 +3,7 @@ async function api(path,opts={}){
|
||||
const rel = path.startsWith('/') ? path.slice(1) : path;
|
||||
const url=new URL(rel,document.baseURI||location.href);
|
||||
const timeoutMs=Object.prototype.hasOwnProperty.call(opts,'timeoutMs')?opts.timeoutMs:30000;
|
||||
const timeoutToast=opts.timeoutToast!==false;
|
||||
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
|
||||
// Server errors (4xx/5xx) and client-side timeouts are NOT retried.
|
||||
let lastErr;
|
||||
@@ -15,6 +16,8 @@ async function api(path,opts={}){
|
||||
try{
|
||||
const fetchOpts={...opts};
|
||||
delete fetchOpts.timeoutMs;
|
||||
delete fetchOpts.timeoutToast;
|
||||
|
||||
const useTimeout=Number.isFinite(Number(timeoutMs))&&Number(timeoutMs)>0;
|
||||
if(useTimeout&&typeof AbortController!=='undefined'){
|
||||
controller=new AbortController();
|
||||
@@ -69,7 +72,7 @@ async function api(path,opts={}){
|
||||
const err=(e&&e.name==='TimeoutError')?e:new Error('Request timed out. Please try again.');
|
||||
err.name='TimeoutError';
|
||||
err.timeout=true;
|
||||
if(typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error');
|
||||
if(timeoutToast&&typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error');
|
||||
throw err;
|
||||
}
|
||||
// Only retry on network errors (TypeError from fetch), not on HTTP errors
|
||||
@@ -98,7 +101,7 @@ function recordClientSSEError(source, details={}){
|
||||
url_path:(typeof location!=='undefined'&&location.pathname)||'/',
|
||||
reason:details.reason||'EventSource.onerror',
|
||||
};
|
||||
void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000}).catch(()=>{});
|
||||
void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000,timeoutToast:false}).catch(()=>{});
|
||||
}catch(_){}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,13 +159,48 @@ def test_api_rejects_stalled_response_body_with_timeout():
|
||||
assert any(event.get("aborted") for event in payload["events"]), payload
|
||||
|
||||
|
||||
def test_api_can_suppress_timeout_toast_for_background_pollers():
|
||||
"""Passive pollers need abort/reject cleanup without a user-visible toast."""
|
||||
api_fn = _extract_js_function(_source(WORKSPACE_JS), "api")
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const events=[];
|
||||
global.document={{baseURI:'http://example.test/hermes/'}};
|
||||
global.location={{href:'http://example.test/hermes/',pathname:'/hermes/',search:''}};
|
||||
global.window={{location:global.location}};
|
||||
global.showToast=(msg,ms,type)=>events.push({{msg:String(msg),ms,type}});
|
||||
global.fetch=(url,opts)=>new Promise(()=>{{
|
||||
if(opts&&opts.signal)opts.signal.addEventListener('abort',()=>events.push({{aborted:true}}));
|
||||
}});
|
||||
{api_fn}
|
||||
api('/api/sessions',{{timeoutMs:20,timeoutToast:false}})
|
||||
.then(()=>{{console.error('resolved unexpectedly');process.exit(2);}})
|
||||
.catch(err=>{{
|
||||
console.log(JSON.stringify({{message:String(err&&err.message||err),events}}));
|
||||
process.exit(0);
|
||||
}});
|
||||
setTimeout(()=>{{console.error('api did not reject after timeoutMs');process.exit(3);}},250);
|
||||
"""
|
||||
)
|
||||
result = _node_eval(script, timeout=1.0)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
payload = json.loads(result.stdout.strip())
|
||||
assert "timed out" in payload["message"].lower()
|
||||
assert any(event.get("aborted") for event in payload["events"]), payload
|
||||
assert not any("msg" in event for event in payload["events"]), payload
|
||||
|
||||
|
||||
def test_api_has_default_timeout_and_per_call_override_contract():
|
||||
src = _source(WORKSPACE_JS)
|
||||
body = _extract_js_function(src, "api")
|
||||
assert "timeoutMs" in body, "api() must accept opts.timeoutMs as a per-call override"
|
||||
assert "timeoutToast" in body, "api() must let passive callers suppress timeout toasts"
|
||||
|
||||
assert "30000" in body, "api() must default browser API calls to a 30s timeout"
|
||||
assert "AbortController" in body, "api() must abort hung fetches with AbortController"
|
||||
assert "delete fetchOpts.timeoutMs" in body, "api() must strip timeoutMs before calling fetch()"
|
||||
assert "delete fetchOpts.timeoutToast" in body, "api() must strip timeoutToast before calling fetch()"
|
||||
|
||||
fetch_call = re.search(r"fetch\(url\.href,\{.*?\.\.\.fetchOpts.*?\}\)", body, re.DOTALL)
|
||||
assert fetch_call, "api() must call fetch() with sanitized fetchOpts"
|
||||
assert "...opts" not in fetch_call.group(0), "api() must not spread raw opts into fetch()"
|
||||
@@ -182,6 +217,23 @@ def test_update_flows_keep_explicit_longer_timeouts():
|
||||
assert "api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
|
||||
|
||||
|
||||
def test_passive_background_polls_suppress_timeout_toasts():
|
||||
"""Passive refreshes should be best-effort and not emit generic timeout toasts."""
|
||||
workspace = _source(WORKSPACE_JS)
|
||||
sessions = _source(SESSIONS_JS)
|
||||
ui = _source(UI_JS)
|
||||
panels = _source(PANELS_JS)
|
||||
|
||||
assert "api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000,timeoutToast:false})" in workspace
|
||||
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in sessions
|
||||
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in sessions
|
||||
assert "api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false})" in sessions
|
||||
assert "api('/api/dashboard/status',{timeoutToast:false})" in ui
|
||||
assert "api('/api/system/health',{timeoutToast:false})" in ui
|
||||
assert "api('/api/health/agent',{timeoutToast:false})" in ui
|
||||
assert "api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false})" in panels
|
||||
|
||||
|
||||
def test_new_session_inflight_cleanup_still_runs_after_api_rejects():
|
||||
"""newSession() must keep its finally cleanup path so timeout rejections unpin the UI."""
|
||||
src = _source(SESSIONS_JS)
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_dashboard_rail_item_sits_between_insights_and_settings_spacer():
|
||||
def test_dashboard_frontend_fetches_status_with_sixty_second_cache():
|
||||
assert "DASHBOARD_STATUS_TTL_MS=60000" in UI_JS
|
||||
assert "function refreshDashboardStatus" in UI_JS
|
||||
assert "api('/api/dashboard/status')" in UI_JS
|
||||
assert "api('/api/dashboard/status',{timeoutToast:false})" in UI_JS
|
||||
assert "setInterval(refreshDashboardStatus,DASHBOARD_STATUS_TTL_MS)" in UI_JS
|
||||
assert 'fetch("/api/dashboard/status"' not in UI_JS
|
||||
assert "fetch('/api/dashboard/status'" not in UI_JS
|
||||
|
||||
@@ -134,10 +134,10 @@ def test_static_sessions_js_uses_all_profiles_query_when_toggle_on():
|
||||
assert "_showAllProfiles ? '?all_profiles=1' : ''" in src, (
|
||||
"Expected fetch path to flip on the toggle state"
|
||||
)
|
||||
assert "api('/api/sessions' + allProfilesQS)" in src, (
|
||||
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in src, (
|
||||
"Expected /api/sessions fetch to use the variant query"
|
||||
)
|
||||
assert "api('/api/projects' + allProfilesQS)" in src, (
|
||||
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in src, (
|
||||
"Expected /api/projects fetch to use the variant query"
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +66,39 @@ def test_messaging_merge_helper_dedupes_equivalent_timestamp_formats():
|
||||
assert [m["content"] for m in merged] == ["hi", "same answer"]
|
||||
|
||||
|
||||
def test_messaging_merge_preserves_longer_sidecar_order_when_timestamps_collapse():
|
||||
"""A repaired messaging sidecar can preserve order but lose subsecond timestamps.
|
||||
|
||||
Re-sorting those messages by ``(timestamp, role, content)`` groups assistant
|
||||
and tool rows before user rows, making the WebUI look like replies vanished.
|
||||
"""
|
||||
session = SimpleNamespace(
|
||||
messages=[
|
||||
{"role": "assistant", "content": "prior answer", "timestamp": 100.0},
|
||||
{"role": "user", "content": "first prompt", "timestamp": 101.0},
|
||||
{"role": "assistant", "content": "first answer", "timestamp": 101.0},
|
||||
{"role": "user", "content": "second prompt", "timestamp": 101.0},
|
||||
{"role": "assistant", "content": "second answer", "timestamp": 101.0},
|
||||
]
|
||||
)
|
||||
cli_messages = [
|
||||
{"role": "user", "content": "first prompt", "timestamp": 101.1},
|
||||
{"role": "assistant", "content": "first answer", "timestamp": 101.2},
|
||||
{"role": "user", "content": "second prompt", "timestamp": 101.3},
|
||||
{"role": "assistant", "content": "second answer", "timestamp": 101.4},
|
||||
]
|
||||
|
||||
merged = routes._merged_session_messages_for_display(session, cli_messages)
|
||||
|
||||
assert [m["content"] for m in merged] == [
|
||||
"prior answer",
|
||||
"first prompt",
|
||||
"first answer",
|
||||
"second prompt",
|
||||
"second answer",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_handler_uses_merged_messaging_messages_for_keep_count():
|
||||
branch_idx = ROUTES_PY.index('parsed.path == "/api/session/branch":')
|
||||
block = ROUTES_PY[branch_idx : branch_idx + 2600]
|
||||
|
||||
@@ -159,7 +159,7 @@ def test_system_health_panel_markup_and_styles_live_under_insights_not_top_chrom
|
||||
|
||||
def test_system_health_frontend_polls_visible_and_renders_progress_labels():
|
||||
assert "const SYSTEM_HEALTH_INTERVAL_MS=5000" in UI_JS
|
||||
assert "api('/api/system/health')" in UI_JS
|
||||
assert "api('/api/system/health',{timeoutToast:false})" in UI_JS
|
||||
assert "document.visibilityState !== 'visible'" in UI_JS
|
||||
assert "document.querySelector('main.main.showing-insights')" in UI_JS
|
||||
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS
|
||||
|
||||
@@ -186,7 +186,7 @@ def test_agent_health_banner_markup_and_styles_exist():
|
||||
|
||||
def test_agent_health_frontend_polls_only_visible_and_distinguishes_states():
|
||||
assert "const AGENT_HEALTH_INTERVAL_MS=30000" in UI_JS
|
||||
assert "api('/api/health/agent')" in UI_JS
|
||||
assert "api('/api/health/agent',{timeoutToast:false})" in UI_JS
|
||||
assert "document.visibilityState !== 'visible'" in UI_JS
|
||||
assert "document.addEventListener('visibilitychange',_syncAgentHealthMonitorVisibility)" in UI_JS
|
||||
assert "if(payload.alive === true)" in UI_JS
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from api.streaming import _stamp_missing_message_timestamps
|
||||
|
||||
|
||||
def test_stamp_missing_message_timestamps_uses_subsecond_sequence():
|
||||
messages = [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
]
|
||||
|
||||
stamped = _stamp_missing_message_timestamps(messages, now=1000.0)
|
||||
|
||||
assert stamped == 3
|
||||
assert [m["timestamp"] for m in messages] == [1000.0, 1000.000001, 1000.000002]
|
||||
|
||||
|
||||
def test_stamp_missing_message_timestamps_preserves_existing_timestamp_metadata():
|
||||
messages = [
|
||||
{"role": "user", "content": "old", "timestamp": 900.0},
|
||||
{"role": "assistant", "content": "synthetic", "_ts": 901.0},
|
||||
{"role": "user", "content": "new"},
|
||||
]
|
||||
|
||||
stamped = _stamp_missing_message_timestamps(messages, now=1000.0)
|
||||
|
||||
assert stamped == 1
|
||||
assert messages[0]["timestamp"] == 900.0
|
||||
assert "timestamp" not in messages[1]
|
||||
assert messages[2]["timestamp"] == 1000.0
|
||||
@@ -224,6 +224,9 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
|
||||
saved = models.get_session(s.session_id)
|
||||
assert [m["role"] for m in saved.messages] == ["user", "assistant"]
|
||||
assert saved.messages[-1]["content"] == "hello"
|
||||
assert isinstance(saved.messages[0]["timestamp"], float)
|
||||
assert isinstance(saved.messages[1]["timestamp"], float)
|
||||
assert saved.messages[0]["timestamp"] < saved.messages[1]["timestamp"]
|
||||
assert saved.active_stream_id is None
|
||||
assert stream_id not in STREAMS
|
||||
assert captured["url"] == "http://gateway.local/v1/chat/completions"
|
||||
|
||||
Reference in New Issue
Block a user