diff --git a/static/messages.js b/static/messages.js index f4d54845..171034ab 100644 --- a/static/messages.js +++ b/static/messages.js @@ -5030,9 +5030,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const isSessionViewed=_isSessionActivelyViewed(activeSid); const completedSession=d.session||{session_id:activeSid}; const completedSid=completedSession.session_id||activeSid; + const completedMessageCount=completedSession.message_count != null + ? completedSession.message_count + : ( + Array.isArray(completedSession.messages) + ? completedSession.messages.length + : ( + (S.session&&((S.session.session_id||activeSid)===completedSid)&&S.session.message_count != null) + ? S.session.message_count + : ((Array.isArray(S.messages)&&S.messages.length)||0) + ) + ); if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){ _markSessionCompletionUnread(completedSid, completedSession.message_count); } + if(isSessionViewed) _markSessionViewed(completedSid, completedMessageCount); _clearOwnerInflightState(); if(typeof _markSessionCompletedInList==='function'){ _markSessionCompletedInList(completedSession, activeSid); @@ -5175,7 +5187,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // No-reply guard (#373): if agent returned nothing, show inline error if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});} if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); - if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); + if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? completedMessageCount); // Cooldown: prevent refreshActiveSessionIfExternallyUpdated from // force-reloading immediately after "done" — the event already // delivered the final messages and tool calls. diff --git a/tests/test_issue5273_active_done_viewed_sync.py b/tests/test_issue5273_active_done_viewed_sync.py new file mode 100644 index 00000000..13424281 --- /dev/null +++ b/tests/test_issue5273_active_done_viewed_sync.py @@ -0,0 +1,254 @@ +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 0 + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : idx] + raise AssertionError(f"function {name} body not found") + + +def _extract_function(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + body = _function_body(src, name) + sig = src[start : src.index("{", start)] + return f"{sig}{{{body}}}" + + +def _done_handler_body() -> str: + marker = "source.addEventListener('done',e=>{" + start = MESSAGES_JS.index(marker) + brace = MESSAGES_JS.index("{", start) + depth = 0 + for idx in range(brace, len(MESSAGES_JS)): + ch = MESSAGES_JS[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return MESSAGES_JS[brace + 1 : idx] + raise AssertionError("done handler body not found") + + +def _has_pre_list_view_sync() -> bool: + body = _done_handler_body() + list_call = "_markSessionCompletedInList(completedSession, activeSid);" + viewed_call = "_markSessionViewed(completedSid," + assert list_call in body, "done handler must update the cached sidebar row" + assert viewed_call in body, "done handler must sync viewed state for active sessions" + return body.index(viewed_call) < body.index(list_call) + + +def _run_done_compaction_harness( + *, + is_session_viewed: bool, + include_message_count: bool = True, + include_completed_messages: bool = True, + session_sid_matches_completed: bool = True, + session_message_count: int | None = 4, + visible_message_count: int = 4, +) -> dict: + helpers = "\n".join( + [ + _extract_function(MESSAGES_JS, "_markSessionViewed"), + _extract_function(SESSIONS_JS, "_getSessionViewedCounts"), + _extract_function(SESSIONS_JS, "_saveSessionViewedCounts"), + _extract_function(SESSIONS_JS, "_setSessionViewedCount"), + _extract_function(SESSIONS_JS, "_getSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_markSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_clearSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_hasUnreadForSession"), + _extract_function(SESSIONS_JS, "_markSessionCompletedInList"), + ] + ) + has_pre_list_sync = _has_pre_list_view_sync() + harness = textwrap.dedent( + f""" + const SESSION_VIEWED_COUNTS_KEY='session-viewed-counts'; + const SESSION_COMPLETION_UNREAD_KEY='session-completion-unread'; + let _sessionViewedCounts=null; + let _sessionCompletionUnread=null; + const visibleMessages=Array.from({{length:{visible_message_count}}}, (_, idx)=>({{ + role:'assistant', + content:`message-${{idx + 1}}`, + }})); + let _allSessions=[{{ + session_id:'active-before-compact', + message_count:3, + last_message_at:10, + updated_at:10, + is_streaming:true, + }}]; + const _sessionStreamingById=new Map(); + const _sessionListSnapshotById=new Map(); + const _sessionListSourceById=new Map(); + const storage=new Map(); + const localStorage={{ + getItem(key){{ return storage.has(key) ? storage.get(key) : null; }}, + setItem(key, value){{ storage.set(key, String(value)); }}, + removeItem(key){{ storage.delete(key); }}, + }}; + function renderSessionListFromCache(){{}} + function _forgetObservedStreamingSession(){{}} + function _rememberSessionListSource(){{}} + {helpers} + _setSessionViewedCount('active-after-compact', 3); + const activeSid='active-before-compact'; + const completedSid='active-after-compact'; + const S={{ + session:{{session_id:{json.dumps("active-after-compact" if session_sid_matches_completed else "different-session")}}}, + messages:visibleMessages, + }}; + if ({json.dumps(session_message_count is not None)}) S.session.message_count = {json.dumps(session_message_count)}; + const completedSession={{ + session_id:completedSid, + updated_at:20, + last_message_at:20, + }}; + if ({str(include_completed_messages).lower()}) completedSession.messages = visibleMessages.slice(); + if ({str(include_message_count).lower()}) completedSession.message_count = 4; + const completedMessageCount = completedSession.message_count != null + ? completedSession.message_count + : (Array.isArray(completedSession.messages) + ? completedSession.messages.length + : ((S.session&&((S.session.session_id||activeSid)===completedSid)&&S.session.message_count != null) + ? S.session.message_count + : ((Array.isArray(S.messages)&&S.messages.length)||0))); + if ({str(is_session_viewed).lower()} && {json.dumps(has_pre_list_sync)}) {{ + _markSessionViewed(completedSid, completedMessageCount); + }} + if (!{str(is_session_viewed).lower()}) {{ + _markSessionCompletionUnread(completedSid, completedSession.message_count); + }} + _markSessionCompletedInList(completedSession, activeSid); + const cacheRow=_allSessions.find(s=>s&&s.session_id===completedSid); + const unreadAfterCacheUpdate=_hasUnreadForSession(cacheRow); + const viewedCountAfterCacheUpdate=_getSessionViewedCounts()[completedSid] ?? null; + if ({str(is_session_viewed).lower()}) {{ + _markSessionViewed(completedSid, completedSession.message_count ?? completedMessageCount); + }} + console.log(JSON.stringify({{ + hasPreListSync:{json.dumps(has_pre_list_sync)}, + unreadAfterCacheUpdate, + viewedCountAfterCacheUpdate, + unreadAfterActiveBranchSync:_hasUnreadForSession(cacheRow), + hasCompletionUnread:_hasSessionCompletionUnread(completedSid), + viewedCount:_getSessionViewedCounts()[completedSid] ?? null, + cacheRow, + }})); + """ + ) + result = subprocess.run( + ["node", "-e", harness], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip()) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_actively_viewed_done_completion_stays_read_after_sidebar_cache_update(): + result = _run_done_compaction_harness(is_session_viewed=True) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 4 + assert result["unreadAfterCacheUpdate"] is False, ( + "actively viewed done settle must sync viewed_count before the sidebar cache " + "re-render sees the higher compacted message_count" + ) + assert result["viewedCountAfterCacheUpdate"] == 4 + assert result["unreadAfterActiveBranchSync"] is False + assert result["hasCompletionUnread"] is False + assert result["viewedCount"] == 4 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_actively_viewed_done_completion_uses_messages_fallback_when_message_count_missing(): + result = _run_done_compaction_harness( + is_session_viewed=True, + include_message_count=False, + ) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 4 + assert result["unreadAfterCacheUpdate"] is False + assert result["viewedCountAfterCacheUpdate"] == 4 + assert result["unreadAfterActiveBranchSync"] is False + assert result["hasCompletionUnread"] is False + assert result["viewedCount"] == 4 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_actively_viewed_done_completion_uses_session_fallback_when_payload_counts_are_missing(): + result = _run_done_compaction_harness( + is_session_viewed=True, + include_message_count=False, + include_completed_messages=False, + ) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 3 + assert result["unreadAfterCacheUpdate"] is False + assert result["viewedCountAfterCacheUpdate"] == 4 + assert result["unreadAfterActiveBranchSync"] is False + assert result["hasCompletionUnread"] is False + assert result["viewedCount"] == 4 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_actively_viewed_done_completion_uses_visible_messages_fallback_when_session_count_is_missing(): + result = _run_done_compaction_harness( + is_session_viewed=True, + include_message_count=False, + include_completed_messages=False, + session_sid_matches_completed=False, + session_message_count=None, + ) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 3 + assert result["unreadAfterCacheUpdate"] is False + assert result["viewedCountAfterCacheUpdate"] == 4 + assert result["unreadAfterActiveBranchSync"] is False + assert result["hasCompletionUnread"] is False + assert result["viewedCount"] == 4 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_background_done_completion_stays_unread_after_sidebar_cache_update(): + result = _run_done_compaction_harness(is_session_viewed=False) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 4 + assert result["unreadAfterCacheUpdate"] is True + assert result["viewedCountAfterCacheUpdate"] == 3 + assert result["unreadAfterActiveBranchSync"] is True + assert result["hasCompletionUnread"] is True + assert result["viewedCount"] == 3