From 5f901f579aee6f865979709161de56b27eb88a86 Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Thu, 28 May 2026 11:08:38 +0800 Subject: [PATCH 1/4] fix: suppress timeout toasts for passive pollers --- CHANGELOG.md | 4 ++ static/panels.js | 4 +- static/sessions.js | 6 +-- static/ui.js | 6 +-- static/workspace.js | 7 ++- tests/test_api_timeout.py | 52 +++++++++++++++++++ tests/test_dashboard_link_ui.py | 2 +- ...est_issue1611_session_profile_filtering.py | 4 +- tests/test_issue693_system_health_panel.py | 2 +- tests/test_issue716_agent_heartbeat.py | 2 +- 10 files changed, 74 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 251cd248..351dfcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### 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) + ## [v0.51.151] — 2026-05-28 — Release DW (stage-batch33 — 3-PR mid-risk batch: SSE reattach + title-lang + composer cap) ### Fixed diff --git a/static/panels.js b/static/panels.js index 3261d900..fc8d4757 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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); } diff --git a/static/sessions.js b/static/sessions.js index fb51405e..ddfa1d4c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2278,8 +2278,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; @@ -2352,7 +2352,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; diff --git a/static/ui.js b/static/ui.js index a4bab419..d119b7cf 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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}; @@ -4587,7 +4587,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'); @@ -4653,7 +4653,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); diff --git a/static/workspace.js b/static/workspace.js index 603bdc31..5f86dafa 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -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(_){} } diff --git a/tests/test_api_timeout.py b/tests/test_api_timeout.py index 3f89f71a..f5a51fcc 100644 --- a/tests/test_api_timeout.py +++ b/tests/test_api_timeout.py @@ -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) diff --git a/tests/test_dashboard_link_ui.py b/tests/test_dashboard_link_ui.py index 787b869d..8c55f4c1 100644 --- a/tests/test_dashboard_link_ui.py +++ b/tests/test_dashboard_link_ui.py @@ -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 diff --git a/tests/test_issue1611_session_profile_filtering.py b/tests/test_issue1611_session_profile_filtering.py index c3a6d8d5..6c277187 100644 --- a/tests/test_issue1611_session_profile_filtering.py +++ b/tests/test_issue1611_session_profile_filtering.py @@ -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" ) diff --git a/tests/test_issue693_system_health_panel.py b/tests/test_issue693_system_health_panel.py index 1161dc0a..81b58438 100644 --- a/tests/test_issue693_system_health_panel.py +++ b/tests/test_issue693_system_health_panel.py @@ -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 diff --git a/tests/test_issue716_agent_heartbeat.py b/tests/test_issue716_agent_heartbeat.py index b0cdb0a7..201b3741 100644 --- a/tests/test_issue716_agent_heartbeat.py +++ b/tests/test_issue716_agent_heartbeat.py @@ -184,7 +184,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 From 1cd58f6f5abc98a385115bcf2f9cbb6ed6c86ad2 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 19:58:17 +0200 Subject: [PATCH 2/4] fix(session): preserve sidecar order in display merges --- CHANGELOG.md | 4 +++ api/routes.py | 6 ++++ ...test_issue2472_fork_from_here_messaging.py | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ed7ed6..a4838fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- 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. + ## [v0.51.153] — 2026-05-28 — Release DY (stage-batch35 — 11-PR low-risk cleanup: title-language + clarify SSE + upload filename + discoverability + SSE reconnect + gateway image + docker docs) ### Changed diff --git a/api/routes.py b/api/routes.py index 8ee764df..10e5a9b3 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2223,6 +2223,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: ( diff --git a/tests/test_issue2472_fork_from_here_messaging.py b/tests/test_issue2472_fork_from_here_messaging.py index c82ea764..44d00e66 100644 --- a/tests/test_issue2472_fork_from_here_messaging.py +++ b/tests/test_issue2472_fork_from_here_messaging.py @@ -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] From 07aed6b7ff1adb56c3f887cbe9e957dce8cd6a8e Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Thu, 28 May 2026 19:58:18 +0200 Subject: [PATCH 3/4] fix(session): preserve subsecond message timestamp order --- CHANGELOG.md | 4 ++++ api/gateway_chat.py | 9 ++++++-- api/streaming.py | 24 ++++++++++++++++---- tests/test_message_timestamp_stamping.py | 29 ++++++++++++++++++++++++ tests/test_webui_gateway_chat_backend.py | 3 +++ 5 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 tests/test_message_timestamp_stamping.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ed7ed6..885cc83b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- 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.153] — 2026-05-28 — Release DY (stage-batch35 — 11-PR low-risk cleanup: title-language + clarify SSE + upload filename + discoverability + SSE reconnect + gateway image + docker docs) ### Changed diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 69e653ac..05c3aea9 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -263,11 +263,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 []) diff --git a/api/streaming.py b/api/streaming.py index b4aefdcc..0ab66511 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2976,6 +2976,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 []) @@ -5284,11 +5300,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) diff --git a/tests/test_message_timestamp_stamping.py b/tests/test_message_timestamp_stamping.py new file mode 100644 index 00000000..1a4b50f6 --- /dev/null +++ b/tests/test_message_timestamp_stamping.py @@ -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 diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index f9c7f94d..72ebf197 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -112,6 +112,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" From 2893f87e19725c90a9bc80d4dc00b030295dca6a Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Thu, 28 May 2026 18:28:04 +0000 Subject: [PATCH 4/4] stage-batch37: stamp v0.51.155 / Release EA 3-PR very low-risk cleanup: - #3039 api(): timeoutToast:false opt-in for passive pollers - #3085 _merged_session_messages_for_display: preserve sidecar order when longer - #3086 subsecond timestamps for gateway turns + compaction batches --- CHANGELOG.md | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8e7d23..d2af42d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,30 +3,24 @@ ## [Unreleased] +## [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 - Session discoverability audit now has a default-dry-run `--repair-safe` routine for deterministic cleanup: stale persisted WebUI-as-CLI flags can be cleared from sidecars/index entries, and messageful WebUI rows present only in `state.db` can be materialized into sidecars/index entries when `--apply --backup-dir ` is explicitly provided. - -- Browser chat can now opt into a default-off `HERMES_WEBUI_CHAT_BACKEND=gateway` bridge that routes new WebUI turns through a running Hermes Gateway API server while preserving the existing WebUI chat start/stream contract. Strict enable: only the literal values `gateway`, `api_server`, or `api-server` activate the bridge — generic truthy strings like `1` or `true` keep the legacy in-process WebUI runtime. Configurable via `HERMES_WEBUI_GATEWAY_BASE_URL` (default `http://127.0.0.1:8642`) and `HERMES_WEBUI_GATEWAY_API_KEY` (falls back to `API_SERVER_KEY`). New `api/gateway_chat.py` module isolates the bridge logic; existing direct WebUI chat path unchanged when the env/config is not set. (#3021) - -### Fixed - -- 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.153] — 2026-05-28 — Release DY (stage-batch35 — 11-PR low-risk cleanup: title-language + clarify SSE + upload filename + discoverability + SSE reconnect + gateway image + docker docs) - ### Changed - The third-party notes drawer's "Recently used by AI" list now follows the provider-neutral WebUI-specific `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` / `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]` and command strings such as `python3 /path/to/recall.py`, before falling back to the legacy generic `prefill_messages_script`. Configured third-party notes sources such as Joplin, Obsidian, Notion, and llm-wiki remain visible even before runtime tool inventory hydrates. - -- Local fallback title generation no longer has a German-only `Session Bilder` special case; it now uses the same generic topic extraction path as other fallback titles. (Refs #3040) -- Title-generation prompts now use the same language-neutral "match the user language" instruction for every locale instead of adding German-only exemplars. (Refs #3040) -- Session discoverability audit findings for stale persisted WebUI-as-CLI flags now report whether an API-visible lineage representative already covers the hidden snapshot, including the representative session id in JSON and Markdown output. - ### Fixed - Streaming KaTeX render passes now skip parser-owned equation placeholders that may still be receiving text, preventing long equations from being marked rendered before the final parser flush completes. (#2976) @@ -39,6 +33,13 @@ ## [v0.51.153] — 2026-05-28 — Release DY (stage-batch35 — 11-PR low-risk cleanup: title-language + clarify SSE + upload filename + discoverability + SSE reconnect + gateway image + docker docs) +### Changed + +- Local fallback title generation no longer has a German-only `Session Bilder` special case; it now uses the same generic topic extraction path as other fallback titles. (Refs #3040) +- Title-generation prompts now use the same language-neutral "match the user language" instruction for every locale instead of adding German-only exemplars. (Refs #3040) +- Session discoverability audit findings for stale persisted WebUI-as-CLI flags now report whether an API-visible lineage representative already covers the hidden snapshot, including the representative session id in JSON and Markdown output. + +### Fixed - Title-language detection no longer treats common English tech/jargon text such as "session die" or DAS/DER references as German just because of shared tokens. (Refs #3040) - Clarify prompt SSE fallback polling now preserves its owner session id, matching approval polling behavior so terminal events from another session cannot stop the active clarify fallback poller. @@ -48,15 +49,16 @@ - New chat sessions reset `_messagesTruncated` / `_oldestIdx` so a fresh conversation never displays the stale "Scroll up or click to load older messages" indicator inherited from a previously-paginated session. - `openai-codex` reasoning-effort resolution now lets the existing `models.dev` metadata pass set the supported levels (including `xhigh`) instead of being silently clipped through the Copilot model heuristic. - -- 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) - ### Documentation - Clarify two Docker onboarding traps: `sudo docker compose` can mount `/root/.hermes` instead of the user's Hermes home on Linux, and Linux Docker Engine users should use a `host-gateway` alias such as `api.local` for host-local model servers instead of configuring `localhost` inside the container. (#3006, #3012) ## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat) +### Added + +- Browser chat can now opt into a default-off `HERMES_WEBUI_CHAT_BACKEND=gateway` bridge that routes new WebUI turns through a running Hermes Gateway API server while preserving the existing WebUI chat start/stream contract. Strict enable: only the literal values `gateway`, `api_server`, or `api-server` activate the bridge — generic truthy strings like `1` or `true` keep the legacy in-process WebUI runtime. Configurable via `HERMES_WEBUI_GATEWAY_BASE_URL` (default `http://127.0.0.1:8642`) and `HERMES_WEBUI_GATEWAY_API_KEY` (falls back to `API_SERVER_KEY`). New `api/gateway_chat.py` module isolates the bridge logic; existing direct WebUI chat path unchanged when the env/config is not set. (#3021) + ## [v0.51.151] — 2026-05-28 — Release DW (stage-batch33 — 3-PR mid-risk batch: SSE reattach + title-lang + composer cap) ### Fixed