diff --git a/api/models.py b/api/models.py index fd6557ec..d7210ae9 100644 --- a/api/models.py +++ b/api/models.py @@ -436,7 +436,14 @@ class Session: self.read_only = bool(kwargs.get('read_only', False)) self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {} - self._metadata_message_count = None + raw_message_count = kwargs.get('message_count') + parsed_message_count = None + if raw_message_count is not None: + try: + parsed_message_count = int(raw_message_count) + except (TypeError, ValueError): + parsed_message_count = None + self._metadata_message_count = parsed_message_count if parsed_message_count is not None and parsed_message_count >= 0 else None @property def path(self): @@ -601,7 +608,19 @@ class Session: parsed['messages'] = [] parsed['tool_calls'] = [] session = cls(**parsed) - session._metadata_message_count = _lookup_index_message_count(sid) + metadata_message_count = _lookup_index_message_count(sid) + if metadata_message_count is None: + raw_count = parsed.get('message_count') + if isinstance(raw_count, int) and raw_count >= 0: + metadata_message_count = raw_count + else: + try: + parsed_count = int(raw_count) + except (TypeError, ValueError): + parsed_count = None + if parsed_count is not None and parsed_count >= 0: + metadata_message_count = parsed_count + session._metadata_message_count = metadata_message_count # Mark this session as a metadata-only stub. save() refuses to write # such a session because doing so would atomically replace the # on-disk JSON with messages=[], wiping the conversation. Any diff --git a/api/routes.py b/api/routes.py index ee01a3e3..81dda7cb 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3721,6 +3721,18 @@ def handle_get(handler, parsed) -> bool: _all_msgs = merge_session_messages_append_only(_metadata_sidecar, state_db_messages) if not load_messages: _summary_message_count = len(_all_msgs) + if _summary_message_count == 0: + # Legacy session with no loaded sidecar and no state.db summary — + # fall back to the persisted metadata count from session JSON. + # See PR #2605 (LumenYoung): without this, the metadata poll + # returns 0 and the active-session external-refresh signal + # never trips on legacy sessions. + try: + metadata_count = getattr(s, "_metadata_message_count", None) + if metadata_count is not None: + _summary_message_count = max(0, int(metadata_count)) + except (TypeError, ValueError): + pass try: _summary_last_message_at = max( float((m or {}).get("timestamp") or 0) diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py index 5e0e17b2..a9748bab 100644 --- a/tests/test_webui_state_db_reconciliation.py +++ b/tests/test_webui_state_db_reconciliation.py @@ -135,6 +135,32 @@ def test_api_session_includes_state_db_messages_newer_than_webui_sidecar(monkeyp assert payload["session"]["message_count"] == 4 +def test_metadata_poll_uses_sidecar_message_count_for_external_updates(monkeypatch, tmp_path): + """Active-session external refresh relies on metadata-only counts. + + When no session index exists, metadata-only loads may fall back to + _metadata_message_count=None. The refresh poll must still report the real + sidecar message count; otherwise an external session JSON update can be + invisible until a full reload. + """ + import api.routes as routes + + sid = "webui_reconcile_metadata_sidecar" + sidecar_messages = [ + {"role": "user", "content": "before external update", "timestamp": 1000.0}, + {"role": "assistant", "content": "externally appended", "timestamp": 1001.0}, + ] + _install_test_session(monkeypatch, tmp_path, sid, sidecar_messages) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=0&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + + assert handler.status == 200 + session = handler.response_json["session"] + assert session["message_count"] == 2 + assert session["last_message_at"] == 1001.0 + + def test_state_db_reconciliation_preserves_sidecar_only_messages(monkeypatch, tmp_path): import api.routes as routes