fix(#5273): sync active viewed count on done settle

This commit is contained in:
Rod Boev 2026-06-30 08:31:01 -04:00
parent 7c96296bf5
commit 85fac54831
2 changed files with 267 additions and 1 deletions

View file

@ -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.

View file

@ -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