diff --git a/api/models.py b/api/models.py index 8015809b..f4a70db0 100644 --- a/api/models.py +++ b/api/models.py @@ -3425,12 +3425,17 @@ def _apply_sidebar_state_db_override_metadata(sessions: list[dict], metadata: di state_last = float(state_db_last_message_at or 0) except (TypeError, ValueError): state_last = 0.0 + # ``current_last`` intentionally includes ``updated_at``: if a + # sidecar metadata-only write happened after the state.db append, + # keep the conservative anti-resurrection guard and wait for a + # newer settled state.db message before overlaying counts again. if state_count > current_count and (state_last <= 0 or state_last > current_last): + try: + existing_actual = max(0, int(session.get('actual_message_count') or 0)) + except (TypeError, ValueError): + existing_actual = 0 session['message_count'] = state_count - session['actual_message_count'] = max( - state_count, - int(session.get('actual_message_count') or 0) if str(session.get('actual_message_count') or '').isdigit() else 0, - ) + session['actual_message_count'] = max(state_count, existing_actual) if state_last > 0: session['last_message_at'] = max(float(session.get('last_message_at') or 0), state_last) session['updated_at'] = max(float(session.get('updated_at') or 0), state_last) diff --git a/tests/test_session_sidebar_cache.py b/tests/test_session_sidebar_cache.py index 11896363..c6a328e8 100644 --- a/tests/test_session_sidebar_cache.py +++ b/tests/test_session_sidebar_cache.py @@ -1,10 +1,23 @@ import threading from types import SimpleNamespace +import pytest + import api.routes as routes from api import session_events +@pytest.fixture(autouse=True) +def _isolated_session_list_cache_state(): + routes._session_list_cache_clear() + with routes._SESSIONS_CACHE_LOCK: + routes._SESSIONS_CACHE_INFLIGHT.clear() + yield + routes._session_list_cache_clear() + with routes._SESSIONS_CACHE_LOCK: + routes._SESSIONS_CACHE_INFLIGHT.clear() + + class _StageRecorder: def __init__(self): self.stages = [] @@ -74,8 +87,9 @@ def test_session_list_cache_key_separates_profile_and_all_profiles(): assert calls == ["default", "other", "default_all"] -def test_session_list_cache_singleflight_rebuild_once(): +def test_session_list_cache_singleflight_rebuild_once(monkeypatch): routes._session_list_cache_clear() + monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda _key: ("stable",)) started = threading.Event() release = threading.Event() @@ -121,8 +135,9 @@ def test_session_list_cache_singleflight_rebuild_once(): assert calls == 1 -def test_session_list_cache_follower_wait_stage_when_rebuild_inflight(): +def test_session_list_cache_follower_wait_stage_when_rebuild_inflight(monkeypatch): routes._session_list_cache_clear() + monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda _key: ("stable",)) started = threading.Event() release = threading.Event() diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py index 11daacee..8815e104 100644 --- a/tests/test_webui_state_db_reconciliation.py +++ b/tests/test_webui_state_db_reconciliation.py @@ -125,6 +125,36 @@ def _install_test_session(monkeypatch, tmp_path, sid, sidecar_messages): return session +def test_sidebar_state_db_overlay_preserves_numeric_actual_count(): + import api.models as models + + sid = "webui_float_actual_count" + sessions = [ + { + "session_id": sid, + "source_tag": "webui", + "message_count": 2, + "actual_message_count": 5.0, + "last_message_at": 1001.0, + "updated_at": 1001.0, + } + ] + + models._apply_sidebar_state_db_override_metadata( + sessions, + { + sid: { + "_state_db_source": "webui", + "_state_db_message_count": 4, + "_state_db_last_message_at": 1003.0, + } + }, + ) + + assert sessions[0]["message_count"] == 4 + assert sessions[0]["actual_message_count"] == 5 + + def test_tail_cancelled_partial_blocks_state_db_replay(): from api.models import merge_session_messages_append_only