fix: drop stale optimistic sidebar rows

This commit is contained in:
ai-ag2026
2026-05-23 01:11:50 +02:00
parent e091e65d56
commit dcee0563c1
2 changed files with 73 additions and 9 deletions
+41 -9
View File
@@ -2023,6 +2023,31 @@ function _isOptimisticFirstTurnSessionRow(s){
);
}
function _shouldKeepLocalOnlyOptimisticSessionRow(local){
if(!_isOptimisticFirstTurnSessionRow(local)) return false;
const sid=local.session_id;
if(typeof _sendInProgress!=='undefined'&&_sendInProgress&&sid===_sendInProgressSid) return true;
const activeSid=S&&S.session&&S.session.session_id;
const isActive=Boolean(activeSid&&activeSid===sid);
const hasRuntimeConfirmation=Boolean(local.active_stream_id||local.pending_user_message||local.pending_started_at);
if(isActive&&S.busy&&hasRuntimeConfirmation) return true;
const localTs=Number(local.last_message_at||local.updated_at||0);
const ageMs=localTs>0?Date.now()-(localTs*1000):Infinity;
return Boolean(isActive&&S.busy&&ageMs>=0&&ageMs<5000);
}
function _dropStaleOptimisticSessionRow(sid){
if(!sid) return;
if(INFLIGHT&&INFLIGHT[sid]){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
}
if(typeof _sessionStreamingById!=='undefined'&&_sessionStreamingById&&typeof _sessionStreamingById.set==='function'){
_sessionStreamingById.set(sid,false);
}
if(typeof _forgetObservedStreamingSession==='function') _forgetObservedStreamingSession(sid);
}
function _mergeOptimisticFirstTurnSessions(fetchedSessions){
const merged=Array.isArray(fetchedSessions)?[...fetchedSessions]:[];
const bySid=new Map();
@@ -2034,24 +2059,31 @@ function _mergeOptimisticFirstTurnSessions(fetchedSessions){
if(idx>=0){
const fetched=merged[idx]||{};
const fetchedIsServerIdle=_isServerIdleSessionRow(fetched);
const keepLocalOptimistic=!fetchedIsServerIdle||_shouldKeepLocalOnlyOptimisticSessionRow(local);
const localCount=Number(local.message_count||0);
const fetchedCount=Number(fetched.message_count||0);
const localTs=Number(local.last_message_at||local.updated_at||0);
const fetchedTs=Number(fetched.last_message_at||fetched.updated_at||0);
if(!keepLocalOptimistic) _dropStaleOptimisticSessionRow(sid);
merged[idx]={
...local,
...fetched,
message_count:Math.max(localCount,fetchedCount),
last_message_at:Math.max(localTs,fetchedTs),
updated_at:Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs),
active_stream_id:fetchedIsServerIdle?null:(fetched.active_stream_id||local.active_stream_id||null),
pending_user_message:fetchedIsServerIdle?null:(fetched.pending_user_message||local.pending_user_message||null),
pending_started_at:fetchedIsServerIdle?null:(fetched.pending_started_at||local.pending_started_at||null),
is_streaming:fetchedIsServerIdle?false:Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)),
title:keepLocalOptimistic?(local.title||fetched.title):fetched.title,
message_count:keepLocalOptimistic?Math.max(localCount,fetchedCount):fetchedCount,
last_message_at:keepLocalOptimistic?Math.max(localTs,fetchedTs):fetchedTs,
updated_at:keepLocalOptimistic?Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs):Number(fetched.updated_at||fetchedTs||0),
active_stream_id:keepLocalOptimistic?(fetched.active_stream_id||local.active_stream_id||null):null,
pending_user_message:keepLocalOptimistic?(fetched.pending_user_message||local.pending_user_message||null):null,
pending_started_at:keepLocalOptimistic?(fetched.pending_started_at||local.pending_started_at||null):null,
is_streaming:keepLocalOptimistic&&Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)),
};
}else{
merged.push({...local,is_streaming:true});
bySid.set(sid,merged.length-1);
if(_shouldKeepLocalOnlyOptimisticSessionRow(local)){
merged.push({...local,is_streaming:true});
bySid.set(sid,merged.length-1);
}else{
_dropStaleOptimisticSessionRow(sid);
}
}
}
return merged;
+32
View File
@@ -49,3 +49,35 @@ def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists():
skip_idx = body.index("_sendInProgress")
delete_idx = body.index("delete INFLIGHT[sid];")
assert skip_idx < delete_idx, "the current-send skip must run before any purge deletion"
def test_server_absent_optimistic_first_turn_rows_are_not_kept_forever():
"""A local first-turn sidebar row must expire when /api/chat/start never persisted it."""
body = _function_body(SESSIONS_JS, "_mergeOptimisticFirstTurnSessions")
assert "_shouldKeepLocalOnlyOptimisticSessionRow(local)" in body, (
"server-absent optimistic rows need an explicit keep/drop gate"
)
keep_idx = body.index("if(_shouldKeepLocalOnlyOptimisticSessionRow(local))")
append_idx = body.index("merged.push({...local,is_streaming:true});")
drop_idx = body.index("_dropStaleOptimisticSessionRow(sid);", append_idx)
assert keep_idx < append_idx < drop_idx, (
"local optimistic rows may only be appended inside the explicit keep gate"
)
drop_body = _function_body(SESSIONS_JS, "_dropStaleOptimisticSessionRow")
assert "clearInflightState(sid)" in drop_body, (
"dropping a phantom row should also clear persisted browser recovery state"
)
def test_server_idle_row_wins_over_stale_optimistic_count():
"""If the server says the row is idle, stale local message_count/title must not win."""
body = _function_body(SESSIONS_JS, "_mergeOptimisticFirstTurnSessions")
assert "const keepLocalOptimistic=" in body
assert "message_count:keepLocalOptimistic?Math.max(localCount,fetchedCount):fetchedCount" in body, (
"stale optimistic message_count must not override a confirmed idle server row"
)
assert "title:keepLocalOptimistic?(local.title||fetched.title):fetched.title" in body, (
"stale optimistic provisional title must not override a confirmed idle server row"
)