Merge pull request #5992 from nesquena/stage/5975-cron-scope

Release exp: cron unread scoped to active profile (#5975, closes #5960)
This commit is contained in:
nesquena-hermes 2026-07-12 12:31:30 -07:00 committed by GitHub
commit 3bf21c8ffb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1088 additions and 17 deletions

View file

@ -13,6 +13,8 @@
### Fixed
- **Cron unread ("Tasks") dots are now scoped to the active profile — no more ghost dots after switching.** In multi-profile setups, the session-sidebar unread dot and the Tasks badge could keep showing a cron completion from a *different* profile after you switched, and the count could resurrect from a stale poll. Cron completion markers now carry their source + owning profile, are cleared for the profile you switch away from (including legacy/untagged markers), and a previous-profile poll response can no longer restore a stale unread. Non-cron completion unread and the current profile's own cron unread are preserved; default ↔ renamed-root aliases are treated as the same profile so a root cron marker is neither wrongly cleared nor wrongly kept. Thanks @jbbottoms. (#5975, #5960)
- **The Docker "manual update" banner instruction is now localized.** On Docker/no-git deployments the update banner shows the manual update command; that guidance string was hardcoded in English. It now goes through the i18n system, so it renders in the active language (English fallback where a translation isn't present yet). Thanks @jbbottoms. (#5973, #5959)
- **Cross-platform test suite stabilized.** The profile skill-statistics coverage test now selects a genuinely-incompatible platform per-OS (so the incompatibility assertion holds on macOS as well as Linux), and eight pytest class-scoped instance-fixture deprecation warnings were removed by switching to state-free module fixtures. Test-only; no product code changes. Thanks @tahabakhit. (#5970)

View file

@ -288,7 +288,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
- Token usage display toggle (off by default, also via `/usage` command)
- Control Center always opens on the Conversation tab; resets on close
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
- Cron completion alerts -- toast notifications and unread badges scoped to the active profile on the Tasks tab and session sidebar
- Background agent error alerts -- banner when a non-active session encounters an error
### Slash commands

View file

@ -7004,6 +7004,9 @@ async function switchToProfile(name) {
if (_switchGen !== _profileSwitchGeneration) return false;
S.activeProfile = data.active || name;
S.activeProfileIsDefault = !!data.is_default;
if (typeof _resetCronUnreadForProfileSwitch === 'function') {
_resetCronUnreadForProfileSwitch();
}
const targetActiveProfile = S.activeProfile || 'default';
let sessionProfileMatchesTarget = true;
if (!sessionInProgress && S.session) {
@ -12615,8 +12618,22 @@ async function disableAuth(){
let _cronPollSince=Date.now()/1000; // track from page load
let _cronPollTimer=null;
let _cronUnreadCount=0;
let _cronPollGeneration=0;
const _cronNewJobIds=new Set(); // track which job IDs had new completions (unread)
function _resetCronUnreadForProfileSwitch(){
_cronPollGeneration++;
_cronNewJobIds.clear();
_cronPollSince=Date.now()/1000;
// Clear persisted cron sidebar markers from the profile we left. Non-cron
// completion unread stays intact (#5960 gate: sticky all-profile leak).
if(typeof _clearCronSessionCompletionUnreadForInactiveProfiles==='function'){
const activeProfile=(typeof S!=='undefined'&&S&&S.activeProfile)||'default';
_clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile);
}
updateCronBadge();
}
// Auto-refresh the cron list when a job is created from chat or any external source.
// The chat path dispatches this event when the agent response mentions cron creation.
window.addEventListener('hermes:cron_created', () => {
@ -12628,7 +12645,9 @@ function startCronPolling(){
_cronPollTimer=setInterval(async()=>{
if(document.hidden) return; // don't poll when tab is in background
try{
const pollGeneration=_cronPollGeneration;
const data=await api(`/api/crons/recent?since=${_cronPollSince}`);
if(pollGeneration!==_cronPollGeneration) return;
if(data.completions&&data.completions.length>0){
for(const c of data.completions){
if(c.toast_notifications !== false){
@ -12637,7 +12656,11 @@ function startCronPolling(){
_cronPollSince=Math.max(_cronPollSince,c.completed_at);
if(c.job_id) _cronNewJobIds.add(String(c.job_id));
if(c.session_id && typeof _markSessionCompletionUnreadIfBackground === 'function'){
_markSessionCompletionUnreadIfBackground(c.session_id, c.message_count);
const activeProfile=(typeof S!=='undefined'&&S&&S.activeProfile)||'default';
_markSessionCompletionUnreadIfBackground(c.session_id, c.message_count, {
source:'cron',
profile:activeProfile,
});
}
}
// _cronUnreadCount is derived from _cronNewJobIds.size in updateCronBadge.

View file

@ -505,15 +505,24 @@ function _saveSessionCompletionUnread() {
}
}
function _markSessionCompletionUnread(sid, messageCount = 0) {
function _markSessionCompletionUnread(sid, messageCount = 0, meta = null) {
if (!sid) return;
const unread = _getSessionCompletionUnread();
const count = Number.isFinite(messageCount) ? Number(messageCount) : 0;
unread[sid] = {message_count: count, completed_at: Date.now()};
const entry = {message_count: count, completed_at: Date.now()};
// Cron markers carry source+profile so profile switches can clear only that
// cross-profile leak without wiping ordinary chat completion unread (#5960).
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
if (meta.source) entry.source = String(meta.source);
if (typeof meta.profile === 'string' && meta.profile.trim()) {
entry.profile = meta.profile.trim();
}
}
unread[sid] = entry;
_saveSessionCompletionUnread();
}
function _markSessionCompletionUnreadIfBackground(sid, messageCount = null) {
function _markSessionCompletionUnreadIfBackground(sid, messageCount = null, meta = null) {
if (!sid) return false;
let count = Number.isFinite(messageCount) ? Number(messageCount) : NaN;
if (!Number.isFinite(count)) {
@ -527,7 +536,7 @@ function _markSessionCompletionUnreadIfBackground(sid, messageCount = null) {
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
return false;
}
_markSessionCompletionUnread(sid, count);
_markSessionCompletionUnread(sid, count, meta);
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
return true;
}
@ -540,6 +549,132 @@ function _clearSessionCompletionUnread(sid) {
_saveSessionCompletionUnread();
}
// True when a session row is a cron-origin session for unread-dot scoping.
function _isCronSessionForUnread(session) {
if (!session) return false;
const key = (typeof _sourceKeyForSession === 'function')
? _sourceKeyForSession(session)
: String(
session.raw_source
|| session.source_tag
|| session.source
|| session.session_source
|| ''
).toLowerCase();
if (key === 'cron') return true;
return String(session.session_source || '').toLowerCase() === 'cron';
}
// Build {source, profile} for a cron session row; null for ordinary chat.
function _cronCompletionUnreadMetaForSession(session) {
if (!_isCronSessionForUnread(session)) return null;
const fromRow = (session && typeof session.profile === 'string' && session.profile.trim())
? session.profile.trim()
: '';
const active = (typeof S !== 'undefined' && S && typeof S.activeProfile === 'string' && S.activeProfile.trim())
? S.activeProfile.trim()
: 'default';
return {source: 'cron', profile: fromRow || active};
}
// Resolve whether a persisted marker is cron and which profile owns it.
// Untagged/legacy markers are migrated from the sidebar session row when known.
function _resolveCronCompletionMarkerOrigin(sid, marker) {
let isCron = !!(marker && marker.source === 'cron');
let profile = (marker && typeof marker.profile === 'string' && marker.profile.trim())
? marker.profile.trim()
: '';
let session = null;
if (Array.isArray(_allSessions)) {
session = _allSessions.find((s) => s && s.session_id === sid) || null;
}
if (!session && typeof _sessionListSnapshotById !== 'undefined'
&& _sessionListSnapshotById && typeof _sessionListSnapshotById.get === 'function') {
// Snapshot alone lacks source/profile; keep null.
session = null;
}
if (session) {
if (!isCron && _isCronSessionForUnread(session)) isCron = true;
if (!profile) {
const sp = (typeof session.profile === 'string' && session.profile.trim())
? session.profile.trim()
: '';
if (sp) profile = sp;
}
}
// Persist migration so later switches don't re-resolve from a cleared list.
if (marker && isCron) {
if (marker.source !== 'cron') marker.source = 'cron';
if (profile && marker.profile !== profile) marker.profile = profile;
}
return {isCron, profile: profile || ''};
}
// A profile name provably resolving to the root profile: the literal
// 'default' alias, or a roster entry flagged is_default (renamed root).
// Unknown names fail closed — exact-name matching still applies to them.
function _cronProfileNameIsRootAlias(name) {
if (name === 'default') return true;
if (typeof _profilesCache !== 'undefined' && _profilesCache
&& Array.isArray(_profilesCache.profiles)) {
const entry = _profilesCache.profiles.find((p) => p && p.name === name);
if (entry && entry.is_default) return true;
}
return false;
}
// default/renamed-root equivalence for cron-marker ownership (mirrors server
// _profiles_match enough for the active surface: literal 'default' ↔ root).
function _cronMarkerProfileMatchesActive(origin, activeProfile) {
const originName = (typeof origin === 'string' && origin.trim()) ? origin.trim() : '';
const activeName = (typeof activeProfile === 'string' && activeProfile.trim())
? activeProfile.trim()
: 'default';
if (!originName) return false;
if (originName === activeName) return true;
if (typeof _profileMatchesActiveProfile === 'function'
&& _profileMatchesActiveProfile(originName, activeName)) {
return true;
}
// Reverse alias: marker tagged with the renamed-root name while the active
// root surface reports a different alias. Match only when the origin name
// ITSELF provably resolves to the root — never "active is default → match
// all", which under-cleared other profiles' markers on switch to 'default'.
if (typeof S !== 'undefined' && S && S.activeProfileIsDefault
&& typeof _cronProfileNameIsRootAlias === 'function'
&& _cronProfileNameIsRootAlias(originName)) {
return true;
}
return false;
}
// Drop persisted cron unread dots that belong to inactive profiles. Ordinary
// (non-cron) completion markers stay put — sticky all-profile sidebars still
// need those. Called from the shared profile-switch reset in panels.js.
function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) {
const active = (typeof activeProfile === 'string' && activeProfile.trim())
? activeProfile.trim()
: 'default';
const unread = _getSessionCompletionUnread();
let changed = false;
for (const sid of Object.keys(unread)) {
const marker = unread[sid];
if (!marker || typeof marker !== 'object' || Array.isArray(marker)) continue;
const resolved = _resolveCronCompletionMarkerOrigin(sid, marker);
if (!resolved.isCron) continue;
// Only clear when we know the owning profile AND it is not the active one
// (incl. default/renamed-root equivalence). Untagged + unresolvable stays.
if (!resolved.profile) continue;
if (_cronMarkerProfileMatchesActive(resolved.profile, active)) continue;
delete unread[sid];
changed = true;
}
if (!changed) return false;
_saveSessionCompletionUnread();
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
return true;
}
function _clearSessionViewedCount(sid) {
if (!sid) return;
const counts = _getSessionViewedCounts();
@ -1049,7 +1184,26 @@ function _markPollingCompletionUnreadTransitions(sessions) {
const completedPersistedObservedStream = Boolean(observedStreaming && !isStreaming);
if (completedObservedStream || completedPersistedObservedStream || completedWithNewMessages) {
if (!_isSessionActivelyViewedForList(sid)) {
_markSessionCompletionUnread(sid, s.message_count);
// Tag cron session-list markers with source+profile so profile-switch
// reset can clear only inactive-profile cron dots (#5960 / #5975 re-gate).
const meta = (typeof _cronCompletionUnreadMetaForSession === 'function')
? _cronCompletionUnreadMetaForSession(s)
: null;
// Defense: never re-create a cron unread for a non-active profile while
// the sidebar is single-profile (stale pre-switch payloads).
const allProfilesOn = (typeof _showAllProfiles !== 'undefined' && !!_showAllProfiles);
if (
meta
&& meta.source === 'cron'
&& meta.profile
&& !allProfilesOn
&& typeof _cronMarkerProfileMatchesActive === 'function'
&& !_cronMarkerProfileMatchesActive(meta.profile, (typeof S !== 'undefined' && S && S.activeProfile) || 'default')
) {
// Skip mark for inactive-profile cron row.
} else {
_markSessionCompletionUnread(sid, s.message_count, meta);
}
} else {
// Sync viewed count so we don't flag stale unread on tab switch (#3020)
_setSessionViewedCount(sid, messageCount);
@ -1438,6 +1592,9 @@ async function _switchProfileForSessionLoad(profile){
const data=await api('/api/profile/switch',{method:'POST',body:JSON.stringify({name}),timeoutToast:false});
S.activeProfile=data.active||name;
S.activeProfileIsDefault=!!data.is_default;
if(typeof _resetCronUnreadForProfileSwitch==='function'){
_resetCronUnreadForProfileSwitch();
}
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
else localStorage.removeItem('hermes-webui-model');
if(data.default_model) window._defaultModel=data.default_model;
@ -4913,7 +5070,11 @@ function _schedulePendingSessionListApply(){
const payload=_pendingSessionListPayload;
_pendingSessionListPayload=null;
if(payload.gen!==_renderSessionListGen) return;
_applySessionListPayload(payload.sessData,payload.projData);
// Profile switch may have bumped unread gen after the list gen check
// window; still drop completion-marking for the stale pre-switch payload.
_applySessionListPayload(payload.sessData,payload.projData,{
unreadGen:payload.unreadGen,
});
}, Math.max(120, SESSION_LIST_INTERACTION_IDLE_MS));
}
@ -4983,10 +5144,11 @@ function _sessionListRenderSignature(){
]);
}catch(_){ return null; }
}
function _applySessionListPayload(sessData, projData){
function _applySessionListPayload(sessData, projData, opts){
// Server's other_profile_count tells us how many sessions exist outside the
// active profile so the "Show N from other profiles" toggle can render
// without a second round-trip. Stashed on the module for renderSessionListFromCache.
const applyOpts = (opts && typeof opts === 'object') ? opts : {};
_otherProfileCount = sessData.other_profile_count || 0;
_archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0);
_archivedCliCount = Number(sessData.archived_cli_count ?? 0);
@ -5050,7 +5212,16 @@ function _applySessionListPayload(sessData, projData){
const _hadSessionListLoadError = !!_sessionListLoadError;
_sessionListLoadError = null;
_sessionListHasLoadedOnce = true;
_markPollingCompletionUnreadTransitions(_allSessions);
// Greptile #5975 P1: a /api/sessions request started under profile A can
// finish after a switch to B already cleared A's cron markers. The list gen
// check can already have passed (TOCTOU) or a deferred apply can land later.
// Re-validate the profile-switch unread generation (shared with cron poll
// reset via _cronPollGeneration) immediately before marking completions.
const expectedUnreadGen = applyOpts.unreadGen;
const currentUnreadGen = (typeof _cronPollGeneration === 'number') ? _cronPollGeneration : 0;
if (typeof expectedUnreadGen !== 'number' || expectedUnreadGen === currentUnreadGen) {
_markPollingCompletionUnreadTransitions(_allSessions);
}
const isStreaming = _allSessions.some(s => _isSessionEffectivelyStreaming(s));
if (isStreaming) {
startStreamingPoll();
@ -5189,6 +5360,10 @@ function _renderSessionListLoadErrorNote(){
async function _runRenderSessionListRefresh(opts, _gen){
const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);
if(!deferWhileInteracting) _pendingSessionListPayload=null;
// Capture profile-switch unread generation BEFORE the await so a switch
// mid-flight (which increments _cronPollGeneration) invalidates completion
// marking for this response even if list gen checks already passed.
const unreadGen = (typeof _cronPollGeneration === 'number') ? _cronPollGeneration : 0;
try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const sessionListQS = _sessionListQueryString();
@ -5217,11 +5392,11 @@ async function _runRenderSessionListRefresh(opts, _gen){
// renderSessionList(), so that render's payload is the first allowed to paint.
if (_profileSwitchListEmbargo) return;
if(deferWhileInteracting&&_isSessionListUserInteracting()){
_pendingSessionListPayload={gen:_gen,sessData,projData};
_pendingSessionListPayload={gen:_gen,sessData,projData,unreadGen};
_schedulePendingSessionListApply();
return;
}
_applySessionListPayload(sessData,projData);
_applySessionListPayload(sessData,projData,{unreadGen});
}catch(e){
if (_gen !== _renderSessionListGen) return;
// #4671: same embargo guard as the success path — a mid-switch /api/sessions that

View file

@ -33,9 +33,9 @@ def test_polling_payloads_are_deferred_while_user_scrolls_sidebar():
assert "async function _runRenderSessionListRefresh(opts, _gen)" in refresh_block
assert "const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);" in refresh_block
assert "if(deferWhileInteracting&&_isSessionListUserInteracting())" in refresh_block
assert "_pendingSessionListPayload={gen:_gen,sessData,projData};" in refresh_block
assert "_pendingSessionListPayload={gen:_gen,sessData,projData,unreadGen};" in refresh_block
assert "_schedulePendingSessionListApply();" in refresh_block
assert "_applySessionListPayload(sessData,projData);" in refresh_block
assert "_applySessionListPayload(sessData,projData,{unreadGen});" in refresh_block
assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block, (
"deferring sidebar refreshes must preserve background-completion unread semantics"
)

View file

@ -486,6 +486,7 @@ function extractFunc(name) {{
let _cronPollSince = 10;
let _cronPollTimer = null;
let _cronUnreadCount = 0;
let _cronPollGeneration = 0;
const _cronNewJobIds = new Set();
const markCalls = [];
const toastCalls = [];

View file

@ -0,0 +1,867 @@
"""Regression coverage for cross-profile cron unread badges (#5960)."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
NODE = shutil.which("node")
def _extract_function(source: str, name: str) -> str:
start = source.index(f"function {name}(")
if source[max(0, start - 6) : start] == "async ":
start -= 6
brace = source.index("{", start)
depth = 1
pos = brace + 1
while depth and pos < len(source):
if source[pos] == "{":
depth += 1
elif source[pos] == "}":
depth -= 1
pos += 1
assert depth == 0
return source[start:pos]
def test_recent_handler_reuses_dispatcher_cron_context_without_nesting():
dispatch_start = ROUTES_PY.index('if parsed.path == "/api/crons/recent":')
dispatch_end = ROUTES_PY.index('if parsed.path == "/api/crons/status":', dispatch_start)
dispatch = ROUTES_PY[dispatch_start:dispatch_end]
handler_start = ROUTES_PY.index("def _handle_cron_recent(")
handler_end = ROUTES_PY.index("\ndef ", handler_start + 1)
handler = ROUTES_PY[handler_start:handler_end]
assert "with cron_profile_context():" in dispatch
assert "cron_profile_context_for_home" not in handler
def test_successful_profile_switch_resets_unread_cron_state():
switch_start = PANELS_JS.index("async function switchToProfile(name) {")
switch_end = PANELS_JS.index("// ── Cron completion alerts", switch_start)
switch_body = PANELS_JS[switch_start:switch_end]
state_update = switch_body.index("S.activeProfile = data.active || name;")
reset_call = switch_body.index("_resetCronUnreadForProfileSwitch();")
assert reset_call > state_update
reset_start = PANELS_JS.index("function _resetCronUnreadForProfileSwitch(){")
reset_end = PANELS_JS.index("\n}", reset_start)
reset_body = PANELS_JS[reset_start:reset_end]
assert "_cronPollGeneration++;" in reset_body
assert "_cronNewJobIds.clear();" in reset_body
assert "_cronPollSince=Date.now()/1000;" in reset_body
assert "_clearCronSessionCompletionUnreadForInactiveProfiles" in reset_body
assert "updateCronBadge();" in reset_body
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_session_load_profile_switch_resets_unread_cron_state():
switch = _extract_function(SESSIONS_JS, "_switchProfileForSessionLoad")
reset = _extract_function(PANELS_JS, "_resetCronUnreadForProfileSwitch")
script = f"""
let _cronPollSince=10;
let _cronUnreadCount=1;
let _cronPollGeneration=0;
const _cronNewJobIds=new Set(['old-profile-job']);
global.S={{activeProfile:'default'}};
global.api=async()=>({{active:'alternate',is_default:false}});
global.localStorage={{removeItem(){{}}}};
global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }};
function _clearCronSessionCompletionUnreadForInactiveProfiles(){{}}
{reset}
{switch}
(async()=>{{
await _switchProfileForSessionLoad('alternate');
process.stdout.write(JSON.stringify({{
profile:S.activeProfile,
unread:Array.from(_cronNewJobIds),
count:_cronUnreadCount,
generation:_cronPollGeneration,
}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state == {
"profile": "alternate",
"unread": [],
"count": 0,
"generation": 1,
}
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_poll_started_before_switch_cannot_recreate_unread_state():
polling = _extract_function(PANELS_JS, "startCronPolling")
reset = _extract_function(PANELS_JS, "_resetCronUnreadForProfileSwitch")
script = f"""
let _cronPollSince=10;
let _cronPollTimer=null;
let _cronUnreadCount=0;
let _cronPollGeneration=0;
const _cronNewJobIds=new Set();
let intervalCallback=null;
let resolveApi;
global.document={{hidden:false}};
global.setInterval=(callback)=>{{ intervalCallback=callback; return 1; }};
global.api=()=>new Promise(resolve=>{{ resolveApi=resolve; }});
global.showToast=()=>{{}};
global.t=(key)=>key;
global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }};
function _clearCronSessionCompletionUnreadForInactiveProfiles(){{}}
{polling}
{reset}
startCronPolling();
(async()=>{{
const stalePoll=intervalCallback();
_resetCronUnreadForProfileSwitch();
resolveApi({{completions:[{{job_id:'old-profile-job',completed_at:20}}]}});
await stalePoll;
process.stdout.write(JSON.stringify({{
unread:Array.from(_cronNewJobIds),
count:_cronUnreadCount,
generation:_cronPollGeneration,
}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state == {"unread": [], "count": 0, "generation": 1}
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_profile_switch_clears_persisted_old_profile_cron_markers_only():
"""Gate #5975: sticky sidebar must drop old-profile cron dots, keep non-cron."""
mark = _extract_function(SESSIONS_JS, "_markSessionCompletionUnread")
get_unread = _extract_function(SESSIONS_JS, "_getSessionCompletionUnread")
save_unread = _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread")
clear_helpers = "\n".join(
[
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
_extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"),
_extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"),
_extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"),
_extract_function(
SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"
),
]
)
has_unread = _extract_function(SESSIONS_JS, "_hasUnreadForSession")
has_marker = _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread")
reset = _extract_function(PANELS_JS, "_resetCronUnreadForProfileSwitch")
switch = _extract_function(SESSIONS_JS, "_switchProfileForSessionLoad")
script = f"""
const store={{'hermes-session-completion-unread':JSON.stringify({{}})}};
global.localStorage={{
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
setItem:(key,value)=>{{ store[key]=String(value); }},
removeItem:(key)=>{{ delete store[key]; }},
}};
let _sessionCompletionUnread=null;
let _sessionViewedCounts={{}};
const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread';
let _cronPollSince=10;
let _cronUnreadCount=0;
let _cronPollGeneration=0;
const _cronNewJobIds=new Set(['old-cron-job']);
let renders=0;
global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}};
global._allSessions=[];
global.api=async()=>({{active:'profile-b',is_default:false}});
global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }};
global.renderSessionListFromCache=()=>{{ renders+=1; }};
function _getSessionViewedCounts(){{ return _sessionViewedCounts; }}
function _saveSessionViewedCounts(){{}}
function _setSessionViewedCount(sid, count){{
_sessionViewedCounts[sid]=Number(count)||0;
}}
function _clearSessionCompletionUnread(sid){{
const unread=_getSessionCompletionUnread();
if(!Object.prototype.hasOwnProperty.call(unread, sid)) return;
delete unread[sid];
_saveSessionCompletionUnread();
}}
{get_unread}
{save_unread}
{mark}
{has_marker}
{has_unread}
{clear_helpers}
{reset}
{switch}
(async()=>{{
_markSessionCompletionUnread('old-cron-session', 4, {{source:'cron', profile:'profile-a'}});
_markSessionCompletionUnread('chat-session', 9); // ordinary completion — keep
_markSessionCompletionUnread('new-cron-session', 2, {{source:'cron', profile:'profile-b'}});
const before={{
oldCron:_hasUnreadForSession({{session_id:'old-cron-session'}}),
chat:_hasUnreadForSession({{session_id:'chat-session'}}),
newCron:_hasUnreadForSession({{session_id:'new-cron-session'}}),
}};
await _switchProfileForSessionLoad('profile-b');
const after={{
oldCron:_hasUnreadForSession({{session_id:'old-cron-session'}}),
chat:_hasUnreadForSession({{session_id:'chat-session'}}),
newCron:_hasUnreadForSession({{session_id:'new-cron-session'}}),
badgeJobs:Array.from(_cronNewJobIds),
badgeCount:_cronUnreadCount,
generation:_cronPollGeneration,
renders,
persisted:JSON.parse(store['hermes-session-completion-unread']),
}};
process.stdout.write(JSON.stringify({{before, after}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state["before"] == {"oldCron": True, "chat": True, "newCron": True}
assert state["after"]["oldCron"] is False
assert state["after"]["chat"] is True
assert state["after"]["newCron"] is True
assert state["after"]["badgeJobs"] == []
assert state["after"]["badgeCount"] == 0
assert state["after"]["generation"] == 1
assert state["after"]["renders"] >= 1
assert "old-cron-session" not in state["after"]["persisted"]
assert "chat-session" in state["after"]["persisted"]
assert "new-cron-session" in state["after"]["persisted"]
assert state["after"]["persisted"]["new-cron-session"]["source"] == "cron"
assert state["after"]["persisted"]["new-cron-session"]["profile"] == "profile-b"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_cron_poll_tags_persisted_markers_with_active_profile():
polling = _extract_function(PANELS_JS, "startCronPolling")
script = f"""
let _cronPollSince=10;
let _cronPollTimer=null;
let _cronUnreadCount=0;
let _cronPollGeneration=0;
const _cronNewJobIds=new Set();
const markCalls=[];
let intervalCallback=null;
global.document={{hidden:false}};
global.S={{activeProfile:'profile-a'}};
global.setInterval=(callback)=>{{ intervalCallback=callback; return 1; }};
global.api=async()=>({{
completions:[{{
job_id:'job-a',
session_id:'cron-session-a',
message_count:3,
completed_at:20,
toast_notifications:false,
}}]
}});
global.showToast=()=>{{}};
global.t=(key)=>key;
global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }};
function _markSessionCompletionUnreadIfBackground(sid, count, meta){{
markCalls.push([sid, count, meta]);
}}
{polling}
startCronPolling();
(async()=>{{
await intervalCallback();
process.stdout.write(JSON.stringify({{markCalls, unreadJobs:Array.from(_cronNewJobIds)}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state["unreadJobs"] == ["job-a"]
assert state["markCalls"] == [
["cron-session-a", 3, {"source": "cron", "profile": "profile-a"}]
]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_session_list_path_tags_cron_markers_with_source_and_profile():
"""Re-gate #5975: _markPollingCompletionUnreadTransitions must tag cron rows."""
mark_poll = _extract_function(SESSIONS_JS, "_markPollingCompletionUnreadTransitions")
is_cron = _extract_function(SESSIONS_JS, "_isCronSessionForUnread")
meta_fn = _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession")
source_key = _extract_function(SESSIONS_JS, "_sourceKeyForSession")
match_fn = _extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive")
profile_match = _extract_function(SESSIONS_JS, "_profileMatchesActiveProfile")
script = f"""
const markCalls=[];
global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}};
let _showAllProfiles=false;
global._allSessions=[];
global._sessionListSnapshotById=new Map();
global._sessionStreamingById=new Map();
global._sessionListSourceById=new Map();
global._allSessionsScope=null;
function _getSessionObservedStreaming(){{ return {{}}; }}
function _isSessionEffectivelyStreaming(){{ return false; }}
function _hasPendingUserMessageSignal(){{ return false; }}
function _isSessionActivelyViewedForList(){{ return false; }}
function _rememberSessionListSource(){{}}
function _rememberObservedStreamingSession(){{}}
function _forgetObservedStreamingSession(){{}}
function _setSessionViewedCount(){{}}
function _markSessionCompletionUnread(sid, count, meta){{
markCalls.push([sid, count, meta||null]);
}}
{source_key}
{is_cron}
{meta_fn}
{profile_match}
{match_fn}
{mark_poll}
const sessions=[
{{
session_id:'cron-from-list',
message_count:2,
last_message_at:20,
source_tag:'cron',
profile:'profile-a',
is_streaming:false,
}},
{{
session_id:'chat-from-list',
message_count:5,
last_message_at:30,
source_tag:'webui',
profile:'profile-a',
is_streaming:false,
}},
];
// Pretend both previously streaming so completion transition fires.
_sessionStreamingById.set('cron-from-list', true);
_sessionStreamingById.set('chat-from-list', true);
_sessionListSnapshotById.set('cron-from-list', {{message_count:1, last_message_at:10}});
_sessionListSnapshotById.set('chat-from-list', {{message_count:4, last_message_at:10}});
_markPollingCompletionUnreadTransitions(sessions);
process.stdout.write(JSON.stringify({{markCalls}}));
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
by_sid = {row[0]: row for row in state["markCalls"]}
assert "cron-from-list" in by_sid
assert by_sid["cron-from-list"][2] == {"source": "cron", "profile": "profile-a"}
assert "chat-from-list" in by_sid
assert by_sid["chat-from-list"][2] is None
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_legacy_untagged_cron_marker_cleared_via_sidebar_metadata():
"""Re-gate #5975: untagged markers resolve from sidebar session source/profile."""
helpers = "\n".join(
[
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
_extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"),
_extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"),
_extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"),
_extract_function(SESSIONS_JS, "_getSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"),
_extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_hasUnreadForSession"),
]
)
script = f"""
const store={{'hermes-session-completion-unread':JSON.stringify({{
'legacy-cron':{{message_count:2, completed_at:1}},
'chat':{{message_count:4, completed_at:1}},
}})}};
global.localStorage={{
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
setItem:(key,value)=>{{ store[key]=String(value); }},
removeItem:(key)=>{{ delete store[key]; }},
}};
let _sessionCompletionUnread=null;
let _sessionViewedCounts={{}};
const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread';
global.S={{activeProfile:'profile-b',activeProfileIsDefault:false}};
global._allSessions=[
{{session_id:'legacy-cron', source_tag:'cron', profile:'profile-a', message_count:2}},
{{session_id:'chat', source_tag:'webui', profile:'profile-a', message_count:4}},
];
global.renderSessionListFromCache=()=>{{}};
function _getSessionViewedCounts(){{ return _sessionViewedCounts; }}
function _setSessionViewedCount(){{}}
{helpers}
const before={{
legacy:_hasUnreadForSession({{session_id:'legacy-cron'}}),
chat:_hasUnreadForSession({{session_id:'chat'}}),
}};
_clearCronSessionCompletionUnreadForInactiveProfiles('profile-b');
const after={{
legacy:_hasUnreadForSession({{session_id:'legacy-cron'}}),
chat:_hasUnreadForSession({{session_id:'chat'}}),
persisted:JSON.parse(store['hermes-session-completion-unread']),
}};
process.stdout.write(JSON.stringify({{before, after}}));
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state["before"] == {"legacy": True, "chat": True}
assert state["after"]["legacy"] is False
assert state["after"]["chat"] is True
assert "legacy-cron" not in state["after"]["persisted"]
assert "chat" in state["after"]["persisted"]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_root_alias_keeps_current_profile_cron_marker():
"""Re-gate #5975: default/renamed-root must not erase current-root cron dots."""
helpers = "\n".join(
[
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
_extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"),
_extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"),
_extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"),
_extract_function(SESSIONS_JS, "_getSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"),
_extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_hasUnreadForSession"),
]
)
script = f"""
const store={{'hermes-session-completion-unread':JSON.stringify({{
'root-cron':{{message_count:2, completed_at:1, source:'cron', profile:'default'}},
'other-cron':{{message_count:1, completed_at:1, source:'cron', profile:'other'}},
}})}};
global.localStorage={{
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
setItem:(key,value)=>{{ store[key]=String(value); }},
removeItem:(key)=>{{ delete store[key]; }},
}};
let _sessionCompletionUnread=null;
let _sessionViewedCounts={{}};
const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread';
// Renamed root profile is active.
global.S={{activeProfile:'kinni',activeProfileIsDefault:true}};
global._allSessions=[];
global.renderSessionListFromCache=()=>{{}};
function _getSessionViewedCounts(){{ return _sessionViewedCounts; }}
function _setSessionViewedCount(){{}}
{helpers}
_clearCronSessionCompletionUnreadForInactiveProfiles('kinni');
const persisted=JSON.parse(store['hermes-session-completion-unread']);
process.stdout.write(JSON.stringify({{
rootKept:_hasUnreadForSession({{session_id:'root-cron'}}),
otherCleared:!_hasUnreadForSession({{session_id:'other-cron'}}),
persisted,
}}));
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state["rootKept"] is True
assert state["otherCleared"] is True
assert "root-cron" in state["persisted"]
assert "other-cron" not in state["persisted"]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_switch_to_literal_default_clears_other_profile_cron_markers():
"""Re-gate #5975 r3: active literal 'default' must not match every origin.
The reverse alias may only keep a marker whose origin name itself provably
resolves to the root profile — tagged AND legacy-untagged old-profile cron
markers must clear when switching to 'default'.
"""
helpers = "\n".join(
[
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
_extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"),
_extract_function(SESSIONS_JS, "_cronProfileNameIsRootAlias"),
_extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"),
_extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"),
_extract_function(SESSIONS_JS, "_getSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"),
_extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_hasUnreadForSession"),
]
)
script = f"""
const store={{'hermes-session-completion-unread':JSON.stringify({{
'tagged-old-cron':{{message_count:3, completed_at:1, source:'cron', profile:'profile-a'}},
'legacy-old-cron':{{message_count:2, completed_at:1}},
'root-cron':{{message_count:2, completed_at:1, source:'cron', profile:'default'}},
'renamed-root-cron':{{message_count:1, completed_at:1, source:'cron', profile:'kinni'}},
'plain-chat':{{message_count:5, completed_at:1}},
}})}};
global.localStorage={{
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
setItem:(key,value)=>{{ store[key]=String(value); }},
removeItem:(key)=>{{ delete store[key]; }},
}};
let _sessionCompletionUnread=null;
let _sessionViewedCounts={{}};
const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread';
// Root profile is active under its literal 'default' name.
global.S={{activeProfile:'default',activeProfileIsDefault:true}};
// Sidebar metadata resolves the legacy-untagged marker to profile-a cron;
// 'plain-chat' stays an ordinary completion (no cron source anywhere).
global._allSessions=[
{{session_id:'legacy-old-cron', source:'cron', profile:'profile-a'}},
{{session_id:'plain-chat', source:'chat', profile:'profile-a'}},
];
// Roster proves 'kinni' is a root alias; 'profile-a' is not.
global._profilesCache={{profiles:[
{{name:'kinni', is_default:true}},
{{name:'profile-a', is_default:false}},
]}};
global.renderSessionListFromCache=()=>{{}};
function _getSessionViewedCounts(){{ return _sessionViewedCounts; }}
function _setSessionViewedCount(){{}}
{helpers}
_clearCronSessionCompletionUnreadForInactiveProfiles('default');
const persisted=JSON.parse(store['hermes-session-completion-unread']);
process.stdout.write(JSON.stringify({{
taggedCleared:!_hasUnreadForSession({{session_id:'tagged-old-cron'}}),
legacyCleared:!_hasUnreadForSession({{session_id:'legacy-old-cron'}}),
rootKept:_hasUnreadForSession({{session_id:'root-cron'}}),
renamedRootKept:_hasUnreadForSession({{session_id:'renamed-root-cron'}}),
chatKept:_hasUnreadForSession({{session_id:'plain-chat'}}),
persisted,
}}));
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state["taggedCleared"] is True
assert state["legacyCleared"] is True
assert state["rootKept"] is True
assert state["renamedRootKept"] is True
assert state["chatKept"] is True
assert "tagged-old-cron" not in state["persisted"]
assert "legacy-old-cron" not in state["persisted"]
assert "root-cron" in state["persisted"]
assert "renamed-root-cron" in state["persisted"]
assert "plain-chat" in state["persisted"]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_switch_to_literal_default_without_roster_fails_closed_on_unknown_names():
"""Re-gate #5975 r3: with no roster, an unknown origin under active 'default'
gets exact-name semantics (cleared), and literal-'default' markers stay."""
helpers = "\n".join(
[
_extract_function(SESSIONS_JS, "_isCronSessionForUnread"),
_extract_function(SESSIONS_JS, "_sourceKeyForSession"),
_extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"),
_extract_function(SESSIONS_JS, "_resolveCronCompletionMarkerOrigin"),
_extract_function(SESSIONS_JS, "_cronProfileNameIsRootAlias"),
_extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive"),
_extract_function(SESSIONS_JS, "_profileMatchesActiveProfile"),
_extract_function(SESSIONS_JS, "_getSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_clearCronSessionCompletionUnreadForInactiveProfiles"),
_extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"),
_extract_function(SESSIONS_JS, "_hasUnreadForSession"),
]
)
script = f"""
const store={{'hermes-session-completion-unread':JSON.stringify({{
'other-cron':{{message_count:1, completed_at:1, source:'cron', profile:'profile-a'}},
'root-cron':{{message_count:2, completed_at:1, source:'cron', profile:'default'}},
}})}};
global.localStorage={{
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
setItem:(key,value)=>{{ store[key]=String(value); }},
removeItem:(key)=>{{ delete store[key]; }},
}};
let _sessionCompletionUnread=null;
let _sessionViewedCounts={{}};
const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread';
global.S={{activeProfile:'default',activeProfileIsDefault:true}};
global._allSessions=[];
global.renderSessionListFromCache=()=>{{}};
function _getSessionViewedCounts(){{ return _sessionViewedCounts; }}
function _setSessionViewedCount(){{}}
{helpers}
_clearCronSessionCompletionUnreadForInactiveProfiles('default');
const persisted=JSON.parse(store['hermes-session-completion-unread']);
process.stdout.write(JSON.stringify({{
otherCleared:!_hasUnreadForSession({{session_id:'other-cron'}}),
rootKept:_hasUnreadForSession({{session_id:'root-cron'}}),
persisted,
}}));
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state["otherCleared"] is True
assert state["rootKept"] is True
assert "other-cron" not in state["persisted"]
assert "root-cron" in state["persisted"]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_stale_pre_switch_session_list_does_not_recreate_cron_markers():
"""Greptile #5975 P1: delayed /api/sessions after profile switch must not remount cron dots."""
# Re-read sources so this test always sees the latest shipped functions.
sessions_js = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
panels_js = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
apply_fn = _extract_function(sessions_js, "_applySessionListPayload")
mark_poll = _extract_function(sessions_js, "_markPollingCompletionUnreadTransitions")
helpers = "\n".join(
[
_extract_function(sessions_js, "_isCronSessionForUnread"),
_extract_function(sessions_js, "_sourceKeyForSession"),
_extract_function(sessions_js, "_cronCompletionUnreadMetaForSession"),
_extract_function(sessions_js, "_resolveCronCompletionMarkerOrigin"),
_extract_function(sessions_js, "_cronMarkerProfileMatchesActive"),
_extract_function(sessions_js, "_profileMatchesActiveProfile"),
_extract_function(sessions_js, "_getSessionCompletionUnread"),
_extract_function(sessions_js, "_saveSessionCompletionUnread"),
_extract_function(sessions_js, "_markSessionCompletionUnread"),
_extract_function(sessions_js, "_hasSessionCompletionUnread"),
_extract_function(sessions_js, "_hasUnreadForSession"),
_extract_function(sessions_js, "_clearCronSessionCompletionUnreadForInactiveProfiles"),
_extract_function(panels_js, "_resetCronUnreadForProfileSwitch"),
]
)
script = f"""
const store={{'hermes-session-completion-unread':JSON.stringify({{}})}};
global.localStorage={{
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
setItem:(key,value)=>{{ store[key]=String(value); }},
removeItem:(key)=>{{ delete store[key]; }},
}};
let _sessionCompletionUnread=null;
let _sessionViewedCounts={{}};
const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread';
let _cronPollGeneration=0;
let _cronPollSince=10;
let _cronUnreadCount=0;
const _cronNewJobIds=new Set(['old-job']);
let _allSessions=[];
let _allSessionsScope=null;
let _sidebarReferenceSessions=[];
let _otherProfileCount=0;
let _archivedWebuiCount=0;
let _archivedCliCount=0;
let _serverWebuiSessionCount=null;
let _serverCliSessionCount=null;
let _serverTimeDelta=0;
let _serverTz=null;
let _sessionListLoadError=null;
let _sessionListHasLoadedOnce=false;
let _sessionListFirstRenderAnimated=true;
let _showAllProfiles=false;
const _optimisticallyRemovedSessionIds=new Set();
const _sessionStreamingById=new Map([['old-cron', true]]);
const _sessionListSnapshotById=new Map([['old-cron', {{message_count:1, last_message_at:10}}]]);
const _sessionListSourceById=new Map();
global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}};
global.renderSessionListFromCache=()=>{{}};
global.updateCronBadge=()=>{{ _cronUnreadCount=_cronNewJobIds.size; }};
function _getSessionViewedCounts(){{ return _sessionViewedCounts; }}
function _setSessionViewedCount(){{}}
function _getSessionObservedStreaming(){{ return {{}}; }}
function _isSessionEffectivelyStreaming(){{ return false; }}
function _hasPendingUserMessageSignal(){{ return false; }}
function _isSessionActivelyViewedForList(){{ return false; }}
function _rememberSessionListSource(){{}}
function _rememberObservedStreamingSession(){{}}
function _forgetObservedStreamingSession(){{}}
function _reconcileActiveSessionIdleStateFromList(){{}}
function _mergeOptimisticFirstTurnSessions(s){{ return s; }}
function _recordSessionProfileCount(){{}}
function _syncSessionAttentionSoundState(){{}}
function _pruneLineageReportCacheToVisibleSessions(){{}}
function _requestedSessionSidebarSource(){{ return 'webui'; }}
function _sessionListExcludeHiddenEnabled(){{ return false; }}
function startStreamingPoll(){{}}
function stopStreamingPoll(){{}}
function ensureSessionTimeRefreshPoll(){{}}
function ensureActiveSessionExternalRefreshPoll(){{}}
function animateNextSessionListRefresh(){{}}
function ensureSessionEventsSSE(){{}}
function _activeSessionIdForSidebar(){{ return null; }}
function _sessionListRenderSignature(){{ return 'sig'; }}
function _purgeStaleInflightEntries(){{}}
let _sessionListSkeletonActive=false;
let _sessionListRefreshAnimationPending=false;
let _lastSessionListRenderSig=null;
let _renamingSid=null;
let _sessionActionMenu=null;
let _allProjects=[];
{helpers}
{mark_poll}
{apply_fn}
// Snapshot generation under profile A (request starts).
const unreadGenAtStart=_cronPollGeneration;
// Profile switch to B: bumps unread gen + clears markers.
global.S.activeProfile='profile-b';
_resetCronUnreadForProfileSwitch();
// Delayed pre-switch /api/sessions payload (profile A cron still streaming→done).
const sessData={{
sessions:[{{
session_id:'old-cron',
message_count:2,
last_message_at:20,
source_tag:'cron',
profile:'profile-a',
is_streaming:false,
}}],
active_profile:'profile-a',
other_profile_count:0,
}};
_applySessionListPayload(sessData, {{projects:[]}}, {{unreadGen:unreadGenAtStart}});
const persisted=JSON.parse(store['hermes-session-completion-unread']||'{{}}');
process.stdout.write(JSON.stringify({{
generation:_cronPollGeneration,
badgeJobs:Array.from(_cronNewJobIds),
oldCronMarked:Object.prototype.hasOwnProperty.call(persisted,'old-cron'),
persisted,
}}));
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state["generation"] == 1
assert state["badgeJobs"] == []
assert state["oldCronMarked"] is False
assert "old-cron" not in state["persisted"]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_fresh_session_list_still_marks_when_unread_gen_matches():
"""Sanity: matching unreadGen still allows completion marks after switch."""
sessions_js = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
apply_fn = _extract_function(sessions_js, "_applySessionListPayload")
mark_poll = _extract_function(sessions_js, "_markPollingCompletionUnreadTransitions")
helpers = "\n".join(
[
_extract_function(sessions_js, "_isCronSessionForUnread"),
_extract_function(sessions_js, "_sourceKeyForSession"),
_extract_function(sessions_js, "_cronCompletionUnreadMetaForSession"),
_extract_function(sessions_js, "_cronMarkerProfileMatchesActive"),
_extract_function(sessions_js, "_profileMatchesActiveProfile"),
_extract_function(sessions_js, "_getSessionCompletionUnread"),
_extract_function(sessions_js, "_saveSessionCompletionUnread"),
_extract_function(sessions_js, "_markSessionCompletionUnread"),
_extract_function(sessions_js, "_hasSessionCompletionUnread"),
_extract_function(sessions_js, "_hasUnreadForSession"),
]
)
script = f"""
const store={{'hermes-session-completion-unread':JSON.stringify({{}})}};
global.localStorage={{
getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null,
setItem:(key,value)=>{{ store[key]=String(value); }},
removeItem:(key)=>{{ delete store[key]; }},
}};
let _sessionCompletionUnread=null;
let _sessionViewedCounts={{}};
const SESSION_COMPLETION_UNREAD_KEY='hermes-session-completion-unread';
let _cronPollGeneration=3;
let _allSessions=[];
let _allSessionsScope=null;
let _sidebarReferenceSessions=[];
let _otherProfileCount=0;
let _archivedWebuiCount=0;
let _archivedCliCount=0;
let _serverWebuiSessionCount=null;
let _serverCliSessionCount=null;
let _serverTimeDelta=0;
let _serverTz=null;
let _sessionListLoadError=null;
let _sessionListHasLoadedOnce=false;
let _sessionListFirstRenderAnimated=true;
let _showAllProfiles=false;
const _optimisticallyRemovedSessionIds=new Set();
const _sessionStreamingById=new Map([['new-cron', true]]);
const _sessionListSnapshotById=new Map([['new-cron', {{message_count:1, last_message_at:10}}]]);
const _sessionListSourceById=new Map();
global.S={{activeProfile:'profile-b',activeProfileIsDefault:false}};
global.renderSessionListFromCache=()=>{{}};
function _getSessionViewedCounts(){{ return _sessionViewedCounts; }}
function _setSessionViewedCount(){{}}
function _getSessionObservedStreaming(){{ return {{}}; }}
function _isSessionEffectivelyStreaming(){{ return false; }}
function _hasPendingUserMessageSignal(){{ return false; }}
function _isSessionActivelyViewedForList(){{ return false; }}
function _rememberSessionListSource(){{}}
function _rememberObservedStreamingSession(){{}}
function _forgetObservedStreamingSession(){{}}
function _reconcileActiveSessionIdleStateFromList(){{}}
function _mergeOptimisticFirstTurnSessions(s){{ return s; }}
function _recordSessionProfileCount(){{}}
function _syncSessionAttentionSoundState(){{}}
function _pruneLineageReportCacheToVisibleSessions(){{}}
function _requestedSessionSidebarSource(){{ return 'webui'; }}
function _sessionListExcludeHiddenEnabled(){{ return false; }}
function startStreamingPoll(){{}}
function stopStreamingPoll(){{}}
function ensureSessionTimeRefreshPoll(){{}}
function ensureActiveSessionExternalRefreshPoll(){{}}
function animateNextSessionListRefresh(){{}}
function ensureSessionEventsSSE(){{}}
function _sessionListRenderSignature(){{ return 'sig'; }}
function _purgeStaleInflightEntries(){{}}
let _sessionListSkeletonActive=false;
let _sessionListRefreshAnimationPending=false;
let _lastSessionListRenderSig=null;
let _renamingSid=null;
let _sessionActionMenu=null;
let _allProjects=[];
{helpers}
{mark_poll}
{apply_fn}
_applySessionListPayload({{
sessions:[{{
session_id:'new-cron',
message_count:2,
last_message_at:20,
source_tag:'cron',
profile:'profile-b',
is_streaming:false,
}}],
active_profile:'profile-b',
}}, {{projects:[]}}, {{unreadGen:3}});
const persisted=JSON.parse(store['hermes-session-completion-unread']||'{{}}');
process.stdout.write(JSON.stringify({{
marked:Object.prototype.hasOwnProperty.call(persisted,'new-cron'),
meta:persisted['new-cron']||null,
}}));
"""
result = subprocess.run(
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
)
state = json.loads(result.stdout)
assert state["marked"] is True
assert state["meta"]["source"] == "cron"
assert state["meta"]["profile"] == "profile-b"

View file

@ -159,10 +159,13 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
assert "wasStreaming === true && !isStreaming" in transition_block, (
"polling fallback must only fire on an observed streaming -> stopped transition"
)
assert "_markSessionCompletionUnread(sid, s.message_count);" in transition_block
# #5960/#5975: third arg may carry cron source+profile meta; still mark unread.
assert "_markSessionCompletionUnread(sid, s.message_count" in transition_block
assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block
assert "const _streamingPollMs = 30000;" in SESSIONS_JS
assert "_applySessionListPayload(sessData,projData);" in refresh_block
# Greptile #5975: apply carries unreadGen so stale pre-switch lists skip mark.
assert "_applySessionListPayload(sessData,projData,{unreadGen});" in refresh_block
assert "unreadGen" in refresh_block
assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block
assert "_allSessions.some(s => _isSessionEffectivelyStreaming(s))" in apply_block, (
"the streaming poll fallback must stay active for the same server-confirmed "

View file

@ -53,7 +53,7 @@ def test_sessions_and_projects_load_independently_so_projects_failure_cannot_bla
assert "return await api('/api/projects' + projectQS,{timeoutToast:false});" in helper
assert "console.warn('renderProjectsList',projectError);" in helper
assert "const projData = await projectPromise;" in helper
assert "_applySessionListPayload(sessData,projData)" in block
assert "_applySessionListPayload(sessData,projData,{unreadGen})" in block
def test_sessions_api_always_retries_transient_upstream_statuses_and_boot_keeps_longer_timeout():