fix(cron): drop stale pre-switch session-list completion marks
Greptile #5975 P1: capture _cronPollGeneration before /api/sessions await and re-check in _applySessionListPayload before completion-unread marking so a delayed profile-A response cannot remount cron dots after switch to B. Also skip inactive-profile cron marks in single-profile mode. Tests cover the race, positive path, and collateral call-shape contracts.
This commit is contained in:
1 parent
b17e76d171
commit
617c1f451d
5 files changed
+290
-10
No files matched your search
+38
-6
@@ -1172,7 +1172,21 @@ function _markPollingCompletionUnreadTransitions(sessions) {
|
|||||||
const meta = (typeof _cronCompletionUnreadMetaForSession === 'function')
|
const meta = (typeof _cronCompletionUnreadMetaForSession === 'function')
|
||||||
? _cronCompletionUnreadMetaForSession(s)
|
? _cronCompletionUnreadMetaForSession(s)
|
||||||
: null;
|
: null;
|
||||||
_markSessionCompletionUnread(sid, s.message_count, meta);
|
// 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 {
|
} else {
|
||||||
// Sync viewed count so we don't flag stale unread on tab switch (#3020)
|
// Sync viewed count so we don't flag stale unread on tab switch (#3020)
|
||||||
_setSessionViewedCount(sid, messageCount);
|
_setSessionViewedCount(sid, messageCount);
|
||||||
@@ -5039,7 +5053,11 @@ function _schedulePendingSessionListApply(){
|
|||||||
const payload=_pendingSessionListPayload;
|
const payload=_pendingSessionListPayload;
|
||||||
_pendingSessionListPayload=null;
|
_pendingSessionListPayload=null;
|
||||||
if(payload.gen!==_renderSessionListGen) return;
|
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));
|
}, Math.max(120, SESSION_LIST_INTERACTION_IDLE_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5109,10 +5127,11 @@ function _sessionListRenderSignature(){
|
|||||||
]);
|
]);
|
||||||
}catch(_){ return null; }
|
}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
|
// 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
|
// active profile so the "Show N from other profiles" toggle can render
|
||||||
// without a second round-trip. Stashed on the module for renderSessionListFromCache.
|
// without a second round-trip. Stashed on the module for renderSessionListFromCache.
|
||||||
|
const applyOpts = (opts && typeof opts === 'object') ? opts : {};
|
||||||
_otherProfileCount = sessData.other_profile_count || 0;
|
_otherProfileCount = sessData.other_profile_count || 0;
|
||||||
_archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0);
|
_archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0);
|
||||||
_archivedCliCount = Number(sessData.archived_cli_count ?? 0);
|
_archivedCliCount = Number(sessData.archived_cli_count ?? 0);
|
||||||
@@ -5176,7 +5195,16 @@ function _applySessionListPayload(sessData, projData){
|
|||||||
const _hadSessionListLoadError = !!_sessionListLoadError;
|
const _hadSessionListLoadError = !!_sessionListLoadError;
|
||||||
_sessionListLoadError = null;
|
_sessionListLoadError = null;
|
||||||
_sessionListHasLoadedOnce = true;
|
_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));
|
const isStreaming = _allSessions.some(s => _isSessionEffectivelyStreaming(s));
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
startStreamingPoll();
|
startStreamingPoll();
|
||||||
@@ -5315,6 +5343,10 @@ function _renderSessionListLoadErrorNote(){
|
|||||||
async function _runRenderSessionListRefresh(opts, _gen){
|
async function _runRenderSessionListRefresh(opts, _gen){
|
||||||
const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);
|
const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);
|
||||||
if(!deferWhileInteracting) _pendingSessionListPayload=null;
|
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{
|
try{
|
||||||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||||||
const sessionListQS = _sessionListQueryString();
|
const sessionListQS = _sessionListQueryString();
|
||||||
@@ -5343,11 +5375,11 @@ async function _runRenderSessionListRefresh(opts, _gen){
|
|||||||
// renderSessionList(), so that render's payload is the first allowed to paint.
|
// renderSessionList(), so that render's payload is the first allowed to paint.
|
||||||
if (_profileSwitchListEmbargo) return;
|
if (_profileSwitchListEmbargo) return;
|
||||||
if(deferWhileInteracting&&_isSessionListUserInteracting()){
|
if(deferWhileInteracting&&_isSessionListUserInteracting()){
|
||||||
_pendingSessionListPayload={gen:_gen,sessData,projData};
|
_pendingSessionListPayload={gen:_gen,sessData,projData,unreadGen};
|
||||||
_schedulePendingSessionListApply();
|
_schedulePendingSessionListApply();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_applySessionListPayload(sessData,projData);
|
_applySessionListPayload(sessData,projData,{unreadGen});
|
||||||
}catch(e){
|
}catch(e){
|
||||||
if (_gen !== _renderSessionListGen) return;
|
if (_gen !== _renderSessionListGen) return;
|
||||||
// #4671: same embargo guard as the success path — a mid-switch /api/sessions that
|
// #4671: same embargo guard as the success path — a mid-switch /api/sessions that
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ def test_polling_payloads_are_deferred_while_user_scrolls_sidebar():
|
|||||||
assert "async function _runRenderSessionListRefresh(opts, _gen)" in refresh_block
|
assert "async function _runRenderSessionListRefresh(opts, _gen)" in refresh_block
|
||||||
assert "const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);" in refresh_block
|
assert "const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);" in refresh_block
|
||||||
assert "if(deferWhileInteracting&&_isSessionListUserInteracting())" 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 "_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, (
|
assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block, (
|
||||||
"deferring sidebar refreshes must preserve background-completion unread semantics"
|
"deferring sidebar refreshes must preserve background-completion unread semantics"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -301,9 +301,12 @@ def test_session_list_path_tags_cron_markers_with_source_and_profile():
|
|||||||
is_cron = _extract_function(SESSIONS_JS, "_isCronSessionForUnread")
|
is_cron = _extract_function(SESSIONS_JS, "_isCronSessionForUnread")
|
||||||
meta_fn = _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession")
|
meta_fn = _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession")
|
||||||
source_key = _extract_function(SESSIONS_JS, "_sourceKeyForSession")
|
source_key = _extract_function(SESSIONS_JS, "_sourceKeyForSession")
|
||||||
|
match_fn = _extract_function(SESSIONS_JS, "_cronMarkerProfileMatchesActive")
|
||||||
|
profile_match = _extract_function(SESSIONS_JS, "_profileMatchesActiveProfile")
|
||||||
script = f"""
|
script = f"""
|
||||||
const markCalls=[];
|
const markCalls=[];
|
||||||
global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}};
|
global.S={{activeProfile:'profile-a',activeProfileIsDefault:false}};
|
||||||
|
let _showAllProfiles=false;
|
||||||
global._allSessions=[];
|
global._allSessions=[];
|
||||||
global._sessionListSnapshotById=new Map();
|
global._sessionListSnapshotById=new Map();
|
||||||
global._sessionStreamingById=new Map();
|
global._sessionStreamingById=new Map();
|
||||||
@@ -323,6 +326,8 @@ function _markSessionCompletionUnread(sid, count, meta){{
|
|||||||
{source_key}
|
{source_key}
|
||||||
{is_cron}
|
{is_cron}
|
||||||
{meta_fn}
|
{meta_fn}
|
||||||
|
{profile_match}
|
||||||
|
{match_fn}
|
||||||
{mark_poll}
|
{mark_poll}
|
||||||
const sessions=[
|
const sessions=[
|
||||||
{{
|
{{
|
||||||
@@ -478,3 +483,244 @@ process.stdout.write(JSON.stringify({{
|
|||||||
assert state["otherCleared"] is True
|
assert state["otherCleared"] is True
|
||||||
assert "root-cron" in state["persisted"]
|
assert "root-cron" in state["persisted"]
|
||||||
assert "other-cron" not in state["persisted"]
|
assert "other-cron" not 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"
|
||||||
@@ -163,7 +163,9 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
|
|||||||
assert "_markSessionCompletionUnread(sid, s.message_count" in transition_block
|
assert "_markSessionCompletionUnread(sid, s.message_count" in transition_block
|
||||||
assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block
|
assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block
|
||||||
assert "const _streamingPollMs = 30000;" in SESSIONS_JS
|
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 "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block
|
||||||
assert "_allSessions.some(s => _isSessionEffectivelyStreaming(s))" in apply_block, (
|
assert "_allSessions.some(s => _isSessionEffectivelyStreaming(s))" in apply_block, (
|
||||||
"the streaming poll fallback must stay active for the same server-confirmed "
|
"the streaming poll fallback must stay active for the same server-confirmed "
|
||||||
|
|||||||
@@ -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 "return await api('/api/projects' + projectQS,{timeoutToast:false});" in helper
|
||||||
assert "console.warn('renderProjectsList',projectError);" in helper
|
assert "console.warn('renderProjectsList',projectError);" in helper
|
||||||
assert "const projData = await projectPromise;" 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():
|
def test_sessions_api_always_retries_transient_upstream_statuses_and_boot_keeps_longer_timeout():
|
||||||
|
|||||||
Reference in new issue
Block a user