Merge pull request #1763 from nesquena/stage-308

v0.51.14 — 4-PR contributor batch (#1756, #1757, #1760, #1761)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-06 15:22:13 -07:00
commit 2106083e71
17 files changed
+696 -68

No files matched your search

+25
View File
@@ -1,5 +1,30 @@
# Hermes Web UI -- Changelog
## [v0.51.14] — 2026-05-06 — 4-PR contributor batch
### Fixed
- **PR #1760** by @ai-ag2026 — Preserve pending user turn on stream errors. Adds reconciliation in `api/streaming.py` so the user's pending turn is appended (with timestamp + attachments) BEFORE runtime state is cleared on `apperror`-no-response and outer-Exception paths. Reload + session reconcile now see the turn instead of losing it. Includes `_materialize_pending_user_turn_before_error()` helper with dedup against eager-checkpointed messages (8-message lookback, whitespace-normalized comparison). Closes #1361.
- **PR #1761** by @dso2ng — Scope terminal stream cleanup to owner session (refs #1694). Centralizes owner-only cleanup behind helpers (`_setActivePaneIdleIfOwner`, `_clearOwnerInflightState`, `_clearApprovalForOwner`, `_clearClarifyForOwner`) at SSE `done`/`error`/`cancel` event handlers in `static/messages.js`. Replaces inline 3-way OR guards introduced by PR #1753 (v0.51.12) with structured helper calls. The actual #1694 bug fix is in `_clearActivePaneInflightIfOwner`, which now gates `clearInflight()` on `_isActiveSession()` — previously unconditional, so a background completion would inadvertently clear the global `INFLIGHT_KEY` localStorage marker for the active pane. **Auto-fix applied**: PR's centralizing helper inadvertently dropped the `!INFLIGHT[S.session.session_id]` permissive-fallback disjunct from #1753; restored in `_setActivePaneIdleIfOwner` so the helper preserves the same 3-way OR contract Opus stage-306 verified.
- **PR #1756** by @ng-technology-llc — Isolate profile cookie per webui instance (closes #803). Adds `WEBUI_PROFILE_COOKIE_NAME` env var so multi-instance WebUI deployments can isolate the active-profile cookie per process. Default cookie name `hermes_profile` preserved when env var not set; backwards-compatible. `get_profile_cookie_name()` resolves per-request via `os.getenv()` so deployments can change the env var without restart (existing client cookies under the old name are treated as no cookie → user re-selects profile, no data loss).
- **PR #1757** by @skspade — Tri-state gateway status (closes earlier "gateway shows 'not running' when no platforms connected" reports). Replaces `bool(identity_map)` running signal with `agent_health.build_agent_health_payload()` as the authoritative source. Adds `alive: True/False/None` + `configured: bool` + `running: bool` fields. Frontend `static/panels.js` distinguishes three states: green "running" / amber "Gateway not configured" / red "not running". `build_agent_health_payload()` is robust to every failure (gateway import error, runtime status read exception, missing PID) — silently nulls and never raises. 247 LOC test coverage in `tests/test_gateway_status_agent_health.py`.
### Tests
4642 → **4662 collected** (+20 across 4 new test files plus regression coverage tightening). Includes 2 new structural-grep regression tests absorbed in-release per Opus advisor's NICE-TO-HAVE follow-ups: (1) `tests/test_sprint36.py` now asserts `_setActivePaneIdleIfOwner` body contains the `!INFLIGHT[...]` disjunct (catches the auto-fix repaired regression in #1761); (2) `tests/test_issue1361_cancel_data_loss.py` adds `test_materialize_helper_called_immediately_before_error_path_clears` to pin the helper call's call-site location in `api/streaming.py` error branches (catches future refactor that drops the call but keeps the clearing).
### Pre-release verification
- All 4 PRs CI-green individually (#1760, #1761) or rebased clean (#1756, #1757 — #1757 had stale base from before v0.51.10 stamps; CHANGELOG conflict auto-resolved by dropping the PR's redundant changelog entry, since we write the v0.51.14 entry at stamp time).
- Auto-fix on #1761 verified by 9-test pass before merge (5 invariants + 4 new ownership tests).
- `node -c` clean on both `static/messages.js` and `static/panels.js`.
- pytest: 4649 passed, 0 failed (single clean run, ~152s).
- `scripts/run-browser-tests.sh`: all 11 endpoints PASS on isolated port 8789.
- Pre-stamp re-fetch: all 4 PR heads still match local rebases — no late commits.
- Opus advisor: SHIP all 4, all 5 verification questions clean, 0 MUST-FIX, 0 SHOULD-FIX. Two NICE-TO-HAVE coverage gaps absorbed in-release as ~30 LOC of defensive structural-grep regression tests (covered above).
Closes #803, #1361, #1694.
## [v0.51.13] — 2026-05-06 — single-PR composer UX
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.51.13 (May 6, 2026) — 4642 tests collected — single-PR composer UX (#1758)
> Last updated: v0.51.14 (May 6, 2026) — 4662 tests collected — 4-PR contributor batch (#1756, #1757, #1760, #1761)
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
+2 -2
View File
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
*Last updated: v0.51.13, May 6, 2026*
*Total automated tests collected: 4642*
*Last updated: v0.51.14, May 6, 2026*
*Total automated tests collected: 4662*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
+16 -8
View File
@@ -2,6 +2,7 @@
Hermes Web UI -- HTTP helper functions.
"""
import json as _json
import os
import re as _re
from pathlib import Path
from api.config import IMAGE_EXTS, MD_EXTS
@@ -252,8 +253,13 @@ def read_body(handler) -> dict:
PROFILE_COOKIE_NAME = 'hermes_profile'
def get_profile_cookie_name() -> str:
"""Return the cookie name used to persist the active WebUI profile."""
return os.getenv('WEBUI_PROFILE_COOKIE_NAME', PROFILE_COOKIE_NAME)
def get_profile_cookie(handler) -> str | None:
"""Extract the hermes_profile cookie value from the request, or None."""
"""Extract the active-profile cookie value from the request, or None."""
cookie_header = handler.headers.get('Cookie', '')
if not cookie_header:
return None
@@ -263,7 +269,8 @@ def get_profile_cookie(handler) -> str | None:
cookie.load(cookie_header)
except _hc.CookieError:
return None
morsel = cookie.get(PROFILE_COOKIE_NAME)
cookie_name = get_profile_cookie_name()
morsel = cookie.get(cookie_name)
if morsel and morsel.value:
# Validate against profile-name pattern before trusting
from api.profiles import _PROFILE_ID_RE
@@ -274,7 +281,7 @@ def get_profile_cookie(handler) -> str | None:
def build_profile_cookie(name: str) -> str:
"""Build a Set-Cookie header value for the hermes_profile cookie.
"""Build a Set-Cookie header value for the active-profile cookie.
Always persist the selected profile in the cookie, including 'default'.
Clearing the cookie causes the backend to fall back to process-global
@@ -287,8 +294,9 @@ def build_profile_cookie(name: str) -> str:
"""
import http.cookies as _hc
cookie = _hc.SimpleCookie()
cookie[PROFILE_COOKIE_NAME] = name
cookie[PROFILE_COOKIE_NAME]['path'] = '/'
cookie[PROFILE_COOKIE_NAME]['httponly'] = True
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
return cookie[PROFILE_COOKIE_NAME].OutputString()
cookie_name = get_profile_cookie_name()
cookie[cookie_name] = name
cookie[cookie_name]['path'] = '/'
cookie[cookie_name]['httponly'] = True
cookie[cookie_name]['samesite'] = 'Lax'
return cookie[cookie_name].OutputString()
+29 -1
View File
@@ -3291,7 +3291,34 @@ def handle_get(handler, parsed) -> bool:
import datetime
identity_map = _load_gateway_session_identity_map()
sessions_path = _gateway_session_metadata_path()
running = bool(identity_map)
# Detect whether the gateway process is alive, independent of
# connected messaging platforms. An empty identity_map just
# means zero platforms connected, not that the gateway is down.
#
# agent_health.build_agent_health_payload() is the authoritative
# signal: it reads gateway.status runtime metadata and returns a
# tri-state `alive` field (True/False/None). This avoids the
# false-negative where the gateway is running but has zero active
# messaging sessions (empty identity_map).
#
# `alive` tri-state semantics:
# True → gateway process is alive
# False → gateway metadata exists but process is down
# None → no gateway metadata/status available; this WebUI
# setup is probably not configured with a gateway
health = build_agent_health_payload()
alive = health.get("alive")
if alive is True:
running = True
configured = True
elif alive is False:
running = False
configured = True
else: # alive is None → gateway not configured / unavailable
running = bool(identity_map)
configured = False
platforms_set: set[str] = set()
for meta in identity_map.values():
raw = meta.get("raw_source") or meta.get("platform") or ""
@@ -3318,6 +3345,7 @@ def handle_get(handler, parsed) -> bool:
pass
return j(handler, {
"running": running,
"configured": configured,
"platforms": platforms,
"last_active": last_active,
"session_count": len(identity_map),
+39
View File
@@ -1545,6 +1545,43 @@ def _sse(handler, event, data):
handler.wfile.flush()
def _materialize_pending_user_turn_before_error(session) -> bool:
"""Persist the pending user prompt before clearing runtime stream state.
Error paths often clear ``pending_user_message`` before appending an assistant
error marker. In deferred session-save mode that pending field can be the
only durable copy of the user's current turn, so clearing it makes the user
bubble disappear on reload/reconcile. Return True when a recovered user turn
was appended.
"""
pending_text = str(getattr(session, 'pending_user_message', None) or '')
if not pending_text:
return False
normalized_pending = " ".join(pending_text.split())
if normalized_pending:
for existing in reversed(list(getattr(session, 'messages', None) or [])[-8:]):
if not isinstance(existing, dict) or existing.get('role') != 'user':
continue
existing_text = " ".join(str(existing.get('content') or '').split())
if existing_text == normalized_pending:
return False
recovered_ts = int(time.time())
pending_started_at = getattr(session, 'pending_started_at', None)
if isinstance(pending_started_at, (int, float)) and pending_started_at > 0:
recovered_ts = int(pending_started_at)
recovered = {
'role': 'user',
'content': pending_text,
'timestamp': recovered_ts,
'_recovered': True,
}
pending_attachments = getattr(session, 'pending_attachments', None)
if pending_attachments:
recovered['attachments'] = list(pending_attachments)
session.messages.append(recovered)
return True
def _last_resort_sync_from_core(session, stream_id, agent_lock):
"""Final-exit guard: if the stream exits with pending_user_message still set,
sync messages from the core transcript or add an error marker.
@@ -2568,6 +2605,7 @@ def _run_agent_streaming(
# Persist the error so it survives page reload.
# _error=True ensures _sanitize_messages_for_api excludes it from
# subsequent API calls so the LLM never sees its own error as prior context.
_materialize_pending_user_turn_before_error(s)
s.active_stream_id = None
s.pending_user_message = None
s.pending_attachments = []
@@ -3029,6 +3067,7 @@ def _run_agent_streaming(
# API calls so the LLM never sees its own error as prior context on the next turn.
_lock_ctx = _agent_lock if _agent_lock is not None else contextlib.nullcontext()
with _lock_ctx:
_materialize_pending_user_turn_before_error(s)
s.active_stream_id = None
s.pending_user_message = None
s.pending_attachments = []
+58 -41
View File
@@ -357,6 +357,37 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
function _isActiveSession(){
return !!(S.session&&S.session.session_id===activeSid);
}
function _clearActivePaneInflightIfOwner(){
if(_isActiveSession()) clearInflight();
}
function _approvalBelongsToOwner(){
return _approvalSessionId===activeSid||(!_approvalSessionId&&_isActiveSession());
}
function _clarifyBelongsToOwner(){
return _clarifySessionId===activeSid||(!_clarifySessionId&&_isActiveSession());
}
function _clearApprovalForOwner(){
if(!_approvalBelongsToOwner()) return;
stopApprovalPolling();
hideApprovalCard(true);
}
function _clearClarifyForOwner(reason){
if(!_clarifyBelongsToOwner()) return;
stopClarifyPolling();
hideClarifyCard(true, reason||'terminal');
}
function _clearOwnerInflightState(){
delete INFLIGHT[activeSid];
clearInflightState(activeSid);
_clearActivePaneInflightIfOwner();
}
function _setActivePaneIdleIfOwner(){
if(_isActiveSession()||!S.session||!INFLIGHT[S.session.session_id]){
setBusy(false);
setComposerStatus('');
if(typeof setStatus==='function') setStatus('');
}
}
function persistInflightState(){
const inflight=INFLIGHT[activeSid];
if(!inflight||typeof saveInflightState!=='function') return;
@@ -852,15 +883,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
_markSessionCompletionUnread(completedSid, completedSession.message_count);
}
delete INFLIGHT[activeSid];
clearInflight();clearInflightState(activeSid);
_clearOwnerInflightState();
if(typeof _markSessionCompletedInList==='function'){
_markSessionCompletedInList(completedSession, activeSid);
}
stopApprovalPollingForSession(activeSid);
stopClarifyPollingForSession(activeSid);
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(isActiveSession){
S.activeStreamId=null;
}
@@ -944,13 +972,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// TTS auto-read: speak the last assistant response if enabled (#499)
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
if(isActiveSession) _queueDrainSid=activeSid;
renderSessionList();
if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id]){
_queueDrainSid=activeSid;
setBusy(false);
setStatus('');
setComposerStatus('');
}
_setActivePaneIdleIfOwner();
playNotificationSound();
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
});
@@ -1031,9 +1055,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
source.close();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
_clearOwnerInflightState();
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
@@ -1057,7 +1081,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
}
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
_setActivePaneIdleIfOwner();
renderSessionList(); // clear streaming indicator immediately on apperror
});
@@ -1109,9 +1133,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
source.close();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'cancelled');
_clearOwnerInflightState();
_clearApprovalForOwner();
_clearClarifyForOwner('cancelled');
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
}
@@ -1138,7 +1162,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
})();
renderSessionList();
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
_setActivePaneIdleIfOwner();
});
}
@@ -1148,10 +1172,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const session=data&&data.session;
if(!session) return false;
if(session.active_stream_id||session.pending_user_message) return false;
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
_clearOwnerInflightState();
_closeSource();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
const isSessionViewed=_isSessionActivelyViewed(activeSid);
const completedSid=session.session_id||activeSid;
if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
@@ -1185,12 +1209,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length);
syncTopbar();renderMessages({preserveScroll:true});
}
if(_isActiveSession()) _queueDrainSid=activeSid;
renderSessionList();
if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id]){
_queueDrainSid=activeSid;
setBusy(false);
setComposerStatus('');
}
_setActivePaneIdleIfOwner();
return true;
}catch(_){
return false;
@@ -1204,10 +1225,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
_clearOwnerInflightState();
_closeSource();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
@@ -1219,7 +1240,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
trackBackgroundError(activeSid,_errTitle,'Connection lost');
}
}
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
_setActivePaneIdleIfOwner();
}
(async()=>{
@@ -1229,19 +1250,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
try{
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(!st.active){
delete INFLIGHT[activeSid];
clearInflight();
clearInflightState(activeSid);
stopApprovalPollingForSession(activeSid);
stopClarifyPollingForSession(activeSid);
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
_clearOwnerInflightState();
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
clearLiveToolCards();
removeThinking();
_queueDrainSid=activeSid;setBusy(false);
setComposerStatus('');
if(_isActiveSession()) _queueDrainSid=activeSid;
_setActivePaneIdleIfOwner();
renderMessages({preserveScroll:true});
renderSessionList();
}
+4
View File
@@ -5413,6 +5413,10 @@ function loadGatewayStatus(){
if(!card) return;
api('/api/gateway/status').then(r=>{
if(!r) return;
if(!r.configured){
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;display:inline-block"></span>Gateway not configured</div>`;
return;
}
if(!r.running){
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>Gateway not running</div>`;
return;
@@ -0,0 +1,93 @@
"""Regression tests for #1694 terminal stream cleanup ownership.
Terminal SSE events for one session must not mutate another currently viewed
active pane. The owning session's persisted/runtime stream marker can be cleared,
but global pane state such as ``clearInflight()``, approval/clarify polling, and
``setBusy(false)`` must be gated to the session that owns the active pane/card.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
def _body_from_brace(src: str, brace: int, label: str) -> str:
assert brace >= 0, f"body opening brace not found for: {label}"
depth = 1
i = brace + 1
while i < len(src) and depth:
ch = src[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
i += 1
assert depth == 0, f"body did not close for: {label}"
return src[brace + 1 : i - 1]
def _brace_body_after(src: str, marker: str) -> str:
start = src.find(marker)
assert start >= 0, f"marker not found: {marker}"
brace = src.find("{", start)
return _body_from_brace(src, brace, marker)
def _event_body(event_name: str) -> str:
return _brace_body_after(MESSAGES_JS, f"source.addEventListener('{event_name}'")
def _function_body(name: str) -> str:
marker = f"function {name}("
start = MESSAGES_JS.find(marker)
assert start >= 0, f"function not found: {name}"
signature_end = MESSAGES_JS.find("){", start)
assert signature_end >= 0, f"function body not found: {name}"
return _body_from_brace(MESSAGES_JS, signature_end + 1, name)
def test_terminal_handlers_use_session_owned_cleanup_helpers():
"""Patch #1694 should centralize terminal cleanup behind owner-aware helpers."""
attach_body = _function_body("attachLiveStream")
assert "function _clearOwnerInflightState()" in attach_body
owner_helper = _function_body("_clearOwnerInflightState")
assert "delete INFLIGHT[activeSid]" in owner_helper
assert "clearInflightState(activeSid)" in owner_helper
assert "_clearActivePaneInflightIfOwner();" in owner_helper
assert "function _clearActivePaneInflightIfOwner()" in attach_body
assert "function _clearApprovalForOwner()" in attach_body
assert "function _clearClarifyForOwner(" in attach_body
assert "function _setActivePaneIdleIfOwner(" in attach_body
def test_done_event_does_not_clear_active_pane_for_background_session():
"""A background done event may clear its owner marker, not the active pane."""
body = _event_body("done")
assert "_clearOwnerInflightState();" in body
assert "clearInflight();clearInflightState(activeSid)" not in body
assert "delete INFLIGHT[activeSid];\n clearInflight();" not in body
assert "renderSessionList();setBusy(false)" not in body
assert "_setActivePaneIdleIfOwner" in body
def test_error_and_cancel_events_do_not_blanket_stop_active_pane_polling():
"""Background app errors/cancels must not stop another pane's prompt polling."""
for event_name in ("apperror", "cancel"):
body = _event_body(event_name)
assert "_clearOwnerInflightState();" in body, event_name
assert "_clearApprovalForOwner" in body, event_name
assert "_clearClarifyForOwner" in body, event_name
assert "stopApprovalPolling();stopClarifyPolling();" not in body, event_name
assert "clearInflight();clearInflightState(activeSid)" not in body, event_name
def test_reconnect_settled_and_error_paths_keep_cleanup_session_scoped():
"""Reconnect terminal cleanup paths should follow the same owner model."""
restore_body = _function_body("_restoreSettledSession")
error_body = _function_body("_handleStreamError")
combined = restore_body + "\n" + error_body
assert combined.count("_clearOwnerInflightState();") >= 2
assert "delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid)" not in combined
assert "stopApprovalPolling();stopClarifyPolling();" not in combined
assert "renderSessionList();setBusy(false)" not in combined
assert "_setActivePaneIdleIfOwner" in combined
+247
View File
@@ -0,0 +1,247 @@
"""Regression coverage: /api/gateway/status uses agent_health payload as
the authoritative 'running' signal (#d0568682 / parent review t_9098e3db).
Before the fix, the handler called gateway.status.get_running_pid() directly
and fell back to bool(identity_map) when the module was unavailable. The fix
makes it consult agent_health.build_agent_health_payload() so the tri-state
`alive` field is the single source of truth for gateway process health.
Tests use handle_get + monkeypatched build_agent_health_payload() and
_load_gateway_session_identity_map() to isolate the gateway status route
from real filesystem state.
"""
from __future__ import annotations
import json
from urllib.parse import urlparse
# ── FakeHandler (mirrors test_1560_password_env_var_no_op._FakeHandler) ────────
class _FakeHandler:
"""Minimal BaseHTTPRequestHandler stand-in for routes.handle_get."""
def __init__(self):
self.status = None
self.sent_headers: list[tuple[str, str]] = []
self.body = bytearray()
self.wfile = self
def send_response(self, code):
self.status = code
def send_header(self, key, value):
self.sent_headers.append((key, value))
def end_headers(self):
pass
def write(self, data):
"""Accumulate bytes written to wfile."""
self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8"))
def get_json(self):
"""Parse the accumulated body as JSON."""
return json.loads(self.body.decode("utf-8"))
# ── Helpers ──────────────────────────────────────────────────────────────────
def _call_gateway_status(monkeypatch, agent_health_alive, identity_map=None):
"""Invoke handle_get for /api/gateway/status and return the parsed JSON.
monkeypatches build_agent_health_payload to return the given `alive` value
and _load_gateway_session_identity_map to return the given identity_map.
"""
from api import routes
monkeypatch.setattr(
routes,
"build_agent_health_payload",
lambda: {
"alive": agent_health_alive,
"checked_at": "2026-05-06T12:00:00+00:00",
"details": {},
},
)
if identity_map is not None:
monkeypatch.setattr(
routes,
"_load_gateway_session_identity_map",
lambda: identity_map,
)
handler = _FakeHandler()
parsed = urlparse("http://example.com/api/gateway/status")
routes.handle_get(handler, parsed)
return handler.get_json()
# ── Acceptance criteria tests ─────────────────────────────────────────────────
def test_gateway_status_running_true_when_agent_health_alive_and_no_sessions(monkeypatch):
"""AC1: alive=true + empty identity_map → running=true, configured=true, platforms=[]"""
result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={})
assert result["running"] is True
assert result["configured"] is True
assert result["platforms"] == []
def test_gateway_status_running_false_when_agent_health_alive_false_and_no_sessions(monkeypatch):
"""AC2: alive=false + empty identity_map → running=false, configured=true, platforms=[]"""
result = _call_gateway_status(monkeypatch, agent_health_alive=False, identity_map={})
assert result["running"] is False
assert result["configured"] is True
assert result["platforms"] == []
def test_gateway_status_running_false_when_agent_health_alive_none_and_no_sessions(monkeypatch):
"""When alive=None (not configured): fall back to identity_map heuristic,
and set configured=false so frontend can show 'not configured' state."""
result = _call_gateway_status(monkeypatch, agent_health_alive=None, identity_map={})
assert result["running"] is False
assert result["configured"] is False
assert result["platforms"] == []
def test_gateway_status_running_true_and_platforms_when_agent_health_alive_and_sessions(monkeypatch):
"""AC3: alive=true + sessions with platforms → running=true, configured=true, platforms populated"""
identity_map = {
"sess_a": {"raw_source": "telegram", "platform": "telegram"},
"sess_b": {"raw_source": "discord", "platform": "discord"},
}
result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map=identity_map)
assert result["running"] is True
assert result["configured"] is True
assert len(result["platforms"]) == 2
names = {p["name"] for p in result["platforms"]}
assert names == {"telegram", "discord"}
# ── Edge case tests ───────────────────────────────────────────────────────────
def test_gateway_status_alive_none_falls_back_to_identity_map_heuristic(monkeypatch):
"""When alive=None (not configured) but sessions exist, running reflects identity_map.
configured=false tells the frontend to show 'not configured' state."""
from api import routes
monkeypatch.setattr(
routes,
"build_agent_health_payload",
lambda: {"alive": None, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}},
)
monkeypatch.setattr(
routes,
"_load_gateway_session_identity_map",
lambda: {"sess_c": {"raw_source": "telegram", "platform": "telegram"}},
)
handler = _FakeHandler()
parsed = urlparse("http://example.com/api/gateway/status")
routes.handle_get(handler, parsed)
result = handler.get_json()
# Fallback to identity_map: sessions exist → running=true
assert result["running"] is True
# But configured=false because alive was None (no gateway metadata)
assert result["configured"] is False
def test_gateway_status_handles_corrupted_sessions_json(monkeypatch):
"""Edge: sessions.json is corrupted → identity_map empty, rely on agent_health alone."""
from api import routes
monkeypatch.setattr(
routes,
"build_agent_health_payload",
lambda: {"alive": True, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}},
)
# _load_gateway_session_identity_map already returns {} on JSON parse failure;
# we monkeypatch it to return {} to simulate corrupted file.
monkeypatch.setattr(routes, "_load_gateway_session_identity_map", lambda: {})
handler = _FakeHandler()
parsed = urlparse("http://example.com/api/gateway/status")
routes.handle_get(handler, parsed)
result = handler.get_json()
assert result["running"] is True
assert result["platforms"] == []
assert result["session_count"] == 0
def test_gateway_status_blank_platform_fields_empty_platforms_running_true(monkeypatch):
"""Edge: sessions exist but all have blank/missing platform fields → platforms=[], running=true."""
from api import routes
monkeypatch.setattr(
routes,
"build_agent_health_payload",
lambda: {"alive": True, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}},
)
monkeypatch.setattr(
routes,
"_load_gateway_session_identity_map",
lambda: {
"sess_d": {"raw_source": "", "platform": ""},
"sess_e": {}, # no platform field at all
},
)
handler = _FakeHandler()
parsed = urlparse("http://example.com/api/gateway/status")
routes.handle_get(handler, parsed)
result = handler.get_json()
assert result["running"] is True
assert result["platforms"] == []
# ── Existing behavior preservation tests ──────────────────────────────────────
def test_gateway_status_running_false_when_agent_health_down_even_with_sessions(monkeypatch):
"""When agent_health says alive=false, running should be false regardless of sessions."""
from api import routes
monkeypatch.setattr(
routes,
"build_agent_health_payload",
lambda: {"alive": False, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}},
)
monkeypatch.setattr(
routes,
"_load_gateway_session_identity_map",
lambda: {"sess_f": {"raw_source": "telegram", "platform": "telegram"}},
)
handler = _FakeHandler()
parsed = urlparse("http://example.com/api/gateway/status")
routes.handle_get(handler, parsed)
result = handler.get_json()
# Running should be false even though sessions exist — agent_health is authoritative
assert result["running"] is False
# But configured=true because alive=False means gateway metadata exists
assert result["configured"] is True
# But platforms should still be extracted from sessions
assert len(result["platforms"]) == 1
assert result["platforms"][0]["name"] == "telegram"
def test_gateway_status_missing_r_field_handled_by_frontend(monkeypatch):
"""Edge: response always has 'running' and 'configured' fields.
Frontend handles missing field via catch block. This test verifies the backend
always includes both fields in responses."""
result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={})
assert "running" in result
assert "configured" in result
def test_gateway_status_last_active_empty_when_alive_and_no_sessions_path(monkeypatch):
"""Bonus: alive=true + identity_map={} → last_active is empty string.
This guards the 'if running and sessions_path.exists()' guard from being
silently removed in a future refactor that might expose a stale timestamp."""
result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={})
assert result["running"] is True
assert result["configured"] is True
# In test context, sessions_path won't exist (no real filesystem),
# so last_active must be empty.
assert result["last_active"] == ""
+92
View File
@@ -315,3 +315,95 @@ class TestCancelWithReasoningOnlyNoText:
f"Expected no partial msg when nothing was streamed. Got partials: {partial_msgs}"
assert len(cancel_msgs) == 1, \
f"Expected exactly 1 cancel marker. Got: {cancel_msgs}"
# ── §D: Error paths must not lose pending user turn ─────────────────────────
def test_stream_error_materializes_pending_user_turn_before_clearing_runtime_state():
"""If a stream errors before normal merge, pending_user_message must become a
durable user message before the error marker is saved. Otherwise reload/server
reconcile makes the user's just-submitted prompt disappear.
"""
from api.streaming import _materialize_pending_user_turn_before_error
sid = "test_pending_error_d1"
s = _make_session(
session_id=sid,
pending_msg="please restart the WebUI",
messages=[{"role": "assistant", "content": "previous answer"}],
)
s.pending_started_at = 1778098700.0
s.pending_attachments = [{"name": "screenshot.png"}]
appended = _materialize_pending_user_turn_before_error(s)
assert appended is True
assert s.messages[-1]["role"] == "user"
assert s.messages[-1]["content"] == "please restart the WebUI"
assert s.messages[-1]["timestamp"] == 1778098700
assert s.messages[-1]["attachments"] == [{"name": "screenshot.png"}]
assert s.pending_user_message == "please restart the WebUI"
def test_stream_error_pending_materialization_does_not_duplicate_eager_checkpoint():
"""Eager session-save mode may already have checkpointed the current user turn;
the error materializer must not append the same user message again.
"""
from api.streaming import _materialize_pending_user_turn_before_error
sid = "test_pending_error_d2"
s = _make_session(
session_id=sid,
pending_msg="please restart the WebUI",
messages=[
{"role": "assistant", "content": "previous answer"},
{"role": "user", "content": "please restart the WebUI"},
],
)
appended = _materialize_pending_user_turn_before_error(s)
assert appended is False
assert [m.get("role") for m in s.messages].count("user") == 1
# ── Structural guard: pin call sites of the materialize helper at error branches ──
def test_materialize_helper_called_immediately_before_error_path_clears():
"""Pin call sites of _materialize_pending_user_turn_before_error.
Catches a future refactor that drops the call from the apperror-no-response
or outer-Exception paths in api/streaming.py while leaving the
`pending_user_message = None` clearing in place — which is exactly the
user-turn-data-loss regression #1361 was filed for.
Strategy: count how many `pending_user_message = None` clearings have the
helper call within the preceding 4 lines. Currently 2 (apperror at 2610,
outer-Exception at 3072). The success path (2716) and cancel path (3375)
legitimately don't need the helper. If a future refactor drops the helper
call from one of the error sites, this assertion fires.
"""
from pathlib import Path
src = Path(__file__).parent.parent.joinpath('api', 'streaming.py').read_text(encoding='utf-8')
lines = src.splitlines()
helper_name = '_materialize_pending_user_turn_before_error('
clear_sites = [(i + 1, line) for i, line in enumerate(lines)
if 'pending_user_message = None' in line]
assert len(clear_sites) >= 4, (
f"Expected ≥4 sites that clear pending_user_message; found {len(clear_sites)}. "
f"If api/streaming.py was refactored, re-audit this test."
)
sites_with_helper = []
for lineno, _ in clear_sites:
prev_block = '\n'.join(lines[max(0, lineno - 5):lineno - 1])
if helper_name in prev_block:
sites_with_helper.append(lineno)
# Concretely, PR #1760 wired up the helper at the apperror-no-response
# path and the outer-Exception path. Both must remain wired.
assert len(sites_with_helper) >= 2, (
f"Expected ≥2 clear sites preceded by {helper_name} within 4 lines; "
f"found {sites_with_helper}. PR #1760 / #1361 regression — re-wire the "
f"helper at the error-branch clear sites in api/streaming.py."
)
+32
View File
@@ -73,6 +73,38 @@ class TestProfileCookieHelpers:
result = get_profile_cookie(handler)
assert result is None
def test_profile_cookie_name_defaults_to_hermes_profile(self, monkeypatch):
from api.helpers import build_profile_cookie
monkeypatch.delenv('WEBUI_PROFILE_COOKIE_NAME', raising=False)
s = build_profile_cookie('alice')
assert 'hermes_profile=alice' in s
def test_profile_cookie_name_can_be_isolated_per_webui_instance(self, monkeypatch):
from api.helpers import build_profile_cookie, get_profile_cookie
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_social')
s = build_profile_cookie('writer')
assert 'hermes_profile_social=writer' in s
assert 'hermes_profile=writer' not in s
handler = MagicMock()
handler.headers.get = lambda k, d='': (
'hermes_profile=wrong; hermes_profile_social=writer' if k == 'Cookie' else d
)
assert get_profile_cookie(handler) == 'writer'
def test_configured_profile_cookie_ignores_default_cookie_name(self, monkeypatch):
from api.helpers import get_profile_cookie
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_main')
handler = MagicMock()
handler.headers.get = lambda k, d='': 'hermes_profile=social_profile' if k == 'Cookie' else d
assert get_profile_cookie(handler) is None
# ── 2. Thread-local request context ──────────────────────────────────────────
@@ -74,17 +74,19 @@ def test_done_event_updates_sidebar_cache_immediately_after_completion_marker():
done_block = _done_block()
marker_idx = done_block.find("_markSessionCompletionUnread(completedSid")
delete_idx = done_block.find("delete INFLIGHT[activeSid];")
cleanup_idx = done_block.find("_clearOwnerInflightState();")
if cleanup_idx == -1:
cleanup_idx = done_block.find("delete INFLIGHT[activeSid];")
cache_idx = done_block.find("_markSessionCompletedInList(completedSession, activeSid);")
refresh_idx = done_block.find("renderSessionList();", cache_idx)
sound_idx = done_block.find("playNotificationSound();", cache_idx)
assert "function _markSessionCompletedInList(" in SESSIONS_JS
assert marker_idx != -1, "done handler must write the completion-unread marker first"
assert delete_idx != -1, "done handler must clear local INFLIGHT before rendering idle state"
assert cleanup_idx != -1, "done handler must clear local INFLIGHT before rendering idle state"
assert cache_idx != -1, "done handler must update the sidebar cache immediately"
assert refresh_idx != -1 and sound_idx != -1
assert marker_idx < delete_idx < cache_idx < refresh_idx < sound_idx, (
assert marker_idx < cleanup_idx < cache_idx < refresh_idx < sound_idx, (
"the sidebar should flip from spinner to dot from the done payload before "
"waiting for /api/sessions or playing the completion cue"
)
+7 -2
View File
@@ -248,8 +248,13 @@ def test_done_handler_guards_setbusy_with_inflight_check(cleanup_test_sessions):
disable B's Send button.
"""
src = (REPO_ROOT / "static/messages.js").read_text()
# The fix wraps setBusy(false) in a guard
assert "INFLIGHT[S.session.session_id]" in src, "messages.js must guard setBusy(false) with INFLIGHT check for current session"
# The fix wraps setBusy(false) in an active-pane ownership guard. Newer
# implementations may centralize the guard in a helper rather than repeat the
# raw INFLIGHT expression at every terminal event site.
assert (
"INFLIGHT[S.session.session_id]" in src
or "function _setActivePaneIdleIfOwner" in src
), "messages.js must guard setBusy(false) for the current session"
def test_refresh_handler_does_not_drop_tool_messages_needed_by_todos(cleanup_test_sessions):
@@ -63,9 +63,13 @@ class TestSessionOwnedRuntimeInvariants:
"A background session's done event must not unconditionally call setBusy(false); "
"that can idle an unrelated active pane that is still running."
)
assert "if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id])" in done.replace(" ", ""), (
"The done handler should only idle composer state when the completed stream "
"belongs to the active pane, or when no other active-pane inflight runtime exists."
normalized = done.replace(" ", "")
assert (
"if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id])" in normalized
or "_setActivePaneIdleIfOwner();" in done
), (
"The done handler should only idle composer state through an active-pane guard, "
"not from background completions owned by another session."
)
def test_server_session_finalize_does_not_idle_unrelated_active_pane(self):
@@ -75,7 +79,11 @@ class TestSessionOwnedRuntimeInvariants:
"The fallback server-finalize path must not idle the active pane for a "
"background session completion."
)
assert "if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id])" in finalize.replace(" ", ""), (
normalized = finalize.replace(" ", "")
assert (
"if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id])" in normalized
or "_setActivePaneIdleIfOwner();" in finalize
), (
"The fallback server-finalize path should use the same active-pane guard as the live done event."
)
@@ -96,8 +104,14 @@ class TestSessionOwnedRuntimeInvariants:
)
done = _event_handler(messages, "done")
assert "stopApprovalPollingForSession(activeSid)" in done
assert "stopClarifyPollingForSession(activeSid)" in done
assert (
"stopApprovalPollingForSession(activeSid)" in done
or "_clearApprovalForOwner();" in done
)
assert (
"stopClarifyPollingForSession(activeSid)" in done
or "_clearClarifyForOwner('terminal');" in done
)
assert "stopApprovalPolling();\n stopClarifyPolling();" not in done, (
"The done handler must not blindly stop whatever approval/clarify poller "
"the active pane currently owns."
+4 -2
View File
@@ -602,8 +602,10 @@ class TestClarifyCardTimerLogic:
src, re.DOTALL)
assert m, 'cancel event handler not found'
body = m.group(0)
assert "hideClarifyCard(true, 'cancelled')" in body, \
'explicit stream cancel must not use the timeout/terminal draft preservation path'
assert (
"hideClarifyCard(true, 'cancelled')" in body
or "_clearClarifyForOwner('cancelled')" in body
), 'explicit stream cancel must not use the timeout/terminal draft preservation path'
def test_clarify_urgent_countdown_has_non_color_cue(self):
css = self._get_css().read_text()
+22 -2
View File
@@ -166,9 +166,29 @@ def test_sse_cancel_handler_calls_set_busy():
# Find the closing of this handler block (next top-level addEventListener)
next_handler = src.find("source.addEventListener(", idx + 50)
block = src[idx:next_handler] if next_handler != -1 else src[idx:idx + 3000]
assert "setBusy(false)" in block, (
"SSE cancel handler no longer calls setBusy(false)"
assert (
"setBusy(false)" in block
or "_setActivePaneIdleIfOwner()" in block
), (
"SSE cancel handler no longer idles the owning active pane"
)
if "_setActivePaneIdleIfOwner()" in block:
helper_idx = src.find("function _setActivePaneIdleIfOwner")
assert helper_idx != -1
next_function = src.find("\n function ", helper_idx + 1)
helper = src[helper_idx:next_function if next_function != -1 else helper_idx + 800]
assert "setBusy(false)" in helper
# The helper MUST preserve the v0.51.12 (#1753) 3-way OR guard so
# idling the active pane on a background completion is gated on the
# permissive-fallback disjunct ("no other inflight on the active pane")
# in addition to "is active" / "no session". Without this, a user
# viewing pane A (idle) while pane B completes in the background
# would not get pane A's composer state cleared. Catches the exact
# regression v0.51.14's auto-fix repaired in PR #1761.
assert "!INFLIGHT[S.session.session_id]" in helper, (
"_setActivePaneIdleIfOwner must preserve the !INFLIGHT[...] "
"permissive-fallback disjunct from PR #1753 (v0.51.12)."
)
# ── 7. i18n key preserved ─────────────────────────────────────────────────────