diff --git a/static/ui.js b/static/ui.js index caadaf88..88a53c33 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5537,14 +5537,9 @@ function renderCompressionUi(){ el.style.display='none'; } // Session render cache: avoids full markdown+DOM rebuild when switching back -// to a session that was already rendered with the same message count. +// to a session whose rendered transcript inputs are unchanged. // Keyed by session_id. Only used on cross-session navigation, never for // in-session updates (new messages, edits, stream events). -// -// Known limitation: cache key is session_id + message count. Edits and retries -// that mutate message content without changing the count will serve stale HTML -// on back-navigation until the user triggers an in-session update. Acceptable -// for the common read-only back-navigation case; not suitable as a general cache. const _sessionHtmlCache=new Map(); let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM function clearMessageRenderCache(){ @@ -5552,6 +5547,55 @@ function clearMessageRenderCache(){ _sessionHtmlCacheSid=null; } +function _messageRenderCacheSignature(){ + let hash=2166136261; + function add(value){ + const s=String(value==null?'':value); + for(let i=0;i>>0; + } + hash^=31; + hash=Math.imul(hash,16777619)>>>0; + } + const messages=Array.isArray(S.messages)?S.messages:[]; + add(messages.length); + for(const m of messages){ + if(!m||typeof m!=='object'){ add('missing'); continue; } + add(m.role);add(m.timestamp);add(m._ts);add(m._error);add(m._statusCard); + add(msgContent(m)); + if(Array.isArray(m.content)){ + add('content-array'); + m.content.forEach(part=>{ + if(!part||typeof part!=='object'){ add(part); return; } + add(part.type);add(part.id);add(part.name);add(part.text);add(part.content); + }); + } + if(Array.isArray(m.tool_calls)){ + add('message-tool-calls');add(m.tool_calls.length); + m.tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.type);add(JSON.stringify(tc&&tc.function||{}));}); + } + if(Array.isArray(m._partial_tool_calls)){ + add('partial-tool-calls');add(m._partial_tool_calls.length); + m._partial_tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.snippet);}); + } + if(_messageHasReasoningPayload(m)) add(m.reasoning||m.thinking||m._reasoning||'reasoning'); + if(Array.isArray(m.attachments)) m.attachments.forEach(a=>add(a&&typeof a==='object'?JSON.stringify(a):a)); + } + const toolCalls=Array.isArray(S.toolCalls)?S.toolCalls:[]; + add('settled-tool-calls');add(toolCalls.length); + toolCalls.forEach(tc=>{ + if(!tc||typeof tc!=='object'){ add(tc); return; } + add(tc.tid);add(tc.id);add(tc.name);add(tc.done);add(tc.is_diff);add(tc.assistant_msg_idx);add(tc.snippet);add(JSON.stringify(tc.args||{})); + }); + if(S.session){ + add(S.session.message_count);add(S.session.updated_at);add(S.session.compression_anchor_visible_idx); + add(JSON.stringify(S.session.compression_anchor_message_key||null)); + add(S.session.compression_anchor_summary||''); + } + return `${messages.length}:${toolCalls.length}:${hash.toString(16)}`; +} + function _clipCliToolSnippet(text, maxLen=20000){ const s=String(text||''); if(s.length<=maxLen) return s; @@ -5698,6 +5742,7 @@ function renderMessages(options){ const msgCount=S.messages.length; if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid); const renderWindowSize=_currentMessageRenderWindowSize(); + const renderSignature=_messageRenderCacheSignature(); const hasTransientTranscriptUi=!!( (window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) || (window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid)) @@ -5713,7 +5758,7 @@ function renderMessages(options){ // before those cards can be inserted. if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){ const cached=_sessionHtmlCache.get(sid); - if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize){ + if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize&&cached.signature===renderSignature){ inner.innerHTML=cached.html; _sessionHtmlCacheSid=sid; _wireMessageWindowLoadEarlierButton(); @@ -6324,7 +6369,7 @@ function renderMessages(options){ const _html=inner.innerHTML; // Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions. if(_html.length<300_000){ - _sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize}); + _sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature}); if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);} } } diff --git a/tests/test_issue2613_render_cache_signature.py b/tests/test_issue2613_render_cache_signature.py new file mode 100644 index 00000000..bb812291 --- /dev/null +++ b/tests/test_issue2613_render_cache_signature.py @@ -0,0 +1,26 @@ +from pathlib import Path + + +UI_JS = Path("static/ui.js").read_text(encoding="utf-8") + + +def test_session_html_cache_uses_render_signature_not_only_count(): + assert "function _messageRenderCacheSignature()" in UI_JS + assert "const renderSignature=_messageRenderCacheSignature();" in UI_JS + assert "cached.signature===renderSignature" in UI_JS + assert "signature:renderSignature" in UI_JS + + +def test_render_signature_tracks_message_content_and_settled_tool_cards(): + signature_fn = UI_JS[UI_JS.index("function _messageRenderCacheSignature()"):UI_JS.index("function _clipCliToolSnippet")] + assert "msgContent(m)" in signature_fn + assert "m.tool_calls" in signature_fn + assert "m._partial_tool_calls" in signature_fn + assert "S.toolCalls" in signature_fn + assert "tc.snippet" in signature_fn + assert "compression_anchor_summary" in signature_fn + + +def test_documentation_no_longer_allows_same_count_stale_html(): + assert "Known limitation: cache key is session_id + message count" not in UI_JS + assert "mutate message content without changing the count will serve stale HTML" not in UI_JS diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index 992c5046..0352f0b6 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -33,7 +33,8 @@ def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_ def test_window_state_participates_in_cache_and_cached_button_is_rewired(): assert "cached.renderWindowSize===renderWindowSize" in UI_JS - assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize})" in UI_JS + assert "cached.signature===renderSignature" in UI_JS + assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature})" in UI_JS assert "function _wireMessageWindowLoadEarlierButton()" in UI_JS assert "_wireMessageWindowLoadEarlierButton();" in UI_JS assert UI_JS.count("_wireMessageWindowLoadEarlierButton();") >= 2