Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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.249] — 2026-06-03 — Release HQ (stage-q21 — auto-expand terminal on output toggle)

### Added
Expand Down
317 changes: 198 additions & 119 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 @@ -2268,27 +2268,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||activeSid;
const continuationSid=(d.session&&d.session.session_id)||d.new_session_id||d.continuation_session_id||'';
const eventMatchesCurrent=!!(currentSid&&(eventSid===currentSid||continuationSid===currentSid||eventSid===activeSid||continuationSid===activeSid));
if(S.session&&eventMatchesCurrent){
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
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 @@ -2305,13 +2320,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 @@ -6062,7 +6062,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 @@ -6140,6 +6143,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 @@ -6626,7 +6632,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 @@ -6638,6 +6644,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 @@ -6839,17 +6846,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 @@ -6962,7 +6959,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