# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes 2026-05-28 18:27:03 +00:00
commit 2f73eb0f8d
10 changed files with 81 additions and 28 deletions

View file

@ -3,16 +3,22 @@
## [Unreleased]
## [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 <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)
### 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)
@ -25,13 +31,6 @@
## [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.
@ -41,16 +40,15 @@
- 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

View file

@ -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);
}

View file

@ -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;

View file

@ -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);

View file

@ -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(_){}
}

View file

@ -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)

View file

@ -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

View file

@ -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"
)

View file

@ -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

View file

@ -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