From df35836b75d46d819984fd051ff9113983183191 Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 24 Sep 2026 18:01:04 +0300 Subject: [PATCH] fix: preserve transcript generations in sidebar projection Rocky-Actor: Hermes Rocky Rocky-Mode: interactive --- api/route_session_list_cache.py | 2 + .../webui-run-state-consistency-contract.md | 4 +- tests/test_sidebar_generation_projection.py | 48 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/test_sidebar_generation_projection.py diff --git a/api/route_session_list_cache.py b/api/route_session_list_cache.py index b91ba7b8..41ea7d22 100644 --- a/api/route_session_list_cache.py +++ b/api/route_session_list_cache.py @@ -53,6 +53,8 @@ _SIDEBAR_SESSION_RESPONSE_FIELDS = { "model", "model_provider", "message_count", + "transcript_generation", + "transcript_generation_baseline", "user_message_count", "created_at", "updated_at", diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index 408fd3e0..78cc89bf 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -171,7 +171,9 @@ writes. - **Viewed counts are generation-scoped.** Session mutation routes increment the persisted `transcript_generation` whenever edit, regenerate, retry, undo, clear, or truncate reduces the visible transcript, and record the retained count as - `transcript_generation_baseline`. A newer generation replaces an older one even + `transcript_generation_baseline`. Both fields survive the bounded `/api/sessions` + projection for visible and sidebar-reference rows, including cached responses. + A newer generation replaces an older one even when its count is lower; counts are monotonic only within one generation and merge by maximum there. A client first observing a newer generation acknowledges only that retained baseline, so messages added after the shrink remain unread diff --git a/tests/test_sidebar_generation_projection.py b/tests/test_sidebar_generation_projection.py new file mode 100644 index 00000000..2454481c --- /dev/null +++ b/tests/test_sidebar_generation_projection.py @@ -0,0 +1,48 @@ +"""The sessions route must carry shrink metadata through to unread decisions.""" +import json +import shutil + +import pytest + +from tests.test_issue4766_sidebar_source_pushdown import ( + _handle_sessions, + _install_common_monkeypatches, +) +from tests.test_issue856_background_completion_unread import _run_unread_behavior + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not on PATH") +@pytest.mark.parametrize("retained", [0, 2]) +def test_sessions_route_preserves_post_shrink_unread(monkeypatch, retained): + import api.routes as routes + from api.models import Session + from api.session_ops import truncate_session_at_keep + + session = Session(session_id="X", messages=[ + {"role": "user", "content": str(i)} for i in range(10) + ]) + truncate_session_at_keep(session, retained) + session.messages.append({"role": "assistant", "content": "new response"}) + row = session.compact() + _install_common_monkeypatches(monkeypatch, [row]) + routes._session_list_cache_clear() + try: + # Exercise both the initial projection and the cached response. + for _ in range(2): + response = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui") + assert response.status == 200 + [projected] = response.json_body()["sessions"] + result = _run_unread_behavior(""" +store[SESSION_VIEWED_COUNTS_KEY] = JSON.stringify({ + X: {message_count: 10, transcript_generation: 0}, +}); +const unread = _hasUnreadForSession(""" + json.dumps(projected) + """); +console.log(JSON.stringify({unread, viewed: JSON.parse(store[SESSION_VIEWED_COUNTS_KEY]).X})); +""") + assert result == { + "unread": True, + "viewed": {"message_count": retained, "transcript_generation": 1}, + } + assert "messages" not in projected + finally: + routes._session_list_cache_clear()