diff --git a/static/sessions.js b/static/sessions.js index 98ee6252..e4527a95 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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; diff --git a/tests/test_inflight_send_start_race.py b/tests/test_inflight_send_start_race.py index 933e62a4..1908acad 100644 --- a/tests/test_inflight_send_start_race.py +++ b/tests/test_inflight_send_start_race.py @@ -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" + )