Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

## [Unreleased]

### Fixed

- Streaming finalization now treats compression-exhausted or tool-tail agent results as errors instead of completed turns, so long tool-heavy sessions do not appear done when Hermes Agent failed before writing a final assistant answer. When Hermes Agent rotated the session id during automatic compression before that terminal failure, WebUI now preserves the pre-compression snapshot, migrates continuation state first, and persists the final error on the continuation session instead of the stale parent row.
- Completed transcripts no longer render internal `[CONTEXT COMPACTION — REFERENCE ONLY]` reference cards; compression-exhausted runs now surface as explicit errors instead.

## [v0.51.267] — 2026-06-04 — Release II (stage-r17 — TTS + CSRF forwarded-header security hardening)

### Security
Expand Down
321 changes: 201 additions & 120 deletions api/streaming.py

Large diffs are not rendered by default.

26 changes: 20 additions & 6 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -2442,27 +2442,42 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_clearOwnerInflightState();
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(S.session&&S.session.session_id===activeSid){
let d={};
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
const currentSid=S.session&&S.session.session_id;
const eventSid=d.old_session_id||d.session_id||'';
const continuationSid=(d.session&&d.session.session_id)||d.new_session_id||d.continuation_session_id||'';
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||continuationSid===currentSid));
if(S.session&&eventMatchesCurrent){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
let isRecoveryControlMessage=false;
try{
const d=JSON.parse(e.data);
const isRateLimit=d.type==='rate_limit';
const isQuotaExhausted=d.type==='quota_exhausted';
const isAuthMismatch=d.type==='auth_mismatch';
const isGatewayAuthError=d.type==='gateway_auth_error';
const isModelNotFound=d.type==='model_not_found';
const isCancelled=d.type==='cancelled';
const isInterrupted=d.type==='interrupted';
const isCompressionExhausted=d.type==='compression_exhausted';
isRecoveryControlMessage=isInterrupted && (d.recovery_control===true || _streamRecoveryControlMessageText(d.message));
const isNoResponse=d.type==='no_response'||d.type==='silent_failure';
const label=isCancelled?'Task cancelled':isInterrupted?'Response interrupted':isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isGatewayAuthError?(typeof t==='function'?t('gateway_auth_label'):'Gateway authentication failed'):isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response from provider':'Error';
const label=isCancelled?'Task cancelled':isInterrupted?'Response interrupted':isCompressionExhausted?'Context compression exhausted':isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isGatewayAuthError?(typeof t==='function'?t('gateway_auth_label'):'Gateway authentication failed'):isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response from provider':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
const details=d.details?String(d.details).replace(/```/g,'`\u200b``'):'';
const detailsLabel=isCancelled?'Cancellation details':isInterrupted?'Interruption details':undefined;
window._compressionUi=null;
if(typeof clearCompressionUi==='function') clearCompressionUi();
if(isRecoveryControlMessage){
if(typeof showToast==='function') showToast('Stream recovery signal received. Restoring transcript...',3500,'error');
} else if(d.session&&typeof d.session==='object'){
S.session=d.session;
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
}
} else {
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`,provider_details:details,provider_details_label:detailsLabel});
}
Expand All @@ -2479,13 +2494,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
})();
} else {
_markSessionViewed(activeSid, S.messages.length);
_markSessionViewed((S.session&&S.session.session_id)||activeSid, S.messages.length);
renderMessages({preserveScroll:true});
}
}else if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
trackBackgroundError(activeSid,_errTitle,d.message||'Error');
}
_setActivePaneIdleIfOwner();
renderSessionList(); // clear streaming indicator immediately on apperror
Expand Down
25 changes: 11 additions & 14 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -6359,7 +6359,10 @@ function _collectHandoffSummaryStates(messages){
function _isContextCompactionMessage(m){
if(!m||!m.role||m.role==='tool') return false;
const text=msgContent(m)||String(m.content||'');
return /^\s*\[context compaction/i.test(text) || /^\s*context compaction/i.test(text);
return _isContextCompactionText(text);
}
function _isContextCompactionText(text){
return /^\s*\[context compaction/i.test(String(text||'')) || /^\s*context compaction/i.test(String(text||''));
}
function _isPreservedCompressionTaskListMarkerText(text){
return /^\s*\[your active task list was preserved across context compression\]/i.test(String(text||''));
Expand Down Expand Up @@ -6437,6 +6440,9 @@ function _latestCompressionReferenceMessage(messages, summaryText=''){
}
return {message:null, rawIdx:-1};
}
function _shouldShowSettledCompressionReference(referenceText){
return !!String(referenceText||'').trim() && !_isContextCompactionText(referenceText);
}
function _compressionReferenceCardHtml(text, open=false){
const copy=_engineAwareCompressionCopy();
const preview=text.split(/\n+/).filter(Boolean).slice(0,2).join(' ');
Expand Down Expand Up @@ -6957,7 +6963,7 @@ function renderMessages(options){
const referenceText=referenceMessage
? msgContent(referenceMessage)||String(referenceMessage.content||'')
: sessionCompressionSummary;
const referenceNode=(!compressionState && !!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary))
const referenceNode=(!compressionState && _shouldShowSettledCompressionReference(referenceText) && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary))
? (()=>{const row=document.createElement('div');row.innerHTML=`<div class="compression-turn"><div class="compression-turn-blocks">${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}</div></div>`;return row.firstElementChild;})()
: null;
let preservedCompressionTaskCardsAttached=!!referenceNode;
Expand All @@ -6969,6 +6975,7 @@ function renderMessages(options){
let ri=0;
for(const m of S.messages){
if(!m||!m.role||m.role==='tool'){ri++;continue;}
if(_isContextCompactionMessage(m)){ri++;continue;}
if(_isPreservedCompressionTaskListMessage(m)){ri++;continue;}
if(_isRecoveryControlMessage(m)){ri++;continue;}
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
Expand Down Expand Up @@ -7174,17 +7181,7 @@ function renderMessages(options){
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span>${questionJumpBtn}</div>`;

if(_isContextCompactionMessage(m)){
if(compressionState || referenceNode){
continue;
}else{
currentAssistantTurn=null;
const row=document.createElement('div');
const preservedForThisCard=preservedCompressionTaskCardsAttached?[]:preservedCompressionTaskMessages;
row.innerHTML=_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard);
if(preservedForThisCard.length) preservedCompressionTaskCardsAttached=true;
inner.appendChild(row.firstElementChild);
continue;
}
continue;
}

if(isUser){
Expand Down Expand Up @@ -7297,7 +7294,7 @@ function renderMessages(options){
}
inner.appendChild(node);
}
const preservedOnlyNode=(!preservedCompressionTaskCardsAttached&&(!referenceMessage||compressionState)&&preservedCompressionTaskMessages.length)
const preservedOnlyNode=(!preservedCompressionTaskCardsAttached&&(!referenceNode||compressionState)&&preservedCompressionTaskMessages.length)
? (()=>{const row=document.createElement('div');row.innerHTML=`<div class="compression-turn"><div class="compression-turn-blocks">${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}</div></div>`;return row.firstElementChild;})()
: null;
const preservedOnlyAnchor=preservedCompressionRawIdxs.length
Expand Down
33 changes: 27 additions & 6 deletions tests/test_auto_compression_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,27 @@ def test_context_compaction_branch_precedes_user_bubble_branch():
assert context_idx != -1, "context compaction render branch not found"
assert user_idx != -1, "normal user bubble render branch not found"
assert context_idx < user_idx
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in render_prefix
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" not in render_prefix
assert "continue;" in render_prefix[context_idx:user_idx]


def test_settled_transcript_suppresses_context_compaction_reference_cards():
src = _read("static/ui.js")

assert "function _shouldShowSettledCompressionReference" in src
assert "!_isContextCompactionText(referenceText)" in src

visible_filter_start = src.find("const vis=S.messages.filter")
assert visible_filter_start != -1, "visible message filter not found"
visible_filter_end = src.find("$('emptyState')", visible_filter_start)
visible_filter = src[visible_filter_start:visible_filter_end]
assert "if(_isContextCompactionMessage(m)) return false;" in visible_filter

vis_idx_start = src.find("for(const m of S.messages)", visible_filter_end)
assert vis_idx_start != -1, "raw message index loop not found"
vis_idx_end = src.find("let lastUserRawIdx", vis_idx_start)
vis_idx_loop = src[vis_idx_start:vis_idx_end]
assert "if(_isContextCompactionMessage(m)){ri++;continue;}" in vis_idx_loop


def test_preserved_task_list_skips_normal_visible_message_path():
Expand Down Expand Up @@ -416,7 +436,8 @@ def test_preserved_task_list_renders_through_compression_card_path():
assert "tool-card-compress-reference" in helper
assert "data-compression-card=\"1\"" in helper
assert "li('list-todo',13)" in helper
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in src
assert "const preservedOnlyNode=" in src
assert "_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)" in src


def test_context_anchor_reference_uses_session_summary_fallback():
Expand All @@ -426,7 +447,8 @@ def test_context_anchor_reference_uses_session_summary_fallback():
assert "const sessionCompressionSummary" in src
assert "referenceText=referenceMessage" in src
assert ": sessionCompressionSummary" in src
assert "!!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)" in src
assert "_shouldShowSettledCompressionReference(referenceText)" in src
assert "!_isContextCompactionText(referenceText)" in src


def test_compression_anchor_matching_tolerates_legacy_missing_timestamp():
Expand Down Expand Up @@ -580,9 +602,8 @@ def test_preserved_task_list_attaches_once_per_render():
assert "const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);" in src
assert "S.messages.filter(m=>_isPreservedCompressionTaskListMessage(m))" not in src
assert "let preservedCompressionTaskCardsAttached=!!referenceNode;" in src
assert "const preservedForThisCard=preservedCompressionTaskCardsAttached?[]:preservedCompressionTaskMessages;" in src
assert "if(preservedForThisCard.length) preservedCompressionTaskCardsAttached=true;" in src
assert "(!preservedCompressionTaskCardsAttached&&(!referenceMessage||compressionState)&&preservedCompressionTaskMessages.length)" in src
assert "const preservedOnlyNode=" in src
assert "(!preservedCompressionTaskCardsAttached&&(!referenceNode||compressionState)&&preservedCompressionTaskMessages.length)" in src


def test_preserved_task_list_is_suppressed_when_latest_todo_state_has_no_active_items():
Expand Down
Loading
Loading