Merge remote-tracking branch 'origin/master' into fix/selective-durable-writeback

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
AJV20
2026-05-28 14:59:12 -04:00
16 changed files with 175 additions and 22 deletions
+8
View File
@@ -7,6 +7,14 @@
- WebUI's browser-session surface prompt now explicitly tells agents not to dump browser transcripts into external notes or durable memory by default; it limits saving to explicit captures and clearly reusable durable signals such as preferences, decisions, blockers, and runbook-worthy workflows.
## [v0.51.155] — 2026-05-28 — Release EA (stage-batch37 — 3-PR very low-risk cleanup: passive timeout toasts + sidecar order + subsecond timestamps)
### Fixed
- Passive background refreshes such as sidebar/project polling, health checks, cron-status watches, and client-event logging no longer surface generic timeout toasts; explicit user actions still show timeout errors. (Related to #3024)
- Messaging/session display merges now preserve sidecar transcript order when the sidecar already contains at least as many rows as the mirrored state store, avoiding role/content fallback sorting when timestamp precision collapses.
- Gateway-backed turns and compacted/reconciled message batches now keep subsecond timestamp ordering instead of assigning the same integer-second timestamp to multiple transcript rows.
## [v0.51.154] — 2026-05-28 — Release DZ (stage-batch36 — 9-PR medium-risk cleanup: cron project chip + KaTeX streaming + recovery + .env keys + discoverability repair + media MEDIA tokens + gateway 401 + notes prefill + cron filter)
### Added
+7 -2
View File
@@ -297,11 +297,16 @@ def _run_gateway_chat_streaming(
s = get_session(session_id)
if not _stream_writeback_is_current(s, stream_id):
return
now = int(time.time())
now = time.time()
# Preserve subsecond ordering for gateway-backed turns. Using an
# integer seconds timestamp gives the user and assistant rows the
# same sort key; later transcript merges can then fall back to
# role/content ordering instead of turn order.
assistant_ts = now + 0.000001
user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now}
if attachments:
user_msg["attachments"] = list(attachments)
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": now}
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts}
previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or [])
s.context_messages = previous_context + [user_msg, assistant_msg]
display = list(getattr(s, "messages", None) or [])
+6
View File
@@ -2263,6 +2263,12 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list:
sidecar_messages = list(getattr(session, "messages", []) or [])
if cli_messages:
if sidecar_messages and sidecar_messages != cli_messages:
if len(sidecar_messages) >= len(cli_messages):
return merge_session_messages_append_only(
sidecar_messages,
cli_messages,
truncation_watermark=getattr(session, "truncation_watermark", None),
)
merged_messages = []
seen_message_keys = set()
for msg in sorted(list(cli_messages) + list(sidecar_messages), key=lambda m: (
+19 -5
View File
@@ -2983,6 +2983,22 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
return merged
def _stamp_missing_message_timestamps(messages, *, now: float | None = None) -> int:
"""Stamp missing message timestamps without collapsing transcript order.
Compacted/reconciled rows can arrive without timestamps. Assigning one
integer seconds value to the whole batch makes later timestamp-based display
merges unstable; use a subsecond sequence instead.
"""
base = time.time() if now is None else float(now)
stamped = 0
for msg in messages or []:
if isinstance(msg, dict) and not msg.get('timestamp') and not msg.get('_ts'):
msg['timestamp'] = base + (stamped * 0.000001)
stamped += 1
return stamped
def _assistant_reply_added_after_current_turn(result_messages, previous_context, msg_text) -> bool:
"""Return True only when the just-finished turn produced assistant text."""
result_messages = list(result_messages or [])
@@ -5299,11 +5315,9 @@ def _run_agent_streaming(
'usage': _live_usage_snapshot(),
})
# Stamp 'timestamp' on any messages that don't have one yet
_now = time.time()
for _m in s.messages:
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
_m['timestamp'] = int(_now)
# Stamp 'timestamp' on any messages that don't have one yet,
# preserving transcript order across compacted/reconciled batches.
_stamp_missing_message_timestamps(s.messages)
# Only auto-generate title when still default; preserves user renames
if s.title == 'Untitled' or s.title == 'New Chat' or not s.title:
s.title = title_from(s.messages, s.title)
+2 -2
View File
@@ -1145,7 +1145,7 @@ function _startCronWatch(jobId) {
_cronWatchStart = Date.now();
_cronWatchInterval = setInterval(async () => {
try {
const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`);
const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false});
if (!data.running) {
_stopCronWatch();
if (_currentCronDetail && _currentCronDetail.id === jobId) {
@@ -1200,7 +1200,7 @@ function _formatElapsed(seconds) {
function _checkCronWatchOnDetail(jobId) {
// When opening a detail view, check if job is running
api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`).then(data => {
api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false}).then(data => {
if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) {
_startCronWatch(jobId);
}
+3 -3
View File
@@ -2280,8 +2280,8 @@ async function renderSessionList(opts={}){
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const allProfilesQS = _showAllProfiles ? '?all_profiles=1' : '';
const [sessData, projData] = await Promise.all([
api('/api/sessions' + allProfilesQS),
api('/api/projects' + allProfilesQS),
api('/api/sessions' + allProfilesQS,{timeoutToast:false}),
api('/api/projects' + allProfilesQS,{timeoutToast:false}),
]);
// Discard stale response — a newer renderSessionList() call superseded us.
if (_gen !== _renderSessionListGen) return;
@@ -2354,7 +2354,7 @@ async function refreshActiveSessionIfExternallyUpdated(reason){
const localLast = Number(S.session.last_message_at || S.session.updated_at || 0);
_activeSessionExternalRefreshInFlight = true;
try{
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false});
if(!data || !data.session) return;
if(!S.session || S.session.session_id !== sid) return;
if(S.busy || S.activeStreamId) return;
+3 -3
View File
@@ -475,7 +475,7 @@ async function refreshDashboardStatus(force=false){
return _dashboardStatusCache;
}
try{
const status=await api('/api/dashboard/status');
const status=await api('/api/dashboard/status',{timeoutToast:false});
_dashboardStatusCache=status||{running:false};
}catch(_){
_dashboardStatusCache={running:false};
@@ -4588,7 +4588,7 @@ async function pollSystemHealth(){
if(document.visibilityState !== 'visible') return;
if(!_systemHealthPanelIsVisible()) return;
try{
const payload=await api('/api/system/health');
const payload=await api('/api/system/health',{timeoutToast:false});
renderSystemHealth(payload);
}catch(_){
setSystemHealthUnavailable('Unavailable');
@@ -4654,7 +4654,7 @@ function dismissAgentHealthAlert(){
async function pollAgentHealth(){
if(document.visibilityState !== 'visible') return;
try{
const payload=await api('/api/health/agent');
const payload=await api('/api/health/agent',{timeoutToast:false});
if(payload.alive === true){
_agentHealthLastState='alive';
_setAgentHealthDismissed(false);
+5 -2
View File
@@ -3,6 +3,7 @@ async function api(path,opts={}){
const rel = path.startsWith('/') ? path.slice(1) : path;
const url=new URL(rel,document.baseURI||location.href);
const timeoutMs=Object.prototype.hasOwnProperty.call(opts,'timeoutMs')?opts.timeoutMs:30000;
const timeoutToast=opts.timeoutToast!==false;
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
// Server errors (4xx/5xx) and client-side timeouts are NOT retried.
let lastErr;
@@ -15,6 +16,8 @@ async function api(path,opts={}){
try{
const fetchOpts={...opts};
delete fetchOpts.timeoutMs;
delete fetchOpts.timeoutToast;
const useTimeout=Number.isFinite(Number(timeoutMs))&&Number(timeoutMs)>0;
if(useTimeout&&typeof AbortController!=='undefined'){
controller=new AbortController();
@@ -69,7 +72,7 @@ async function api(path,opts={}){
const err=(e&&e.name==='TimeoutError')?e:new Error('Request timed out. Please try again.');
err.name='TimeoutError';
err.timeout=true;
if(typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error');
if(timeoutToast&&typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error');
throw err;
}
// Only retry on network errors (TypeError from fetch), not on HTTP errors
@@ -98,7 +101,7 @@ function recordClientSSEError(source, details={}){
url_path:(typeof location!=='undefined'&&location.pathname)||'/',
reason:details.reason||'EventSource.onerror',
};
void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000}).catch(()=>{});
void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000,timeoutToast:false}).catch(()=>{});
}catch(_){}
}
+52
View File
@@ -159,13 +159,48 @@ def test_api_rejects_stalled_response_body_with_timeout():
assert any(event.get("aborted") for event in payload["events"]), payload
def test_api_can_suppress_timeout_toast_for_background_pollers():
"""Passive pollers need abort/reject cleanup without a user-visible toast."""
api_fn = _extract_js_function(_source(WORKSPACE_JS), "api")
script = textwrap.dedent(
f"""
const events=[];
global.document={{baseURI:'http://example.test/hermes/'}};
global.location={{href:'http://example.test/hermes/',pathname:'/hermes/',search:''}};
global.window={{location:global.location}};
global.showToast=(msg,ms,type)=>events.push({{msg:String(msg),ms,type}});
global.fetch=(url,opts)=>new Promise(()=>{{
if(opts&&opts.signal)opts.signal.addEventListener('abort',()=>events.push({{aborted:true}}));
}});
{api_fn}
api('/api/sessions',{{timeoutMs:20,timeoutToast:false}})
.then(()=>{{console.error('resolved unexpectedly');process.exit(2);}})
.catch(err=>{{
console.log(JSON.stringify({{message:String(err&&err.message||err),events}}));
process.exit(0);
}});
setTimeout(()=>{{console.error('api did not reject after timeoutMs');process.exit(3);}},250);
"""
)
result = _node_eval(script, timeout=1.0)
assert result.returncode == 0, result.stderr or result.stdout
payload = json.loads(result.stdout.strip())
assert "timed out" in payload["message"].lower()
assert any(event.get("aborted") for event in payload["events"]), payload
assert not any("msg" in event for event in payload["events"]), payload
def test_api_has_default_timeout_and_per_call_override_contract():
src = _source(WORKSPACE_JS)
body = _extract_js_function(src, "api")
assert "timeoutMs" in body, "api() must accept opts.timeoutMs as a per-call override"
assert "timeoutToast" in body, "api() must let passive callers suppress timeout toasts"
assert "30000" in body, "api() must default browser API calls to a 30s timeout"
assert "AbortController" in body, "api() must abort hung fetches with AbortController"
assert "delete fetchOpts.timeoutMs" in body, "api() must strip timeoutMs before calling fetch()"
assert "delete fetchOpts.timeoutToast" in body, "api() must strip timeoutToast before calling fetch()"
fetch_call = re.search(r"fetch\(url\.href,\{.*?\.\.\.fetchOpts.*?\}\)", body, re.DOTALL)
assert fetch_call, "api() must call fetch() with sanitized fetchOpts"
assert "...opts" not in fetch_call.group(0), "api() must not spread raw opts into fetch()"
@@ -182,6 +217,23 @@ def test_update_flows_keep_explicit_longer_timeouts():
assert "api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
def test_passive_background_polls_suppress_timeout_toasts():
"""Passive refreshes should be best-effort and not emit generic timeout toasts."""
workspace = _source(WORKSPACE_JS)
sessions = _source(SESSIONS_JS)
ui = _source(UI_JS)
panels = _source(PANELS_JS)
assert "api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000,timeoutToast:false})" in workspace
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in sessions
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in sessions
assert "api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false})" in sessions
assert "api('/api/dashboard/status',{timeoutToast:false})" in ui
assert "api('/api/system/health',{timeoutToast:false})" in ui
assert "api('/api/health/agent',{timeoutToast:false})" in ui
assert "api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false})" in panels
def test_new_session_inflight_cleanup_still_runs_after_api_rejects():
"""newSession() must keep its finally cleanup path so timeout rejections unpin the UI."""
src = _source(SESSIONS_JS)
+1 -1
View File
@@ -25,7 +25,7 @@ def test_dashboard_rail_item_sits_between_insights_and_settings_spacer():
def test_dashboard_frontend_fetches_status_with_sixty_second_cache():
assert "DASHBOARD_STATUS_TTL_MS=60000" in UI_JS
assert "function refreshDashboardStatus" in UI_JS
assert "api('/api/dashboard/status')" in UI_JS
assert "api('/api/dashboard/status',{timeoutToast:false})" in UI_JS
assert "setInterval(refreshDashboardStatus,DASHBOARD_STATUS_TTL_MS)" in UI_JS
assert 'fetch("/api/dashboard/status"' not in UI_JS
assert "fetch('/api/dashboard/status'" not in UI_JS
@@ -134,10 +134,10 @@ def test_static_sessions_js_uses_all_profiles_query_when_toggle_on():
assert "_showAllProfiles ? '?all_profiles=1' : ''" in src, (
"Expected fetch path to flip on the toggle state"
)
assert "api('/api/sessions' + allProfilesQS)" in src, (
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in src, (
"Expected /api/sessions fetch to use the variant query"
)
assert "api('/api/projects' + allProfilesQS)" in src, (
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in src, (
"Expected /api/projects fetch to use the variant query"
)
@@ -66,6 +66,39 @@ def test_messaging_merge_helper_dedupes_equivalent_timestamp_formats():
assert [m["content"] for m in merged] == ["hi", "same answer"]
def test_messaging_merge_preserves_longer_sidecar_order_when_timestamps_collapse():
"""A repaired messaging sidecar can preserve order but lose subsecond timestamps.
Re-sorting those messages by ``(timestamp, role, content)`` groups assistant
and tool rows before user rows, making the WebUI look like replies vanished.
"""
session = SimpleNamespace(
messages=[
{"role": "assistant", "content": "prior answer", "timestamp": 100.0},
{"role": "user", "content": "first prompt", "timestamp": 101.0},
{"role": "assistant", "content": "first answer", "timestamp": 101.0},
{"role": "user", "content": "second prompt", "timestamp": 101.0},
{"role": "assistant", "content": "second answer", "timestamp": 101.0},
]
)
cli_messages = [
{"role": "user", "content": "first prompt", "timestamp": 101.1},
{"role": "assistant", "content": "first answer", "timestamp": 101.2},
{"role": "user", "content": "second prompt", "timestamp": 101.3},
{"role": "assistant", "content": "second answer", "timestamp": 101.4},
]
merged = routes._merged_session_messages_for_display(session, cli_messages)
assert [m["content"] for m in merged] == [
"prior answer",
"first prompt",
"first answer",
"second prompt",
"second answer",
]
def test_branch_handler_uses_merged_messaging_messages_for_keep_count():
branch_idx = ROUTES_PY.index('parsed.path == "/api/session/branch":')
block = ROUTES_PY[branch_idx : branch_idx + 2600]
+1 -1
View File
@@ -159,7 +159,7 @@ def test_system_health_panel_markup_and_styles_live_under_insights_not_top_chrom
def test_system_health_frontend_polls_visible_and_renders_progress_labels():
assert "const SYSTEM_HEALTH_INTERVAL_MS=5000" in UI_JS
assert "api('/api/system/health')" in UI_JS
assert "api('/api/system/health',{timeoutToast:false})" in UI_JS
assert "document.visibilityState !== 'visible'" in UI_JS
assert "document.querySelector('main.main.showing-insights')" in UI_JS
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS
+1 -1
View File
@@ -186,7 +186,7 @@ def test_agent_health_banner_markup_and_styles_exist():
def test_agent_health_frontend_polls_only_visible_and_distinguishes_states():
assert "const AGENT_HEALTH_INTERVAL_MS=30000" in UI_JS
assert "api('/api/health/agent')" in UI_JS
assert "api('/api/health/agent',{timeoutToast:false})" in UI_JS
assert "document.visibilityState !== 'visible'" in UI_JS
assert "document.addEventListener('visibilitychange',_syncAgentHealthMonitorVisibility)" in UI_JS
assert "if(payload.alive === true)" in UI_JS
+29
View File
@@ -0,0 +1,29 @@
from api.streaming import _stamp_missing_message_timestamps
def test_stamp_missing_message_timestamps_uses_subsecond_sequence():
messages = [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
]
stamped = _stamp_missing_message_timestamps(messages, now=1000.0)
assert stamped == 3
assert [m["timestamp"] for m in messages] == [1000.0, 1000.000001, 1000.000002]
def test_stamp_missing_message_timestamps_preserves_existing_timestamp_metadata():
messages = [
{"role": "user", "content": "old", "timestamp": 900.0},
{"role": "assistant", "content": "synthetic", "_ts": 901.0},
{"role": "user", "content": "new"},
]
stamped = _stamp_missing_message_timestamps(messages, now=1000.0)
assert stamped == 1
assert messages[0]["timestamp"] == 900.0
assert "timestamp" not in messages[1]
assert messages[2]["timestamp"] == 1000.0
+3
View File
@@ -224,6 +224,9 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
saved = models.get_session(s.session_id)
assert [m["role"] for m in saved.messages] == ["user", "assistant"]
assert saved.messages[-1]["content"] == "hello"
assert isinstance(saved.messages[0]["timestamp"], float)
assert isinstance(saved.messages[1]["timestamp"], float)
assert saved.messages[0]["timestamp"] < saved.messages[1]["timestamp"]
assert saved.active_stream_id is None
assert stream_id not in STREAMS
assert captured["url"] == "http://gateway.local/v1/chat/completions"