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:
commit
3bf21c8ffb
9 changed files with 1088 additions and 17 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
867
tests/test_issue5960_cron_unread_profile_scope.py
Normal file
867
tests/test_issue5960_cron_unread_profile_scope.py
Normal 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"
|
||||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue