fix: harden webui passive performance paths
This commit is contained in:
1 parent
e90ebd2394
commit
b53bc1a2ea
16 files changed
+358
-52
No files matched your search
@@ -125,6 +125,7 @@ Environment variables controlling behavior:
|
||||
HERMES_PREFILL_MESSAGES_FILE Optional JSON message list for browser-turn prefill context
|
||||
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or plain-text user prefill context
|
||||
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)
|
||||
HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS Optional parsed prefill budget in characters (default 12000, 0 disables)
|
||||
HERMES_HOME Base directory for Hermes state (~/.hermes by default)
|
||||
|
||||
Test isolation environment variables (set by conftest.py):
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Coalesced duplicate in-flight sidebar/project refreshes and suppressed overlapping approval/clarify fallback polls, reducing repeated passive requests while preserving latest-refresh-wins sidebar state.
|
||||
- Skipped duplicate composer-draft writes when the normalized autosave payload is unchanged, avoiding redundant full session JSON rewrites during debounced input/focus churn.
|
||||
- Refined empty Activity waiting placeholders to distinguish stream creation, first-token wait, post-tool model wait, and running-tool wait states.
|
||||
- Self-update restart safety now checks active agent runs as well as open SSE streams and waits for in-flight work before re-exec, avoiding update-triggered interruption when a run outlives its browser stream.
|
||||
- Documented the WebUI prefill context budget in README and architecture notes so operators can keep new-browser-turn startup context compact.
|
||||
|
||||
## [v0.51.163] — 2026-05-30 — Release EI (stage-batch45 — session duplicate/branch field propagation)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -158,9 +158,15 @@ a `messages` list, or plain text; plain text is wrapped as one `user` prefill
|
||||
message so dynamic recall text becomes ordinary context instead of an extra
|
||||
system instruction. If the hook must provide system-level guidance, emit JSON
|
||||
messages with an explicit `role: "system"` entry instead. Script output is capped
|
||||
at 256 KiB before parsing. The browser only receives a compact status event
|
||||
(`source`, `label`, message count, and redacted errors), never the prefill
|
||||
message bodies.
|
||||
at 256 KiB before parsing. Parsed prefill context is then bounded by
|
||||
`webui_prefill_context_max_chars` or `HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS`
|
||||
(default: 12,000 characters; set to `0` to disable). When a dynamic script
|
||||
exceeds the budget and a compact static prefill file is configured, WebUI falls
|
||||
back to that file. If no compact fallback is available, WebUI injects a short
|
||||
retrieval instruction instead of sending the oversized note/body payload with
|
||||
every new browser turn. The browser only receives a compact status event
|
||||
(`source`, `label`, message count, compaction metadata, and redacted errors),
|
||||
never the prefill message bodies.
|
||||
|
||||
### Optional Gateway-backed browser chat
|
||||
|
||||
|
||||
+21
-10
@@ -5637,19 +5637,30 @@ def handle_post(handler, parsed) -> bool:
|
||||
s = get_session(sid)
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
unchanged = False
|
||||
with _get_session_agent_lock(sid):
|
||||
draft = getattr(s, "composer_draft", {}) or {}
|
||||
current_draft = dict(getattr(s, "composer_draft", {}) or {})
|
||||
next_draft = dict(current_draft)
|
||||
if text is not None:
|
||||
draft["text"] = text
|
||||
next_draft["text"] = text
|
||||
if files is not None:
|
||||
draft["files"] = files
|
||||
s.composer_draft = draft
|
||||
# Draft persistence is not conversation activity. Touching updated_at
|
||||
# here makes the active-session external-refresh poll force-reload the
|
||||
# current chat every few seconds while the user is typing, and that
|
||||
# delayed reload can restore an older draft over newer local input.
|
||||
s.save(touch_updated_at=False, skip_index=True)
|
||||
return j(handler, {"ok": True, "draft": s.composer_draft})
|
||||
next_draft["files"] = files
|
||||
if next_draft == current_draft:
|
||||
unchanged = True
|
||||
saved_draft = current_draft
|
||||
else:
|
||||
s.composer_draft = next_draft
|
||||
# Draft persistence is not conversation activity. Touching updated_at
|
||||
# here makes the active-session external-refresh poll force-reload the
|
||||
# current chat every few seconds while the user is typing, and that
|
||||
# delayed reload can restore an older draft over newer local input.
|
||||
s.save(touch_updated_at=False, skip_index=True)
|
||||
saved_draft = s.composer_draft
|
||||
payload = {"ok": True, "draft": saved_draft}
|
||||
if unchanged:
|
||||
payload["unchanged"] = True
|
||||
j(handler, payload)
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/session/update":
|
||||
try:
|
||||
|
||||
+66
-16
@@ -60,32 +60,81 @@ def _sanitize_git_diagnostic(output: str, *, limit: int = _GIT_DIAGNOSTIC_MAX_CH
|
||||
return sanitized
|
||||
|
||||
|
||||
def _restart_blocker_snapshot() -> dict:
|
||||
"""Return active chat work that should block a self-restart."""
|
||||
with STREAMS_LOCK:
|
||||
stream_ids = [str(k) for k in STREAMS.keys()]
|
||||
run_ids: list[str] = []
|
||||
try:
|
||||
from api import config as _config
|
||||
active_runs = getattr(_config, 'ACTIVE_RUNS', {})
|
||||
active_runs_lock = getattr(_config, 'ACTIVE_RUNS_LOCK', None)
|
||||
if active_runs_lock is not None:
|
||||
with active_runs_lock:
|
||||
run_ids = [str(k) for k in active_runs.keys()]
|
||||
else:
|
||||
run_ids = [str(k) for k in active_runs.keys()]
|
||||
except Exception:
|
||||
run_ids = []
|
||||
return {
|
||||
'active_streams': len(stream_ids),
|
||||
'active_runs': len(run_ids),
|
||||
'blocking_stream_ids': stream_ids[:10],
|
||||
'blocking_run_ids': run_ids[:10],
|
||||
'restart_blocked': bool(stream_ids or run_ids),
|
||||
}
|
||||
|
||||
|
||||
def _active_stream_count() -> int:
|
||||
"""Return the current in-memory chat stream count.
|
||||
|
||||
Self-update schedules an in-process re-exec after git pull/reset. That is
|
||||
restart-equivalent for live streams, even when systemd does not see a unit
|
||||
restart. Refuse update/force-update while a stream exists so a browser
|
||||
update click cannot recreate the pending-message loss class fixed in #1543.
|
||||
Kept for compatibility with older tests/helpers; restart safety should use
|
||||
``_restart_blocker_snapshot()`` so detached worker runs also block updates.
|
||||
"""
|
||||
with STREAMS_LOCK:
|
||||
return len(STREAMS)
|
||||
return int(_restart_blocker_snapshot().get('active_streams') or 0)
|
||||
|
||||
|
||||
def _restart_blocked_response(target: str, active_streams: int) -> dict:
|
||||
plural = "s" if active_streams != 1 else ""
|
||||
def _restart_blocked_response(target: str, blocker_snapshot: dict | int) -> dict:
|
||||
if isinstance(blocker_snapshot, int):
|
||||
blocker_snapshot = {
|
||||
'active_streams': blocker_snapshot,
|
||||
'active_runs': 0,
|
||||
'blocking_stream_ids': [],
|
||||
'blocking_run_ids': [],
|
||||
'restart_blocked': bool(blocker_snapshot),
|
||||
}
|
||||
active_streams = int(blocker_snapshot.get('active_streams') or 0)
|
||||
active_runs = int(blocker_snapshot.get('active_runs') or 0)
|
||||
parts = []
|
||||
if active_streams:
|
||||
parts.append(f"{active_streams} active chat stream{'s' if active_streams != 1 else ''}")
|
||||
if active_runs:
|
||||
parts.append(f"{active_runs} active agent run{'s' if active_runs != 1 else ''}")
|
||||
detail = ' and '.join(parts) or 'active chat work'
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'Cannot update {target} while {active_streams} active chat stream{plural} '
|
||||
'is running. Wait for the response to finish, then retry the update.'
|
||||
f'Cannot update {target} while {detail} is running. '
|
||||
'Wait for the response to finish, then retry the update.'
|
||||
),
|
||||
'target': target,
|
||||
'restart_blocked': True,
|
||||
'active_streams': active_streams,
|
||||
'active_runs': active_runs,
|
||||
'blocking_stream_ids': blocker_snapshot.get('blocking_stream_ids') or [],
|
||||
'blocking_run_ids': blocker_snapshot.get('blocking_run_ids') or [],
|
||||
}
|
||||
|
||||
|
||||
def _wait_until_restart_safe(poll_seconds: float = 2.0) -> dict:
|
||||
"""Wait for active work to finish before self-reexec."""
|
||||
snapshot = _restart_blocker_snapshot()
|
||||
while snapshot.get('restart_blocked'):
|
||||
time.sleep(max(0.1, poll_seconds))
|
||||
snapshot = _restart_blocker_snapshot()
|
||||
return snapshot
|
||||
|
||||
|
||||
def _run_git(args, cwd, timeout=10):
|
||||
"""Run a git command and return (useful output, ok).
|
||||
|
||||
@@ -928,6 +977,7 @@ def _schedule_restart(delay: float = 2.0) -> None:
|
||||
# Threads die when execv replaces the process image, so the lock is
|
||||
# released atomically by the kernel.
|
||||
with _apply_lock:
|
||||
_wait_until_restart_safe()
|
||||
try:
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
except Exception:
|
||||
@@ -950,9 +1000,9 @@ def apply_force_update(target: str) -> dict:
|
||||
response with ``conflict: True`` or ``diverged: True`` and the user
|
||||
has confirmed they want to discard local changes.
|
||||
"""
|
||||
active_streams = _active_stream_count()
|
||||
if active_streams:
|
||||
return _restart_blocked_response(target, active_streams)
|
||||
blocker_snapshot = _restart_blocker_snapshot()
|
||||
if blocker_snapshot.get('restart_blocked'):
|
||||
return _restart_blocked_response(target, blocker_snapshot)
|
||||
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
return {'ok': False, 'message': 'Update already in progress'}
|
||||
@@ -1002,9 +1052,9 @@ def apply_force_update(target: str) -> dict:
|
||||
|
||||
def apply_update(target):
|
||||
"""Stash, pull --ff-only, pop for the given target repo."""
|
||||
active_streams = _active_stream_count()
|
||||
if active_streams:
|
||||
return _restart_blocked_response(target, active_streams)
|
||||
blocker_snapshot = _restart_blocker_snapshot()
|
||||
if blocker_snapshot.get('restart_blocked'):
|
||||
return _restart_blocked_response(target, blocker_snapshot)
|
||||
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
return {'ok': False, 'message': 'Update already in progress'}
|
||||
|
||||
+14
-2
@@ -564,6 +564,7 @@ async function send(){
|
||||
}
|
||||
streamId=startData.stream_id;
|
||||
S.activeStreamId = streamId;
|
||||
if(typeof appendThinking==='function') appendThinking('',{pending:true});
|
||||
// setBusy(true) already ran with activeStreamId=null; refresh now that we
|
||||
// have a stream id so the primary button can switch to Stop (see getComposerPrimaryAction).
|
||||
if(typeof updateSendBtn==='function') updateSendBtn();
|
||||
@@ -2507,6 +2508,7 @@ async function toggleYoloFromApproval() {
|
||||
|
||||
// ── Approval polling ──
|
||||
let _approvalPollTimer = null;
|
||||
let _approvalFallbackPollInFlight = false;
|
||||
let _approvalHideTimer = null;
|
||||
let _approvalVisibleSince = 0;
|
||||
let _approvalSignature = '';
|
||||
@@ -2707,11 +2709,14 @@ function _startApprovalFallbackPoll(sid) {
|
||||
if (!S.busy || !S.session || S.session.session_id !== sid) {
|
||||
stopApprovalPolling(); _hideApprovalCardIfOwner(sid, true); return;
|
||||
}
|
||||
if (_approvalFallbackPollInFlight) return;
|
||||
_approvalFallbackPollInFlight = true;
|
||||
try {
|
||||
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid));
|
||||
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
|
||||
if (data.pending) { showApprovalForSession(sid, data.pending, data.pending_count||1); }
|
||||
else { _clearApprovalPendingForSession(sid); _hideApprovalCardIfOwner(sid); }
|
||||
} catch(e) { /* ignore poll errors */ }
|
||||
finally { _approvalFallbackPollInFlight = false; }
|
||||
}, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
|
||||
}
|
||||
|
||||
@@ -2724,6 +2729,7 @@ function stopApprovalPolling() {
|
||||
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
|
||||
if (_approvalEventSource) { try { _approvalEventSource.close(); } catch(_){} _approvalEventSource = null; }
|
||||
if (_approvalSSEHealthTimer) { clearInterval(_approvalSSEHealthTimer); _approvalSSEHealthTimer = null; }
|
||||
_approvalFallbackPollInFlight = false;
|
||||
_approvalPollingSessionId = null;
|
||||
}
|
||||
|
||||
@@ -3197,6 +3203,7 @@ async function respondClarify(response) {
|
||||
var _clarifyEventSource = null;
|
||||
var _clarifyFallbackTimer = null;
|
||||
var _clarifyHealthTimer = null;
|
||||
let _clarifyFallbackPollInFlight = false;
|
||||
let _clarifyPollingSessionId = null;
|
||||
|
||||
function startClarifyPolling(sid) {
|
||||
@@ -3265,8 +3272,10 @@ function _startClarifyFallbackPoll(sid) {
|
||||
if (!S.session || S.session.session_id !== sid) {
|
||||
stopClarifyPolling(); _hideClarifyCardIfOwner(sid, true, 'session'); return;
|
||||
}
|
||||
if (_clarifyFallbackPollInFlight) return;
|
||||
_clarifyFallbackPollInFlight = true;
|
||||
try {
|
||||
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid));
|
||||
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
|
||||
if (data.pending) { showClarifyForSession(sid, data.pending); }
|
||||
else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); }
|
||||
} catch(e) {
|
||||
@@ -3279,6 +3288,8 @@ function _startClarifyFallbackPoll(sid) {
|
||||
}
|
||||
stopClarifyPolling();
|
||||
}
|
||||
} finally {
|
||||
_clarifyFallbackPollInFlight = false;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
@@ -3292,6 +3303,7 @@ function stopClarifyPolling() {
|
||||
if (_clarifyEventSource) { try { _clarifyEventSource.close(); } catch(_){} _clarifyEventSource = null; }
|
||||
if (_clarifyFallbackTimer) { clearInterval(_clarifyFallbackTimer); _clarifyFallbackTimer = null; }
|
||||
if (_clarifyHealthTimer) { clearInterval(_clarifyHealthTimer); _clarifyHealthTimer = null; }
|
||||
_clarifyFallbackPollInFlight = false;
|
||||
_clarifyPollingSessionId = null;
|
||||
}
|
||||
|
||||
|
||||
+43
-2
@@ -2098,6 +2098,8 @@ window.addEventListener('resize',()=>{
|
||||
// concurrently. Without this guard, a slower older response can overwrite _allSessions
|
||||
// with stale data, causing sessions to vanish from the sidebar.
|
||||
let _renderSessionListGen = 0;
|
||||
let _renderSessionListInFlight = null;
|
||||
let _renderSessionListQueuedRequest = null;
|
||||
let _sessionListRefreshAnimationPending = false;
|
||||
let _sessionListFirstRenderAnimated = false;
|
||||
let _sessionListEnterAllAnimationPending = false;
|
||||
@@ -2292,9 +2294,17 @@ function _applySessionListPayload(sessData, projData){
|
||||
renderSessionListFromCache(); // no-ops if rename is in progress
|
||||
}
|
||||
|
||||
async function renderSessionList(opts={}){
|
||||
function _mergeRenderSessionListOptions(prev, next){
|
||||
const merged={...(prev||{}),...(next||{})};
|
||||
// Immediate refreshes must not be downgraded by a later passive polling tick.
|
||||
if((prev&&prev.deferWhileInteracting===false)||(next&&next.deferWhileInteracting===false)){
|
||||
merged.deferWhileInteracting=false;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function _runRenderSessionListRefresh(opts, _gen){
|
||||
const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);
|
||||
const _gen = ++_renderSessionListGen;
|
||||
if(!deferWhileInteracting) _pendingSessionListPayload=null;
|
||||
try{
|
||||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||||
@@ -2314,6 +2324,37 @@ async function renderSessionList(opts={}){
|
||||
}catch(e){console.warn('renderSessionList',e);}
|
||||
}
|
||||
|
||||
async function _drainRenderSessionListQueue(initialRequest){
|
||||
let request=initialRequest;
|
||||
try{
|
||||
while(request){
|
||||
await _runRenderSessionListRefresh(request.opts, request.gen);
|
||||
request=_renderSessionListQueuedRequest;
|
||||
_renderSessionListQueuedRequest=null;
|
||||
}
|
||||
}finally{
|
||||
_renderSessionListInFlight=null;
|
||||
if(_renderSessionListQueuedRequest){
|
||||
const next=_renderSessionListQueuedRequest;
|
||||
_renderSessionListQueuedRequest=null;
|
||||
_renderSessionListInFlight=_drainRenderSessionListQueue(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSessionList(opts={}){
|
||||
const request={opts:opts||{},gen:++_renderSessionListGen};
|
||||
if(_renderSessionListInFlight){
|
||||
_renderSessionListQueuedRequest={
|
||||
opts:_mergeRenderSessionListOptions(_renderSessionListQueuedRequest&&_renderSessionListQueuedRequest.opts, request.opts),
|
||||
gen:request.gen,
|
||||
};
|
||||
return _renderSessionListInFlight;
|
||||
}
|
||||
_renderSessionListInFlight=_drainRenderSessionListQueue(request);
|
||||
return _renderSessionListInFlight;
|
||||
}
|
||||
|
||||
// ── Gateway session SSE (real-time sync for agent sessions) ──
|
||||
let _gatewaySSE = null;
|
||||
let _gatewayPollTimer = null;
|
||||
|
||||
+17
-4
@@ -7996,10 +7996,23 @@ function appendThinking(text='', options){
|
||||
const body=group&&group.querySelector('.tool-call-group-body');
|
||||
if(!body) return;
|
||||
if(!cleanThinking||cleanThinking==='Thinking…'){
|
||||
const label=body.querySelector('.tool-card.tool-card-running')?'Waiting on tool result':'Waiting on model';
|
||||
const detail=body.querySelector('.tool-card-row')
|
||||
? 'The agent is running; tool results and response text will appear here.'
|
||||
: 'No tool activity has been reported yet.';
|
||||
const hasRunningTool=!!body.querySelector('.tool-card.tool-card-running');
|
||||
const hasToolCard=!!body.querySelector('.tool-card-row');
|
||||
let label;
|
||||
let detail;
|
||||
if(!S.activeStreamId && options && options.pending){
|
||||
label='Starting agent';
|
||||
detail='Creating the stream and sending your message…';
|
||||
}else if(hasRunningTool){
|
||||
label='Waiting on tool result';
|
||||
detail='The tool is still running; the response will continue after it completes.';
|
||||
}else if(hasToolCard){
|
||||
label='Waiting on model';
|
||||
detail='Tool finished; waiting for the model to continue.';
|
||||
}else{
|
||||
label='Waiting for first model token';
|
||||
detail='Stream connected; no model output has arrived yet.';
|
||||
}
|
||||
_appendActivityEvent(group,{id:'thinking-placeholder',kind:'waiting',label,detail,status:'waiting',ts:_activityNowSeconds()});
|
||||
const active=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
if(active) active.removeAttribute('data-thinking-active');
|
||||
|
||||
@@ -221,6 +221,7 @@ def test_passive_background_polls_suppress_timeout_toasts():
|
||||
"""Passive refreshes should be best-effort and not emit generic timeout toasts."""
|
||||
workspace = _source(WORKSPACE_JS)
|
||||
sessions = _source(SESSIONS_JS)
|
||||
messages = _source(ROOT / "static" / "messages.js")
|
||||
ui = _source(UI_JS)
|
||||
panels = _source(PANELS_JS)
|
||||
|
||||
@@ -228,6 +229,8 @@ def test_passive_background_polls_suppress_timeout_toasts():
|
||||
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in sessions
|
||||
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in sessions
|
||||
assert "api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false})" in sessions
|
||||
assert 'api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false})' in messages
|
||||
assert 'api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false})' in messages
|
||||
assert "api('/api/dashboard/status',{timeoutToast:false})" in ui
|
||||
assert "api('/api/system/health',{timeoutToast:false})" in ui
|
||||
assert "api('/api/health/agent',{timeoutToast:false})" in ui
|
||||
|
||||
@@ -25,15 +25,17 @@ def test_session_list_disables_browser_scroll_anchoring():
|
||||
|
||||
def test_polling_payloads_are_deferred_while_user_scrolls_sidebar():
|
||||
render_block = _block("async function renderSessionList", "// ── Gateway session SSE")
|
||||
apply_block = _block("function _applySessionListPayload", "async function renderSessionList")
|
||||
refresh_block = _block("async function _runRenderSessionListRefresh", "async function _drainRenderSessionListQueue")
|
||||
apply_block = _block("function _applySessionListPayload", "function _mergeRenderSessionListOptions")
|
||||
|
||||
assert "function _isSessionListUserInteracting()" in SESSIONS_JS
|
||||
assert "async function renderSessionList(opts={})" in render_block
|
||||
assert "const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);" in render_block
|
||||
assert "if(deferWhileInteracting&&_isSessionListUserInteracting())" in render_block
|
||||
assert "_pendingSessionListPayload={gen:_gen,sessData,projData};" in render_block
|
||||
assert "_schedulePendingSessionListApply();" in render_block
|
||||
assert "_applySessionListPayload(sessData,projData);" in render_block
|
||||
assert "async function _runRenderSessionListRefresh(opts, _gen)" in refresh_block
|
||||
assert "const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);" in refresh_block
|
||||
assert "if(deferWhileInteracting&&_isSessionListUserInteracting())" in refresh_block
|
||||
assert "_pendingSessionListPayload={gen:_gen,sessData,projData};" in refresh_block
|
||||
assert "_schedulePendingSessionListApply();" in refresh_block
|
||||
assert "_applySessionListPayload(sessData,projData);" in refresh_block
|
||||
assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block, (
|
||||
"deferring sidebar refreshes must preserve background-completion unread semantics"
|
||||
)
|
||||
@@ -41,9 +43,9 @@ def test_polling_payloads_are_deferred_while_user_scrolls_sidebar():
|
||||
|
||||
def test_deferred_payloads_keep_generation_stale_response_guard():
|
||||
schedule_apply_block = _block("function _schedulePendingSessionListApply", "function _applySessionListPayload")
|
||||
render_block = _block("async function renderSessionList", "// ── Gateway session SSE")
|
||||
refresh_block = _block("async function _runRenderSessionListRefresh", "async function _drainRenderSessionListQueue")
|
||||
|
||||
assert "if(!deferWhileInteracting) _pendingSessionListPayload=null;" in render_block, (
|
||||
assert "if(!deferWhileInteracting) _pendingSessionListPayload=null;" in refresh_block, (
|
||||
"explicit/user-initiated refreshes must clear older deferred background payloads"
|
||||
)
|
||||
assert "payload.gen!==_renderSessionListGen" in schedule_apply_block, (
|
||||
|
||||
@@ -121,7 +121,9 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
|
||||
)
|
||||
render_idx = SESSIONS_JS.find("async function renderSessionList")
|
||||
assert render_idx != -1, "renderSessionList not found"
|
||||
render_block = SESSIONS_JS[render_idx:SESSIONS_JS.find("// ── Gateway session SSE", render_idx)]
|
||||
refresh_idx = SESSIONS_JS.find("async function _runRenderSessionListRefresh")
|
||||
assert refresh_idx != -1, "_runRenderSessionListRefresh not found"
|
||||
refresh_block = SESSIONS_JS[refresh_idx:SESSIONS_JS.find("async function _drainRenderSessionListQueue", refresh_idx)]
|
||||
|
||||
apply_idx = SESSIONS_JS.find("function _applySessionListPayload(")
|
||||
assert apply_idx != -1, "_applySessionListPayload not found"
|
||||
@@ -136,7 +138,7 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
|
||||
)
|
||||
assert "_markSessionCompletionUnread(sid, s.message_count);" in transition_block
|
||||
assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block
|
||||
assert "_applySessionListPayload(sessData,projData);" in render_block
|
||||
assert "_applySessionListPayload(sessData,projData);" in refresh_block
|
||||
assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import pathlib
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -23,12 +24,27 @@ def test_live_activity_group_has_observable_baseline_events():
|
||||
|
||||
def test_empty_thinking_placeholder_becomes_status_row_not_raw_thinking_card():
|
||||
assert "data-activity-event-id=\"thinking-placeholder\"" in UI_JS
|
||||
assert "Starting agent" in UI_JS
|
||||
assert "Creating the stream and sending your message…" in UI_JS
|
||||
assert "Waiting for first model token" in UI_JS
|
||||
assert "Stream connected; no model output has arrived yet." in UI_JS
|
||||
assert "Waiting on model" in UI_JS
|
||||
assert "No tool activity has been reported yet." in UI_JS
|
||||
assert "Tool finished; waiting for the model to continue." in UI_JS
|
||||
assert "Waiting on tool result" in UI_JS
|
||||
assert "The tool is still running; the response will continue after it completes." in UI_JS
|
||||
assert "_thinkingActivityNode(thinkingText, false)" in UI_JS
|
||||
|
||||
|
||||
def test_stream_start_refreshes_waiting_status_after_stream_id_arrives():
|
||||
active_idx = MESSAGES_JS.find("S.activeStreamId = streamId;")
|
||||
assert active_idx != -1
|
||||
refresh_idx = MESSAGES_JS.find("appendThinking('',{pending:true})", active_idx)
|
||||
attach_idx = MESSAGES_JS.find("attachLiveStream(activeSid, streamId, uploadedNames);", active_idx)
|
||||
assert refresh_idx != -1
|
||||
assert attach_idx != -1
|
||||
assert refresh_idx < attach_idx
|
||||
|
||||
|
||||
def test_tool_events_update_activity_timeline_and_summary():
|
||||
assert "Tool finished: ${toolName}" in UI_JS
|
||||
assert "Running tool: ${toolName}" in UI_JS
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Static regressions for frontend passive polling hardening."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_JS = ROOT / "static" / "sessions.js"
|
||||
MESSAGES_JS = ROOT / "static" / "messages.js"
|
||||
|
||||
|
||||
def _source(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_session_list_refreshes_are_coalesced_while_in_flight():
|
||||
src = _source(SESSIONS_JS)
|
||||
assert "let _renderSessionListInFlight = null" in src
|
||||
assert "let _renderSessionListQueuedRequest = null" in src
|
||||
assert "async function _runRenderSessionListRefresh" in src
|
||||
assert "async function _drainRenderSessionListQueue" in src
|
||||
assert "const request={opts:opts||{},gen:++_renderSessionListGen}" in src
|
||||
assert "if(_renderSessionListInFlight)" in src
|
||||
assert "_renderSessionListQueuedRequest={" in src
|
||||
assert "opts:_mergeRenderSessionListOptions" in src
|
||||
assert "if (_gen !== _renderSessionListGen) return" in src
|
||||
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in src
|
||||
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in src
|
||||
|
||||
|
||||
def test_approval_and_clarify_fallback_polls_do_not_overlap():
|
||||
src = _source(MESSAGES_JS)
|
||||
assert "let _approvalFallbackPollInFlight = false" in src
|
||||
assert "if (_approvalFallbackPollInFlight) return" in src
|
||||
assert "_approvalFallbackPollInFlight = true" in src
|
||||
assert "finally { _approvalFallbackPollInFlight = false; }" in src
|
||||
assert "_approvalFallbackPollInFlight = false;\n _approvalPollingSessionId = null;" in src
|
||||
|
||||
assert "let _clarifyFallbackPollInFlight = false" in src
|
||||
assert "if (_clarifyFallbackPollInFlight) return" in src
|
||||
assert "_clarifyFallbackPollInFlight = true" in src
|
||||
assert "finally {\n _clarifyFallbackPollInFlight = false;\n }" in src
|
||||
assert "_clarifyFallbackPollInFlight = false;\n _clarifyPollingSessionId = null;" in src
|
||||
@@ -81,7 +81,7 @@ def test_draft_validation_appears_before_persist():
|
||||
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
# Anchor on the unique POST-validation comment marker.
|
||||
marker_idx = src.find("Stage-326 hardening (per Opus advisor)")
|
||||
persist_idx = src.find("s.composer_draft = draft\n # Draft persistence is not conversation activity")
|
||||
persist_idx = src.find("s.composer_draft = next_draft\n # Draft persistence is not conversation activity")
|
||||
assert marker_idx != -1 and persist_idx != -1, (
|
||||
"could not locate validation marker or persist site"
|
||||
)
|
||||
@@ -98,7 +98,21 @@ def test_draft_save_does_not_touch_session_updated_at():
|
||||
update and force-reloads the current chat a few seconds later.
|
||||
"""
|
||||
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
persist_idx = src.find("s.composer_draft = draft")
|
||||
persist_idx = src.find("s.composer_draft = next_draft")
|
||||
assert persist_idx != -1, "could not locate composer draft persist site"
|
||||
save_idx = src.find("s.save(touch_updated_at=False, skip_index=True)", persist_idx)
|
||||
assert save_idx != -1, "composer draft save must preserve session updated_at and skip index churn"
|
||||
|
||||
|
||||
def test_draft_save_skips_unchanged_payload_before_persist():
|
||||
"""Duplicate debounced draft POSTs should not rewrite the full session JSON."""
|
||||
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
draft_idx = src.find('current_draft = dict(getattr(s, "composer_draft", {}) or {})')
|
||||
unchanged_idx = src.find("if next_draft == current_draft", draft_idx)
|
||||
save_idx = src.find("s.save(touch_updated_at=False, skip_index=True)", draft_idx)
|
||||
|
||||
assert draft_idx != -1, "draft route should snapshot current composer_draft"
|
||||
assert unchanged_idx != -1, "draft route should no-op unchanged normalized payloads"
|
||||
assert save_idx != -1, "draft route should still save changed drafts"
|
||||
assert unchanged_idx < save_idx, "unchanged guard must run before full session save"
|
||||
assert 'payload["unchanged"] = True' in src
|
||||
@@ -426,6 +426,90 @@ class TestApplyUpdateRestartSafety:
|
||||
assert result.get('restart_blocked') is True
|
||||
assert 'active chat stream' in result['message']
|
||||
|
||||
def test_apply_update_refuses_when_active_run_without_stream(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
from api.config import ACTIVE_RUNS, ACTIVE_RUNS_LOCK, STREAMS, STREAMS_LOCK
|
||||
|
||||
(tmp_path / '.git').mkdir()
|
||||
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
||||
called = []
|
||||
monkeypatch.setattr(upd, '_run_git', lambda *a, **k: (called.append(a) or ('', True)))
|
||||
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: (_ for _ in ()).throw(AssertionError('must not restart')))
|
||||
|
||||
with STREAMS_LOCK:
|
||||
old_streams = dict(STREAMS)
|
||||
STREAMS.clear()
|
||||
with ACTIVE_RUNS_LOCK:
|
||||
old_runs = dict(ACTIVE_RUNS)
|
||||
ACTIVE_RUNS.clear()
|
||||
ACTIVE_RUNS['run_active'] = {'session_id': 's1', 'stream_id': 'missing-stream'}
|
||||
try:
|
||||
result = upd.apply_update('webui')
|
||||
finally:
|
||||
with ACTIVE_RUNS_LOCK:
|
||||
ACTIVE_RUNS.clear()
|
||||
ACTIVE_RUNS.update(old_runs)
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.clear()
|
||||
STREAMS.update(old_streams)
|
||||
|
||||
assert result['ok'] is False
|
||||
assert result.get('active_streams') == 0
|
||||
assert result.get('active_runs') == 1
|
||||
assert result.get('restart_blocked') is True
|
||||
assert 'active agent run' in result['message']
|
||||
assert called == []
|
||||
|
||||
def test_force_update_refuses_when_active_run_without_stream(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
from api.config import ACTIVE_RUNS, ACTIVE_RUNS_LOCK, STREAMS, STREAMS_LOCK
|
||||
|
||||
(tmp_path / '.git').mkdir()
|
||||
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
||||
monkeypatch.setattr(upd, '_run_git', lambda *a, **k: (_ for _ in ()).throw(AssertionError('must not run git')))
|
||||
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: (_ for _ in ()).throw(AssertionError('must not restart')))
|
||||
|
||||
with STREAMS_LOCK:
|
||||
old_streams = dict(STREAMS)
|
||||
STREAMS.clear()
|
||||
with ACTIVE_RUNS_LOCK:
|
||||
old_runs = dict(ACTIVE_RUNS)
|
||||
ACTIVE_RUNS.clear()
|
||||
ACTIVE_RUNS['run_active'] = {'session_id': 's1', 'stream_id': 'missing-stream'}
|
||||
try:
|
||||
result = upd.apply_force_update('agent')
|
||||
finally:
|
||||
with ACTIVE_RUNS_LOCK:
|
||||
ACTIVE_RUNS.clear()
|
||||
ACTIVE_RUNS.update(old_runs)
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.clear()
|
||||
STREAMS.update(old_streams)
|
||||
|
||||
assert result['ok'] is False
|
||||
assert result.get('active_streams') == 0
|
||||
assert result.get('active_runs') == 1
|
||||
assert result.get('restart_blocked') is True
|
||||
assert 'active agent run' in result['message']
|
||||
|
||||
def test_wait_until_restart_safe_waits_for_active_run_to_clear(self, monkeypatch):
|
||||
import api.updates as upd
|
||||
|
||||
snapshots = [
|
||||
{'restart_blocked': True, 'active_streams': 0, 'active_runs': 1},
|
||||
{'restart_blocked': False, 'active_streams': 0, 'active_runs': 0},
|
||||
]
|
||||
sleeps = []
|
||||
monkeypatch.setattr(upd, '_restart_blocker_snapshot', lambda: snapshots.pop(0))
|
||||
monkeypatch.setattr(upd.time, 'sleep', lambda seconds: sleeps.append(seconds))
|
||||
|
||||
result = upd._wait_until_restart_safe(poll_seconds=0.25)
|
||||
|
||||
assert result['restart_blocked'] is False
|
||||
assert sleeps == [0.25]
|
||||
|
||||
|
||||
class TestSuccessfulUpdateReturnsRestartScheduled:
|
||||
"""#814 — successful apply_update must return restart_scheduled: True."""
|
||||
|
||||
@@ -231,7 +231,7 @@ def test_apply_force_update_fetches_tags_with_force(tmp_path):
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git), \
|
||||
patch.object(updates, 'REPO_ROOT', tmp_path), \
|
||||
patch.object(updates, '_active_stream_count', return_value=0):
|
||||
patch.object(updates, '_restart_blocker_snapshot', return_value={'restart_blocked': False, 'active_streams': 0, 'active_runs': 0}):
|
||||
updates.apply_force_update('webui')
|
||||
|
||||
fetch_calls = [a for a in seen_args if a[:2] == ['fetch', 'origin']]
|
||||
@@ -256,7 +256,7 @@ def test_apply_update_fetches_tags_with_force(tmp_path):
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git), \
|
||||
patch.object(updates, 'REPO_ROOT', tmp_path), \
|
||||
patch.object(updates, '_active_stream_count', return_value=0):
|
||||
patch.object(updates, '_restart_blocker_snapshot', return_value={'restart_blocked': False, 'active_streams': 0, 'active_runs': 0}):
|
||||
updates.apply_update('webui')
|
||||
|
||||
fetch_calls = [a for a in seen_args if a[:2] == ['fetch', 'origin']]
|
||||
|
||||
Reference in new issue
Block a user