Stage 397: PR #2692 — fix(ui): invalidate transcript cache on same-count content changes

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-05-21 17:14:37 +00:00
parent e2338f696f
commit 8e1ac89baa
3 changed files with 81 additions and 9 deletions
+53 -8
View File
@@ -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<s.length;i++){
hash^=s.charCodeAt(i);
hash=Math.imul(hash,16777619)>>>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);}
}
}
@@ -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
+2 -1
View File
@@ -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