gate-fix #5467: serialize full rows in render-skip signature (close false-skips)

Codex gate found the render-skip signature omitted fields the sidebar renders:
- CORE: pending/running (active_stream_id/has_pending_user_message/pending_started_at)
- SILENT: attention dots
- SILENT: source/read-only/worktree/lineage/child/model/profile + _sidebarReferenceSessions
Any omitted field = a false-skip = stale sidebar.

Root-cause fix (most reliable): serialize the FULL applied row objects +
_sidebarReferenceSessions instead of a hand-picked allowlist — inherently
complete, can't rot when a new rendered field is added. Rewrote the
'ignores non-display fields' test into positive full-serialization + no-strip
regression assertions.
This commit is contained in:
nesquena-hermes committed 2026-07-03 22:58:21 +00:00
1 parent 10e5e941d3
commit 3999418b0e
2 files changed
+52 -30

No files matched your search

+13 -21
View File
@@ -4346,32 +4346,24 @@ function _syncSessionAttentionSoundState(sessions){
next.forEach((sig,sid)=>_sessionAttentionSoundState.set(sid,sig));
}
// Signature of everything the sidebar render reads from the applied payload +
// the coarse display state. Used to skip the full DOM rebuild when a poll
// returns data identical to what is already on screen (the common idle case).
// A streaming row's fields advance each poll so its signature changes and it
// still renders; client-only display toggles (search, active, density, select,
// lineage expansion) render through their own paths and are intentionally light
// here. Serialization failure returns null → never skip. (#5455 WS2.4)
// Signature of everything the sidebar render reads. Used to skip the full DOM
// rebuild when a poll returns data identical to what is already on screen (the
// common idle case). We serialize the FULL applied row objects (not a curated
// field subset) plus the reference/nesting rows and the coarse display state, so
// ANY server- or client-visible field the render helpers read (streaming/pending
// state, attention dots, source/read-only/worktree/lineage/child/model/profile
// meta, etc.) is covered — a narrow allowlist silently false-skips the moment a
// new rendered field is added (Codex #5467 gate: it omitted pending/running,
// attention, and the source/lineage cluster). A streaming/pending row's fields
// advance each poll so its signature changes and it still renders. Serialization
// failure returns null → never skip (fail-open). (#5455 WS2.4)
let _lastSessionListRenderSig = null;
function _sessionListRenderSignature(){
try{
const search=($('sessionSearch')&&$('sessionSearch').value)||'';
const sessionKeys = [
'session_id','id','display_name','title','created_at','started_at',
'updated_at','last_message_at','last_activity','message_count',
'actual_message_count','is_streaming','streaming','status','pinned',
'archived','project_id','source','session_source','generation',
'_lineage_root_id','_lineage_tip_id','_compression_segment_count',
];
const sessionsSlim = Array.isArray(_allSessions) ? _allSessions.map(s => {
if(!s || typeof s !== 'object') return s;
const out={};
sessionKeys.forEach(k=>{ if(Object.prototype.hasOwnProperty.call(s,k)) out[k]=s[k]; });
return out;
}) : _allSessions;
return JSON.stringify([
sessionsSlim,
_allSessions,
_sidebarReferenceSessions,
_allProjects,
_activeSessionIdForSidebar(),
search,
+39 -9
View File
@@ -133,17 +133,47 @@ def test_session_event_profile_filter_tolerates_default_root_aliases():
assert "S.activeProfileIsDefault = !!data.is_default;" in PANELS_JS
def test_session_list_render_signature_ignores_non_display_session_fields():
def test_session_list_render_signature_serializes_full_rows_not_a_narrow_allowlist():
"""The render-skip signature must serialize the FULL applied rows (+ reference
rows), not a curated field subset — a narrow allowlist silently false-skips
when a rendered field it omits changes (Codex #5467: it dropped pending/running
streaming state, attention dots, and the source/lineage cluster, so an
approval/clarify transition or a row starting to stream could keep a stale
sidebar). Serializing the whole row objects covers every field the render
helpers read, present and future.
"""
start = SESSIONS_JS.find("function _sessionListRenderSignature()")
assert start != -1
block = SESSIONS_JS[start:start + 1500]
assert "const sessionKeys = [" in block
assert "const sessionsSlim = Array.isArray(_allSessions)" in block
assert "sessionKeys.forEach" in block
assert "_allSessions," not in block
assert "message_count" in block
assert "last_message_at" in block
assert "_compression_segment_count" in block
block = SESSIONS_JS[start:start + 900]
# Full-object serialization, not a hand-picked allowlist.
assert "const sessionKeys = [" not in block, \
"signature must not use a narrow field allowlist (it false-skips on omitted fields)"
assert "sessionKeys.forEach" not in block
assert "_allSessions," in block, "signature must serialize the full applied rows"
assert "_sidebarReferenceSessions," in block, \
"signature must include the hidden reference/nesting rows the sidebar renders"
# Fail-open on serialization failure (never skip on null).
assert "catch(_){ return null; }" in block
def test_session_list_render_signature_changes_on_pending_running_and_attention():
"""Regression for the Codex #5467 CORE/SILENT false-skips: because the
signature serializes whole rows, a change to pending/running state or the
attention dot changes the signature (so the sidebar repaints, not skips).
This is a structural guarantee of full-row serialization — assert the
signature does not strip those fields back out.
"""
start = SESSIONS_JS.find("function _sessionListRenderSignature()")
block = SESSIONS_JS[start:start + 900]
# None of the previously-omitted, render-consumed fields may be filtered out.
for stripped in (
"delete out.active_stream_id",
"delete out.attention",
"delete out.has_pending_user_message",
):
assert stripped not in block, f"signature must not strip {stripped!r}"
# The whole-object arrays are passed straight to JSON.stringify.
assert "JSON.stringify([" in block
def test_pwa_pull_to_refresh_refreshes_session_list_not_page_when_available():