Files
hermes-webui/tests/test_issue5273_active_done_viewed_sync.py
T

263 lines
10 KiB
Python

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 _completed_message_count_assignment() -> str:
body = _done_handler_body()
marker = "const completedMessageCount="
start = body.index(marker)
end = body.index(";\n", start) + 1
return body[start:end]
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()
completed_message_count_assignment = textwrap.indent(
_completed_message_count_assignment(),
" ",
).rstrip()
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;
{completed_message_count_assignment}
if ({str(is_session_viewed).lower()} && {json.dumps(has_pre_list_sync)}) {{
_markSessionViewed(completedSid, completedMessageCount);
}}
if (!{str(is_session_viewed).lower()}) {{
_markSessionCompletionUnread(completedSid, completedMessageCount);
}}
_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, completedMessageCount);
}}
console.log(JSON.stringify({{
hasPreListSync:{json.dumps(has_pre_list_sync)},
unreadAfterCacheUpdate,
viewedCountAfterCacheUpdate,
unreadAfterActiveBranchSync:_hasUnreadForSession(cacheRow),
hasCompletionUnread:_hasSessionCompletionUnread(completedSid),
completionUnreadMessageCount:_getSessionCompletionUnread()[completedSid]?.message_count ?? null,
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["completionUnreadMessageCount"] == 4
assert result["viewedCount"] == 3