diff --git a/CHANGELOG.md b/CHANGELOG.md index 89762019..083760a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Hermes Web UI -- Changelog +## [v0.51.5] — 2026-05-05 — 4-PR full-sweep batch + +### Added + +- **PR #1688** by @Michaelyklam — VPS resource health Insights panel (closes #693). New `api/system_health.py` provides a dependency-free Linux/stdlib metrics collector for aggregate CPU (via /proc/stat delta sample), memory (/proc/meminfo), and root disk (shutil.disk_usage). Authenticated `GET /api/system/health` returns sanitized aggregate fields only — no process argv, env, paths, or secrets. The card lives in the Insights tab (NOT always-visible top chrome) per maintainer placement feedback. Polling is gated by `visibilityState` so hidden tabs don't poll, and on macOS/Windows the panel hides itself instead of showing a noisy error. 7 regression tests pin endpoint registration, payload sanitization, Insights placement, and absence from top chrome. + +### Fixed + +- **PR #1709** by @Michaelyklam — Preserve scroll on stream completion (closes #1690). `_run_background_title_refresh()` and terminal stream handlers were clearing `S.activeStreamId` before the final `renderMessages()` call, while `renderMessages()` chose between `scrollIfPinned()` and `scrollToBottom()` based on stream liveness alone. Result: long stream + user scrolls up to read earlier content + stream finishes → cursor jumped to bottom. Fix adds `_scrollAfterMessageRender(preserveScroll)` helper. When `preserveScroll=true`, calls `scrollIfPinned()` (respects pin state); when false (load/switch path), legacy `scrollToBottom()`. 4 callsites in messages.js terminal-stream paths (`done`, `error`, `cancel`, fallback) pass `{preserveScroll: true}`. +- **PR #1711** by @nesquena-hermes — Hide 'Double-click to rename' tooltip on folders (closes #1710). Workspace file-tree row tooltip said "Double-click to rename" on every entry — including folders. But folder dblclick navigates via `loadDir()`, not rename; rename for folders lives in the right-click context menu. The tooltip was misleading. 4-line fix in `_renderTreeItems()`: gate `nameEl.title = t('double_click_rename')` on `item.type !== 'dir'`. Reported by @Deor in the WebUI Discord testers thread May 5 2026. +- **PR #1712** by @24601 — Guard `localStorage.setItem('hermes-webui-model')` against `QuotaExceededError`. On setups with localStorage near quota, the bare `setItem` call threw an unhandled `DOMException` that broke model selection and prevented the chat UI from loading. Wraps both callsites (boot.js modelSelect.onchange handler, onboarding.js _saveOnboardingDefaults) in `try{...}catch{}` so the error is silently absorbed and the UI falls back to server-side model state on next load. The stored value (a model ID string) is tiny — quota failure is from overall localStorage pressure, not this key. + +### Tests + +4504 → **4527 passing** (+23 regression tests across the 4 PRs, mostly from #1688's 7-test suite). 0 regressions. Full suite ~130s. + +### Pre-release verification + +- Stage-302: 4 PRs merged with zero conflicts (each rebased clean against current master). Zero stage-applied edits to any file — every change ships exactly as the contributor wrote it. +- All JS files syntax-clean (`node -c static/{boot,messages,onboarding,panels,ui}.js`). +- All Python files syntax-clean (py_compile on every changed file). +- Live browser walkthrough on port 8789: + - `/api/system/health` returns sanitized JSON with CPU/memory/disk percentages (no /proc paths, no argv leakage) + - System health card renders in Insights with Live badge + 3 progress bars (visual rated 9.5/10 via vision check) + - System health card NOT in top chrome (per nesquena placement feedback) + - Sidebar scroll holds at 400px (carry-over fix from v0.51.2 preserved) + - `_scrollAfterMessageRender` 4-branch behavioral test all correct (preserveScroll respects pin state in all paths) + - Recent-release feature inventory verified: PR #1644 model picker chip, PR #1685 Codex spark group, PR #1684 update banner network detection, PR #1671 quota card endpoint, PR #1676 heartbeat banner default-hidden, PR #1664 LLM Wiki endpoint, PR #1662 Logs nav button (via aria-label), PR #1706 paste-multiple fix +- Opus advisor: SHIP, 6/6 verification clean, 0 MUST-FIX, 0 SHOULD-FIX. Two non-blocking observations: + - `/api/system/health` could use `Cache-Control: no-store` (optional, defensive) + - `}catch{}` in #1712 swallows all errors silently (acceptable for 2-LOC defensive guard) + +### Notes on this sweep + +- **#1686** (Docker enhance by @binhpt310) was held back. Opus advisor flagged a blocker: the PR's `docker-compose.yml` change (`build context: ..`) and `COPY hermes-agent-desktop/...` Dockerfile additions assume a sibling `hermes-agent-desktop/` directory at clone time, which would break standalone clones. Left open for follow-up. +- **#1712** was force-pushed mid-sweep to a simpler form (drops `console.warn`). v2 adopted; fits in the original `test_provider_mismatch.py` 1100-char window so no test widening needed. +- **#1688** was on the held list (ux + hold labels) but per maintainer call ("Looks much better, thanks! Going to move towards review and merge"), labels removed and PR included in batch. CI was already green on all 3 Python versions. + +Closes #693, #1690, #1710. + + ## [v0.51.5] — 2026-05-05 — single-PR hotfix (#1707) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index a8f0d594..a3b09304 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.5 (May 5, 2026) — 4517 tests collected — single-PR hotfix #1707 (workspace filename single-click regression) +> Last updated: v0.51.5 (May 5, 2026) — 4527 tests collected > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index 67b9360a..ad3affc9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1836,7 +1836,7 @@ Bridged CLI sessions: --- *Last updated: v0.51.5, May 5, 2026* -*Total automated tests collected: 4503* +*Total automated tests collected: 4527* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/api/routes.py b/api/routes.py index 04640458..e2139a0c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -480,6 +480,7 @@ from api.helpers import ( _redact_text, ) from api.agent_health import build_agent_health_payload +from api.system_health import build_system_health_payload def _clear_stale_stream_state(session) -> bool: @@ -2491,6 +2492,10 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/health/agent": return j(handler, build_agent_health_payload()) + if parsed.path == "/api/system/health": + j(handler, build_system_health_payload()) + return True + if parsed.path == "/api/models": return j(handler, get_available_models()) diff --git a/api/system_health.py b/api/system_health.py new file mode 100644 index 00000000..9b86f4ed --- /dev/null +++ b/api/system_health.py @@ -0,0 +1,167 @@ +"""Safe aggregate host resource metrics for the WebUI VPS panel (#693). + +The browser only needs coarse CPU/RAM/disk usage. Keep this module intentionally +small and dependency-free: no process lists, command strings, user identities, +environment variables, or filesystem topology leave the server. +""" + +from __future__ import annotations + +import shutil +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +_PROC_STAT = Path("/proc/stat") +_PROC_MEMINFO = Path("/proc/meminfo") +_CPU_SAMPLE_SECONDS = 0.05 + + +def _checked_at() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _clamp_percent(value: Any) -> float: + try: + numeric = float(value) + except (TypeError, ValueError): + return 0.0 + if numeric < 0: + numeric = 0.0 + if numeric > 100: + numeric = 100.0 + return round(numeric, 1) + + +def _read_proc_stat_cpu() -> tuple[int, int]: + """Return (idle_ticks, total_ticks) from Linux /proc/stat.""" + with _PROC_STAT.open("r", encoding="utf-8") as handle: + first = handle.readline().strip().split() + if not first or first[0] != "cpu": + raise RuntimeError("proc_stat_unavailable") + values = [int(part) for part in first[1:]] + if len(values) < 4: + raise RuntimeError("proc_stat_unavailable") + idle = values[3] + (values[4] if len(values) > 4 else 0) + total = sum(values) + if total <= 0: + raise RuntimeError("proc_stat_unavailable") + return idle, total + + +def _cpu_delta_percent(start: tuple[int, int], end: tuple[int, int]) -> float: + idle_delta = end[0] - start[0] + total_delta = end[1] - start[1] + if total_delta <= 0: + return 0.0 + busy_delta = max(0, total_delta - max(0, idle_delta)) + return _clamp_percent((busy_delta / total_delta) * 100.0) + + +def _cpu_percent() -> float: + """Sample aggregate CPU usage without psutil. + + A short local sample avoids storing cross-request state and returns a stable + percentage on the first poll. Unsupported platforms raise a safe error code. + """ + start = _read_proc_stat_cpu() + time.sleep(_CPU_SAMPLE_SECONDS) + end = _read_proc_stat_cpu() + return _cpu_delta_percent(start, end) + + +def _read_meminfo_kib() -> dict[str, int]: + data: dict[str, int] = {} + with _PROC_MEMINFO.open("r", encoding="utf-8") as handle: + for line in handle: + key, _, rest = line.partition(":") + if not key or not rest: + continue + parts = rest.strip().split() + if not parts: + continue + try: + data[key] = int(parts[0]) + except ValueError: + continue + return data + + +def _memory_usage() -> dict[str, int | float]: + meminfo = _read_meminfo_kib() + total = int(meminfo.get("MemTotal") or 0) * 1024 + if total <= 0: + raise RuntimeError("meminfo_unavailable") + available_kib = meminfo.get("MemAvailable") + if available_kib is None: + available_kib = ( + meminfo.get("MemFree", 0) + + meminfo.get("Buffers", 0) + + meminfo.get("Cached", 0) + + meminfo.get("SReclaimable", 0) + - meminfo.get("Shmem", 0) + ) + available = max(0, int(available_kib) * 1024) + used = max(0, min(total, total - available)) + return { + "used_bytes": used, + "total_bytes": total, + "percent": _clamp_percent((used / total) * 100.0), + } + + +def _disk_usage() -> dict[str, int | float]: + usage = shutil.disk_usage("/") + total = int(usage.total) + if total <= 0: + raise RuntimeError("disk_unavailable") + used = int(usage.used) + return { + "used_bytes": used, + "total_bytes": total, + "percent": _clamp_percent((used / total) * 100.0), + } + + +def _safe_error(metric: str, exc: Exception) -> dict[str, str]: + # Keep this intentionally coarse. Exception messages can contain local paths + # on unusual platforms; the browser only needs a safe unavailable reason. + return {"metric": metric, "code": type(exc).__name__} + + +def build_system_health_payload() -> dict[str, Any]: + metrics: dict[str, Any] = {"cpu": None, "memory": None, "disk": None} + errors: list[dict[str, str]] = [] + + collectors = { + "cpu": _cpu_percent, + "memory": _memory_usage, + "disk": _disk_usage, + } + for name, collect in collectors.items(): + try: + value = collect() + if name == "cpu": + metrics[name] = {"percent": _clamp_percent(value)} + else: + metrics[name] = { + "used_bytes": max(0, int(value["used_bytes"])), + "total_bytes": max(0, int(value["total_bytes"])), + "percent": _clamp_percent(value["percent"]), + } + except Exception as exc: + errors.append(_safe_error(name, exc)) + + available = any(metrics[name] is not None for name in metrics) + status = "ok" if available and not errors else "partial" if available else "unavailable" + return { + "status": status, + "available": available, + "checked_at": _checked_at(), + "cpu": metrics["cpu"], + "memory": metrics["memory"], + "disk": metrics["disk"], + "errors": errors, + } diff --git a/docs/pr-media/1688/chat-no-health-bar.png b/docs/pr-media/1688/chat-no-health-bar.png new file mode 100644 index 00000000..f79ee650 Binary files /dev/null and b/docs/pr-media/1688/chat-no-health-bar.png differ diff --git a/docs/pr-media/1688/insights-system-health.png b/docs/pr-media/1688/insights-system-health.png new file mode 100644 index 00000000..c9788835 Binary files /dev/null and b/docs/pr-media/1688/insights-system-health.png differ diff --git a/docs/pr-media/1690/scroll-preserved-after-completion.png b/docs/pr-media/1690/scroll-preserved-after-completion.png new file mode 100644 index 00000000..805245d0 Binary files /dev/null and b/docs/pr-media/1690/scroll-preserved-after-completion.png differ diff --git a/docs/pr-media/693/system-health-panel.png b/docs/pr-media/693/system-health-panel.png new file mode 100644 index 00000000..a228a346 Binary files /dev/null and b/docs/pr-media/693/system-health-panel.png differ diff --git a/static/boot.js b/static/boot.js index 62f3c8ab..8767dd67 100644 --- a/static/boot.js +++ b/static/boot.js @@ -815,7 +815,7 @@ $('modelSelect').onchange=async()=>{ : {model:selectedModel,model_provider:null}; if(typeof closeModelDropdown==='function') closeModelDropdown(); if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider); - else localStorage.setItem('hermes-webui-model', modelState.model); + else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{} await api('/api/session/update',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id, workspace:S.session.workspace, diff --git a/static/messages.js b/static/messages.js index 43ebda9d..c32d8fdc 100644 --- a/static/messages.js +++ b/static/messages.js @@ -932,7 +932,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // No-reply guard (#373): if agent returned nothing, show inline error if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});} if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); - syncTopbar();renderMessages();loadDir('.'); + syncTopbar();renderMessages({preserveScroll:true});loadDir('.'); // TTS auto-read: speak the last assistant response if enabled (#499) if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300); } @@ -1038,7 +1038,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'}); } _markSessionViewed(activeSid, S.messages.length); - renderMessages(); + renderMessages({preserveScroll:true}); }else if(typeof trackBackgroundError==='function'){ const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null; try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');} @@ -1113,13 +1113,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.messages=(data.session.messages||[]).filter(m=>m&&m.role); clearLiveToolCards();if(!assistantText)removeThinking(); _markSessionViewed(activeSid, data.session.message_count ?? S.messages.length); - renderMessages(); + renderMessages({preserveScroll:true}); } }catch(_){ // Fallback to local cancel message if API fails if(S.session&&S.session.session_id===activeSid){ clearLiveToolCards();if(!assistantText)removeThinking(); - S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages(); + S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages({preserveScroll:true}); _markSessionViewed(activeSid, S.messages.length); } } @@ -1169,7 +1169,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.toolCalls=[]; } if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length); - syncTopbar();renderMessages(); + syncTopbar();renderMessages({preserveScroll:true}); } _queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus(''); return true; @@ -1192,7 +1192,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(S.session&&S.session.session_id===activeSid){ S.activeStreamId=null; clearLiveToolCards();if(!assistantText)removeThinking(); - S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages(); + S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages({preserveScroll:true}); _markSessionViewed(activeSid, S.messages.length); }else{ if(typeof trackBackgroundError==='function'){ @@ -1223,7 +1223,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ removeThinking(); _queueDrainSid=activeSid;setBusy(false); setComposerStatus(''); - renderMessages(); + renderMessages({preserveScroll:true}); renderSessionList(); } return; @@ -1980,7 +1980,7 @@ function startBackgroundPolling(parentSid, taskId, prompt){ delete _bgPollTimers[taskId]; const msg={role:'assistant',content:`**${t('bg_label')}** ${prompt.slice(0,80)}\n\n${res.answer||t('bg_no_answer')}`,'_background':true,_ts:Date.now()/1000}; S.messages.push(msg); - renderMessages(); + renderMessages({preserveScroll:true}); showToast(t('bg_complete')); return; } diff --git a/static/onboarding.js b/static/onboarding.js index bd0f9650..85b71d4a 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -465,7 +465,7 @@ async function _saveOnboardingDefaults(){ if(ONBOARDING.status){ ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled}; } - localStorage.setItem('hermes-webui-model',model); + try{localStorage.setItem('hermes-webui-model',model)}catch{} if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect')); } diff --git a/static/panels.js b/static/panels.js index ea9a30aa..c07e6229 100644 --- a/static/panels.js +++ b/static/panels.js @@ -215,6 +215,7 @@ async function switchPanel(name, opts = {}) { if (nextPanel === 'insights') await loadInsights(); if (nextPanel === 'logs') await loadLogs(); _syncLogsAutoRefresh(); + if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility(); if (nextPanel === 'settings') { switchSettingsSection(_currentSettingsSection); loadSettingsPanel(); @@ -2118,6 +2119,8 @@ async function loadInsights(animate) { api('/api/wiki/status').catch(err => ({status:'error', error: err.message || String(err)})), ]); _renderInsights(data, box, wikiStatus); + if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility(); + if (typeof pollSystemHealth === 'function') void pollSystemHealth(); } catch(e) { box.innerHTML = `
${esc(t('error_prefix') + e.message)}
`; } finally { @@ -2134,6 +2137,34 @@ function _formatLlmWikiTimestamp(value) { catch (_) { return String(value); } } +function _renderSystemHealthPanel() { + return ` +
+
+
+
System health
+
Current VPS resource usage
+
+ Loading… +
+
+
+
CPU
+
+
+
+
RAM
+
+
+
+
Disk
+
+
+
+
Live snapshot only; historical resource charts can build on this surface later.
+
`; +} + function _renderLlmWikiStatus(d) { const status = d || {status:'error'}; const isReady = status.available && status.status === 'ready'; @@ -2279,6 +2310,7 @@ function _renderInsights(d, box, wikiStatus) { `; box.innerHTML = ` + ${_renderSystemHealthPanel()} ${_renderLlmWikiStatus(wikiStatus)}
${overviewCards.map(c => `
${c.icon}
${c.value}
${esc(c.label)}
`).join('')} diff --git a/static/style.css b/static/style.css index 6a9c70d0..d5e8fe61 100644 --- a/static/style.css +++ b/static/style.css @@ -293,6 +293,20 @@ .layout{display:flex;width:100%;flex:1 1 auto;min-height:0;} .app-titlebar{display:flex;align-items:center;justify-content:center;height:38px;flex-shrink:0;background:var(--sidebar);border-bottom:1px solid var(--border);padding:0 12px;padding-top:var(--app-titlebar-safe-top);padding-left:max(12px,env(safe-area-inset-left,0));padding-right:max(12px,env(safe-area-inset-right,0));box-sizing:content-box;font-size:12px;color:var(--muted);user-select:none;-webkit-app-region:drag;position:relative;z-index:20;} .app-titlebar-inner{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%;justify-content:center;} + .system-health-panel.insights-card{display:flex;flex-direction:column;gap:12px;color:var(--muted);} + .system-health-panel.unavailable{display:none;} + .system-health-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;} + .system-health-sub{font-size:11px;color:var(--muted);margin-top:-4px;} + .system-health-dot{width:7px;height:7px;border-radius:999px;background:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);opacity:.88;} + .system-health-panel.loading .system-health-dot{background:var(--muted);box-shadow:none;opacity:.55;} + .system-health-status{display:inline-flex;align-items:center;gap:7px;border-radius:999px;padding:3px 8px;font-size:11px;font-weight:700;border:1px solid var(--border);color:var(--muted);background:var(--surface);white-space:nowrap;} + .system-health-metrics{display:grid;grid-template-columns:repeat(3,minmax(120px,1fr));gap:10px;min-width:0;} + .system-health-metric{min-width:0;display:flex;flex-direction:column;gap:5px;padding:10px 11px;border:1px solid var(--border);border-radius:8px;background:var(--surface);} + .system-health-label{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;line-height:1;color:var(--muted);} + .system-health-value{font-variant-numeric:tabular-nums;color:var(--text);font-weight:650;} + .system-health-bar{height:5px;overflow:hidden;border-radius:999px;background:color-mix(in srgb,var(--border) 70%,transparent);border:1px solid color-mix(in srgb,var(--border) 75%,transparent);} + .system-health-bar-fill{height:100%;width:0%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-hover));transition:width .25s ease;} + .system-health-foot{font-size:11px;color:var(--muted);line-height:1.45;opacity:.82;} .app-titlebar-icon{display:inline-flex;align-items:center;color:var(--accent);} .app-titlebar-title{font-size:12px;font-weight:600;color:var(--text);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60vw;} .app-titlebar-sub{font-size:10px;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;font-family:'SF Mono',ui-monospace,monospace;white-space:nowrap;flex-shrink:0;} @@ -1280,6 +1294,11 @@ .app-titlebar{justify-content:space-between;} .app-titlebar-hamburger,.app-titlebar-spacer{display:flex;} .app-titlebar-inner{flex:1 1 auto;} + .system-health-panel.insights-card{gap:10px;padding:12px;} + .system-health-head{align-items:flex-start;} + .system-health-metrics{grid-template-columns:1fr;gap:8px;} + .system-health-label{font-size:10px;gap:4px;} + .system-health-bar{height:4px;} /* Overlay backdrop */ .mobile-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5); z-index:199;-webkit-tap-highlight-color:transparent;} diff --git a/static/ui.js b/static/ui.js index 2f00298c..72adb997 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3091,6 +3091,100 @@ function dismissReconnect() { clearInflight(); } +// ── Live host resource health panel (#693) ── +const SYSTEM_HEALTH_INTERVAL_MS=5000; +let _systemHealthTimer=null; +function _systemHealthPercent(metric){ + const percent=Number(metric&&metric.percent); + if(!Number.isFinite(percent)) return null; + return Math.max(0,Math.min(100,Math.round(percent*10)/10)); +} +function _formatSystemHealthPercent(percent){ + if(percent == null) return '—'; + return `${percent.toFixed(percent%1?1:0)}%`; +} +function _formatSystemHealthBytes(metric){ + if(!metric||!metric.used_bytes||!metric.total_bytes) return ''; + const units=['B','KB','MB','GB','TB']; + const fmt=(bytes)=>{ + let value=Number(bytes)||0, idx=0; + while(value>=1024&&idx=10||idx===0?0:1)} ${units[idx]}`; + }; + return `${fmt(metric.used_bytes)} / ${fmt(metric.total_bytes)}`; +} +function _updateSystemHealthMetric(name,metric){ + const row=document.querySelector(`[data-system-health-metric="${name}"]`); + if(!row) return; + const rawPercent=_systemHealthPercent(metric); + const percent=rawPercent == null ? 0 : rawPercent; + const label=row.querySelector('[data-system-health-value]'); + const bar=row.querySelector('.system-health-bar'); + const fill=row.querySelector('.system-health-bar-fill'); + const text=_formatSystemHealthPercent(rawPercent); + if(label){ + label.textContent=text; + const bytes=(name==='memory'||name==='disk')?_formatSystemHealthBytes(metric):''; + label.title=bytes||text; + } + if(bar) bar.setAttribute('aria-valuenow',String(percent)); + if(fill) fill.style.width=`${percent}%`; +} +function setSystemHealthUnavailable(message){ + const panel=$('systemHealthPanel'); + const status=$('systemHealthStatus'); + if(!panel) return; + panel.classList.remove('loading'); + panel.classList.add('unavailable'); + if(status) status.textContent=message||'Unavailable'; + ['cpu','memory','disk'].forEach(name=>_updateSystemHealthMetric(name,null)); +} +function renderSystemHealth(payload){ + const panel=$('systemHealthPanel'); + const status=$('systemHealthStatus'); + if(!panel) return; + if(!payload||payload.available===false){ + setSystemHealthUnavailable('Unavailable'); + return; + } + panel.classList.remove('loading','unavailable'); + if(status) status.textContent=payload.status==='partial'?'Partial':'Live'; + _updateSystemHealthMetric('cpu',payload.cpu); + _updateSystemHealthMetric('memory',payload.memory); + _updateSystemHealthMetric('disk',payload.disk); +} +async function pollSystemHealth(){ + if(document.visibilityState !== 'visible') return; + if(!_systemHealthPanelIsVisible()) return; + try{ + const payload=await api('/api/system/health'); + renderSystemHealth(payload); + }catch(_){ + setSystemHealthUnavailable('Unavailable'); + } +} +function _systemHealthPanelIsVisible(){ + return document.visibilityState === 'visible' && + !!document.querySelector('main.main.showing-insights') && + !!$('systemHealthPanel'); +} +function startSystemHealthMonitor(){ + if(!_systemHealthPanelIsVisible()) return; + if(_systemHealthTimer) return; + void pollSystemHealth(); + _systemHealthTimer=setInterval(pollSystemHealth,SYSTEM_HEALTH_INTERVAL_MS); +} +function stopSystemHealthMonitor(){ + if(_systemHealthTimer){clearInterval(_systemHealthTimer);_systemHealthTimer=null;} +} +function _syncSystemHealthMonitorVisibility(){ + if(_systemHealthPanelIsVisible()) startSystemHealthMonitor(); + else stopSystemHealthMonitor(); +} +document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility); +if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',startSystemHealthMonitor); +else startSystemHealthMonitor(); + // ── Hermes agent/gateway heartbeat alert (#716) ── const AGENT_HEALTH_INTERVAL_MS=30000; const AGENT_HEALTH_DISMISSED_KEY='agent-health-dismissed'; @@ -4014,7 +4108,23 @@ function clearMessageRenderCache(){ _sessionHtmlCacheSid=null; } -function renderMessages(){ +function _scrollAfterMessageRender(preserveScroll){ + // Terminal stream renders can happen after S.activeStreamId is cleared. + // In that case, preserveScroll asks the normal pin-state helper to decide: + // pinned users stay at bottom; users who manually scrolled up stay put. + if(preserveScroll){ + scrollIfPinned(); + return; + } + if(S.activeStreamId){ + scrollIfPinned(); + return; + } + scrollToBottom(); +} + +function renderMessages(options){ + const preserveScroll=!!(options&&options.preserveScroll); const inner=$('msgInner'); const sid=S.session?S.session.session_id:null; const msgCount=S.messages.length; @@ -4039,7 +4149,7 @@ function renderMessages(){ inner.innerHTML=cached.html; _sessionHtmlCacheSid=sid; _wireMessageWindowLoadEarlierButton(); - if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();} + _scrollAfterMessageRender(preserveScroll); requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver(); @@ -4574,11 +4684,7 @@ function renderMessages(){ // Only force-scroll when not actively streaming — mid-stream re-renders // (tool completion, session switch) must not override the user's scroll position. // scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up. - if(S.activeStreamId){ - scrollIfPinned(); - } else { - scrollToBottom(); - } + _scrollAfterMessageRender(preserveScroll); // Apply syntax highlighting after DOM is built requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); @@ -5701,7 +5807,11 @@ function _renderTreeItems(container, entries, depth){ // Name const nameEl=document.createElement('span'); - nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename'); + nameEl.className='file-name';nameEl.textContent=item.name; + // Tooltip only on FILES — dblclick renames them. On directories, dblclick + // navigates into the folder; rename lives in the right-click context menu + // (the "Double-click to rename" hint here would be misleading). #1710. + if(item.type!=='dir')nameEl.title=t('double_click_rename'); // Single-click opens (file) or expand-toggles (dir) but is debounced 300ms so a // double-click can cancel it and trigger rename instead. Without the debounce, the // click bubbles to el.onclick before dblclick can fire — that's #1698. Without the diff --git a/tests/test_1710_folder_tooltip.py b/tests/test_1710_folder_tooltip.py new file mode 100644 index 00000000..4ced9853 --- /dev/null +++ b/tests/test_1710_folder_tooltip.py @@ -0,0 +1,76 @@ +"""Tests for #1710 — file-tree tooltip says "Double-click to rename" on folders too, +but folders don't rename on double-click; they navigate via loadDir(). The tooltip +is therefore misleading on directory rows. + +Fix: gate the tooltip on `item.type !== 'dir'` so it appears only on files. +Folder rename is still reachable via the right-click context menu. +""" +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +UI_JS_PATH = REPO_ROOT / "static" / "ui.js" + + +def _read_ui_js() -> str: + with open(UI_JS_PATH, encoding="utf-8") as f: + return f.read() + + +def _name_block() -> str: + """Source slice covering the file-tree row's name span construction.""" + src = _read_ui_js() + start = src.find("// Name\n const nameEl=document.createElement('span');") + assert start >= 0, "name span construction marker not found in static/ui.js" + end = src.find("el.appendChild(nameEl);", start) + assert end >= 0, "el.appendChild(nameEl) not found after name span" + return src[start:end] + + +class TestFolderTooltipGated: + """The 'Double-click to rename' tooltip must only attach to files, not dirs.""" + + def test_tooltip_assignment_is_guarded_by_item_type(self): + block = _name_block() + # We expect the tooltip line to be wrapped in an `if(item.type!=='dir')` guard. + # The pre-fix shape was `nameEl.title=t('double_click_rename');` unconditionally. + # Find every line that assigns nameEl.title and confirm at least one is gated. + gated = "if(item.type!=='dir')nameEl.title=t('double_click_rename')" + unguarded = " nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename');" + assert gated in block, ( + "tooltip assignment must be guarded by `if(item.type!=='dir')` so directories " + "do not show the misleading 'Double-click to rename' hint (#1701)" + ) + assert unguarded not in block, ( + "the pre-fix unguarded tooltip assignment is still present — folders will " + "still show the misleading hint" + ) + + def test_dir_dblclick_still_navigates_not_renames(self): + """Sanity: directory dblclick path is unchanged — must still call loadDir().""" + block = _name_block() + assert "if(item.type==='dir'){loadDir(item.path);return;}" in block, ( + "directory dblclick must still navigate (call loadDir); the rename-only " + "tooltip gating depends on this contract being unchanged" + ) + + def test_files_still_get_tooltip(self): + """Sanity: the tooltip text is still defined for files via the i18n key.""" + block = _name_block() + assert "t('double_click_rename')" in block, ( + "tooltip i18n key must still be referenced — the gate hides it for dirs, " + "not for files" + ) + + def test_i18n_key_still_defined_in_all_locales(self): + """The i18n key must remain defined in every locale block in static/i18n.js.""" + i18n = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + # i18n.js has 9 locale blocks with the same key. Lock that the key still exists + # at least 5 times (en, plus a quorum of locales) — exact count is i18n maintenance. + count = i18n.count("double_click_rename:") + assert count >= 5, ( + f"i18n key 'double_click_rename' should be defined in multiple locales; " + f"found {count} occurrences — did this PR accidentally drop translations?" + ) diff --git a/tests/test_issue1690_scroll_completion.py b/tests/test_issue1690_scroll_completion.py new file mode 100644 index 00000000..21c202f9 --- /dev/null +++ b/tests/test_issue1690_scroll_completion.py @@ -0,0 +1,75 @@ +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + start = src.index(f"function {name}") + brace = src.index("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"function {name} body not found") + + +def _event_listener_body(src: str, event_name: str) -> str: + needle = f"source.addEventListener('{event_name}'" + start = src.index(needle) + brace = src.index("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"event listener {event_name!r} body not found") + + +def test_terminal_done_render_preserves_manual_scroll_after_active_stream_is_cleared(): + done_block = _event_listener_body(MESSAGES_JS, "done") + + clear_idx = done_block.index("S.activeStreamId=null") + render_idx = done_block.index("renderMessages({preserveScroll:true})") + + assert clear_idx < render_idx, ( + "the done handler should clear stream liveness before the final render, " + "but must pass preserveScroll so renderMessages does not infer bottom-pin " + "from S.activeStreamId alone" + ) + + +def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_liveness(): + render_body = _function_body(UI_JS, "renderMessages") + scroll_helper = _function_body(UI_JS, "_scrollAfterMessageRender") + + assert "function renderMessages(options)" in render_body + assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body + assert "_scrollAfterMessageRender(preserveScroll);" in render_body + assert "if(preserveScroll){\n scrollIfPinned();\n return;\n }" in scroll_helper + assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper + + +def test_cached_render_path_uses_same_scroll_policy_as_fresh_render(): + render_body = _function_body(UI_JS, "renderMessages") + cached_branch = render_body[render_body.index("if(sid&&sid!==_sessionHtmlCacheSid") : render_body.index("const compressionState=")] + + assert "_scrollAfterMessageRender(preserveScroll);" in cached_branch + assert "if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}" not in cached_branch + + +def test_session_switch_and_idle_session_load_keep_default_bottom_pin_behavior(): + load_session = _function_body(SESSIONS_JS, "loadSession") + idle_branch = load_session[load_session.index("}else{\n S.busy=false;") : load_session.index("// Sync context usage indicator")] + + assert "syncTopbar();renderMessages();" in idle_branch + assert "preserveScroll:true" not in idle_branch diff --git a/tests/test_issue677.py b/tests/test_issue677.py index faf3363d..09554e12 100644 --- a/tests/test_issue677.py +++ b/tests/test_issue677.py @@ -25,18 +25,23 @@ class TestScrollPinningFix: instead when S.activeStreamId is set. """ # Find renderMessages function - rm_start = UI_JS.find("function renderMessages()") + rm_start = UI_JS.find("function renderMessages(") assert rm_start != -1, "renderMessages() not found in ui.js" rm_end = UI_JS.find("\nfunction ", rm_start + 1) rm_body = UI_JS[rm_start:rm_end] + helper_start = UI_JS.find("function _scrollAfterMessageRender") + assert helper_start != -1, "renderMessages scroll helper not found in ui.js" + helper_end = UI_JS.find("\nfunction ", helper_start + 1) + helper_body = UI_JS[helper_start:helper_end] # Must check activeStreamId before deciding which scroll fn to call - assert "activeStreamId" in rm_body, ( + assert "activeStreamId" in helper_body, ( "renderMessages() must check S.activeStreamId before scrolling — " "unconditional scrollToBottom() overrides user scroll position (#677)" ) - # scrollIfPinned must be called inside renderMessages (stream path) - assert "scrollIfPinned()" in rm_body, ( + # scrollIfPinned must be called through the renderMessages scroll policy (stream path) + assert "_scrollAfterMessageRender(preserveScroll);" in rm_body + assert "scrollIfPinned()" in helper_body, ( "renderMessages() must call scrollIfPinned() during streaming (#677)" ) diff --git a/tests/test_issue693_system_health_panel.py b/tests/test_issue693_system_health_panel.py new file mode 100644 index 00000000..1161dc0a --- /dev/null +++ b/tests/test_issue693_system_health_panel.py @@ -0,0 +1,183 @@ +"""Regression coverage for #693 live VPS host resource health panel.""" + +from __future__ import annotations + +import json +import pathlib +from types import SimpleNamespace +from urllib.parse import urlparse + + +REPO_ROOT = pathlib.Path(__file__).parent.parent +UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") +PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") +INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8") +STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") +ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") +AUTH_PY = (REPO_ROOT / "api" / "auth.py").read_text(encoding="utf-8") + + +class _FakeHandler: + def __init__(self): + self.status = None + self.sent_headers = [] + self.body = bytearray() + self.wfile = self + self.headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.sent_headers.append((name, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def json_body(self): + return json.loads(bytes(self.body).decode("utf-8")) + + +def test_system_health_payload_normalizes_safe_aggregate_metrics(monkeypatch): + from api import system_health + + monkeypatch.setattr(system_health, "_cpu_percent", lambda: 17.345) + monkeypatch.setattr( + system_health, + "_memory_usage", + lambda: {"used_bytes": 4_000, "total_bytes": 10_000, "percent": 40.0}, + ) + monkeypatch.setattr( + system_health, + "_disk_usage", + lambda: {"used_bytes": 55_500, "total_bytes": 100_000, "percent": 55.5}, + ) + + payload = system_health.build_system_health_payload() + + assert payload["status"] == "ok" + assert payload["available"] is True + assert payload["cpu"] == {"percent": 17.3} + assert payload["memory"] == {"used_bytes": 4000, "total_bytes": 10000, "percent": 40.0} + assert payload["disk"] == {"used_bytes": 55500, "total_bytes": 100000, "percent": 55.5} + assert payload["checked_at"] + rendered = repr(payload) + for private_fragment in ("/home/", "/Users/", "mount", "path", "argv", "command", "env", "token"): + assert private_fragment not in rendered + + +def test_system_health_payload_partial_and_unavailable_are_graceful(monkeypatch): + from api import system_health + + def boom(): + raise RuntimeError("private /home/user/path should not leak") + + monkeypatch.setattr(system_health, "_cpu_percent", boom) + monkeypatch.setattr(system_health, "_memory_usage", boom) + monkeypatch.setattr( + system_health, + "_disk_usage", + lambda: {"used_bytes": 1, "total_bytes": 4, "percent": 25.0}, + ) + + partial = system_health.build_system_health_payload() + assert partial["status"] == "partial" + assert partial["available"] is True + assert partial["disk"]["percent"] == 25.0 + assert partial["cpu"] is None + assert partial["memory"] is None + assert {e["metric"] for e in partial["errors"]} == {"cpu", "memory"} + assert "/home/user" not in repr(partial) + + monkeypatch.setattr(system_health, "_disk_usage", boom) + unavailable = system_health.build_system_health_payload() + assert unavailable["status"] == "unavailable" + assert unavailable["available"] is False + assert unavailable["cpu"] is None + assert unavailable["memory"] is None + assert unavailable["disk"] is None + assert "/home/user" not in repr(unavailable) + + +def test_system_health_route_registered_and_auth_gated(monkeypatch): + assert 'parsed.path == "/api/system/health"' in ROUTES_PY + assert "build_system_health_payload()" in ROUTES_PY + assert '"/api/system/health"' not in AUTH_PY, "system metrics must not be public" + + monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password") + from api.auth import check_auth + + handler = _FakeHandler() + assert check_auth(handler, SimpleNamespace(path="/api/system/health", query="")) is False + assert handler.status in (302, 401) + + +def test_system_health_route_returns_only_sanitized_payload(monkeypatch): + from api import routes + + monkeypatch.setattr( + routes, + "build_system_health_payload", + lambda: { + "status": "ok", + "available": True, + "checked_at": "2026-05-05T00:00:00+00:00", + "cpu": {"percent": 12.0}, + "memory": {"used_bytes": 1, "total_bytes": 2, "percent": 50.0}, + "disk": {"used_bytes": 3, "total_bytes": 4, "percent": 75.0}, + "errors": [], + }, + ) + handler = _FakeHandler() + assert routes.handle_get(handler, urlparse("http://example.test/api/system/health")) is True + payload = handler.json_body() + assert payload["cpu"]["percent"] == 12.0 + assert set(payload) == {"status", "available", "checked_at", "cpu", "memory", "disk", "errors"} + + +def test_system_health_panel_markup_and_styles_live_under_insights_not_top_chrome(): + top_shell = INDEX_HTML[: INDEX_HTML.index('
')] + assert 'id="systemHealthPanel"' not in top_shell + assert 'aria-label="Host resource health"' not in top_shell + assert 'function _renderSystemHealthPanel()' in PANELS_JS + assert 'id="systemHealthPanel"' in PANELS_JS + assert 'aria-label="Host resource health"' in PANELS_JS + assert 'System health' in PANELS_JS + assert 'Current VPS resource usage' in PANELS_JS + assert PANELS_JS.index('_renderSystemHealthPanel()') < PANELS_JS.index('_renderLlmWikiStatus(wikiStatus)') + assert 'data-system-health-metric="cpu"' in PANELS_JS + assert 'data-system-health-metric="memory"' in PANELS_JS + assert 'data-system-health-metric="disk"' in PANELS_JS + assert ".system-health-panel.insights-card" in STYLE_CSS + assert ".system-health-bar-fill" in STYLE_CSS + assert ".system-health-panel.unavailable" in STYLE_CSS + assert "@media(max-width:640px)" in STYLE_CSS and ".system-health-panel.insights-card" in STYLE_CSS + + +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 "document.visibilityState !== 'visible'" in UI_JS + assert "document.querySelector('main.main.showing-insights')" in UI_JS + assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS + assert "typeof _syncSystemHealthMonitorVisibility === 'function'" in PANELS_JS + assert "function renderSystemHealth(payload)" in UI_JS + assert "setSystemHealthUnavailable" in UI_JS + assert "data-system-health-metric" in PANELS_JS + assert "CPU" in PANELS_JS and "RAM" in PANELS_JS and "Disk" in PANELS_JS + assert "aria-valuenow" in UI_JS + assert "style.width=`${percent}%`" in UI_JS + + +def test_system_health_backend_uses_no_shell_or_private_process_sources(): + src = (REPO_ROOT / "api" / "system_health.py").read_text(encoding="utf-8") + assert "import subprocess" not in src + assert "import psutil" not in src + assert "os.environ" not in src + assert "ps aux" not in src + assert "/proc/self/environ" not in src + for private_field in ("argv", "cmdline", "username", "mountpoint"): + assert private_field not in src diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index f0b59ae0..f93e2f6b 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -24,7 +24,7 @@ def test_load_earlier_expands_local_window_before_server_pagination_and_preserve def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_messages(): - assert "if(S.activeStreamId){\n scrollIfPinned();" in UI_JS + assert "_scrollAfterMessageRender(preserveScroll);" in UI_JS assert "const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS assert "if(aIdxa-b);" in UI_JS diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 1932223f..0262ee08 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -302,7 +302,7 @@ def test_hidden_active_done_still_updates_current_pane_but_not_read_state(): viewed_const_idx = done_block.find("const isSessionViewed=_isSessionActivelyViewed(activeSid);") active_guard_idx = done_block.find("if(isActiveSession){", viewed_const_idx) session_update_idx = done_block.find("S.session=d.session", active_guard_idx) - render_idx = done_block.find("renderMessages()", active_guard_idx) + render_idx = done_block.find("renderMessages(", active_guard_idx) load_dir_idx = done_block.find("loadDir('.')", active_guard_idx) mark_viewed_idx = done_block.find("if(isSessionViewed) _markSessionViewed(completedSid", active_guard_idx) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index fd49ae64..e2a6ff12 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -436,11 +436,12 @@ def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_session stream_end_idx = src.find("source.addEventListener('stream_end'", done_idx) assert stream_end_idx >= 0, "stream_end listener after done handler not found" done_block = src[done_idx:stream_end_idx] - # S.busy=false must appear before renderMessages() within the done handler + # S.busy=false must appear before the terminal render call within the done handler. busy_pos = done_block.find("S.busy=false;") - render_pos = done_block.find("renderMessages()") + render_pos = done_block.find("renderMessages(") assert busy_pos >= 0, "done handler must set S.busy=false before renderMessages()" - assert busy_pos < render_pos, f"S.busy=false (pos {busy_pos}) must come before renderMessages() (pos {render_pos})" + assert render_pos >= 0, "done handler must call renderMessages after settling state" + assert busy_pos < render_pos, f"S.busy=false (pos {busy_pos}) must come before renderMessages (pos {render_pos})" # ── R14: send() uses stale modelSelect.value instead of session model ────────