From 8f9e82f606f5892ead9a804094290179425ad4ca Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 23 May 2026 20:19:57 +0200 Subject: [PATCH] fix: drop stale cached user tail after saved assistant --- CHANGELOG.md | 4 + api/models.py | 96 +++++++++++++++++++++ tests/test_webui_state_db_reconciliation.py | 45 ++++++++++ 3 files changed, 145 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a080547d9..94e46891c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Drop stale inactive cached user tails when `/api/session` reloads a conversation whose saved sidecar already ends on an assistant answer. This prevents compacted conversations from appearing to hide the final assistant response until the user forks/reloads the session. + ## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored) ### Fixed diff --git a/api/models.py b/api/models.py index 652bde3ff0..602a765d5a 100644 --- a/api/models.py +++ b/api/models.py @@ -1723,6 +1723,89 @@ def _repair_stale_pending(session) -> bool: return False +def _last_non_tool_role(messages) -> str: + if not isinstance(messages, list): + return '' + for message in reversed(messages): + role = _message_role(message) + if role and role != 'tool': + return role + return '' + + +def _last_non_tool_message(messages): + if not isinstance(messages, list): + return None + for message in reversed(messages): + role = _message_role(message) + if role and role != 'tool': + return message + return None + + +def _message_content_text(message) -> str: + if not isinstance(message, dict): + return '' + content = message.get('content') + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get('text'), str): + parts.append(item['text']) + return ''.join(parts) + return '' + + +def _inactive_cache_tail_needs_disk_check(cached) -> bool: + if cached is None: + return False + if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None): + return False + return _last_non_tool_role(getattr(cached, 'messages', None) or []) == 'user' + + +def _cache_has_stale_unsaved_user_tail(cached, disk_session) -> bool: + """Return True when an inactive cached session has an unsaved user tail. + + A completed turn is saved to the sidecar before the browser reloads it. In + rare compaction/reconnect paths the in-process cache can retain a recovered + or optimistic user row after the saved assistant tail even though the row was + never persisted. If /api/session serves that cache entry, the visible + transcript appears to end on the old prompt and the saved assistant answer + looks missing until a fork/reload resets the cache. + """ + if cached is None or disk_session is None: + return False + if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None): + return False + cached_messages = getattr(cached, 'messages', None) or [] + disk_messages = getattr(disk_session, 'messages', None) or [] + if len(cached_messages) <= len(disk_messages): + return False + if _last_non_tool_role(cached_messages) != 'user': + return False + if _last_non_tool_role(disk_messages) != 'assistant': + return False + + cached_tail = _last_non_tool_message(cached_messages) + previous_disk_user = None + for message in reversed(disk_messages): + if _message_role(message) == 'user': + previous_disk_user = message + break + if previous_disk_user is None: + return False + + # Only drop tails that look like a duplicated optimistic/recovered user row. + # A genuinely new concurrent user edit must stay in memory so stale-session + # guards can report and preserve it. + return _message_content_text(cached_tail) == _message_content_text(previous_disk_user) + + def get_session(sid, metadata_only=False): """Load a session, optionally with metadata only (skipping the messages array). @@ -1736,6 +1819,19 @@ def get_session(sid, metadata_only=False): if cached is not None: SESSIONS.move_to_end(sid) # LRU: mark as recently used if cached is not None: + if not metadata_only and _inactive_cache_tail_needs_disk_check(cached): + try: + disk_session = Session.load(sid) + if _cache_has_stale_unsaved_user_tail(cached, disk_session): + with LOCK: + SESSIONS[sid] = disk_session + SESSIONS.move_to_end(sid) + cached = disk_session + except Exception: + logger.debug( + "stale cached user-tail check failed for session %s", + sid, exc_info=True, + ) if not metadata_only and _session_has_pending_journal_retry(cached): try: _try_retry_journal_recovery_in_place(cached) diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py index e1858d7c8e..f977b6c20c 100644 --- a/tests/test_webui_state_db_reconciliation.py +++ b/tests/test_webui_state_db_reconciliation.py @@ -467,6 +467,51 @@ def test_metadata_fast_path_excludes_state_db_rows_filtered_by_reconciliation(mo assert session["last_message_at"] == 1001.0 +def test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant(monkeypatch, tmp_path): + import api.models as models + import api.routes as routes + + sid = "webui_reconcile_cached_user_tail" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [ + {"role": "user", "content": "please audit phase c", "timestamp": 1000.0}, + {"role": "assistant", "content": "final audit complete", "timestamp": 1001.0}, + ], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "please audit phase c", "timestamp": 1000.0}, + {"role": "assistant", "content": "final audit complete", "timestamp": 1001.0}, + ], + ) + + cached = models.Session.load(sid) + cached.messages.append( + { + "role": "user", + "content": "please audit phase c", + "timestamp": 1002.0, + } + ) + cached.pending_user_message = None + cached.active_stream_id = None + models.SESSIONS[sid] = cached + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + + assert handler.status == 200 + messages = handler.response_json["session"]["messages"] + assert messages[-1]["role"] == "assistant" + assert messages[-1]["content"] == "final audit complete" + assert handler.response_json["session"]["message_count"] == 2 + + def test_state_db_reconciliation_preserves_tool_metadata(monkeypatch, tmp_path): import api.routes as routes