Skip to content
Merged
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]

## [v0.51.279] — 2026-06-05 — Release IU (stage-p3h — preserve Activity/streaming turn on mid-stream scroll)

### Fixed
- **Loading earlier messages during an active stream no longer wipes the Activity panel or the current streaming turn.** Two causes: (1) the message merge/dedup keys didn't include `tool_calls`, so assistant messages invoking *different* tools with identical empty content and same-second timestamps collapsed into one — dropping every state.db tool-call after the first the sidecar registered; (2) `_syncToolCallsForLoadedMessages` cleared `S.toolCalls` while `S.busy` blocked the `renderMessages` rebuild. `tool_calls` is now part of the merge/dedup/visible keys (with a preservation branch so distinct tool invocations within the sidecar timestamp window aren't skipped), and the frontend keeps the live tool-call/streaming state when paging in history. (#3665, @mysoul12138; fixes #3346)

## [v0.51.278] — 2026-06-05 — Release IT (stage-p3g — repair inline PDF preview)

### Fixed
Expand Down
63 changes: 54 additions & 9 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3953,13 +3953,22 @@ def _session_message_merge_key(msg: dict):
message_identity = msg.get("id") or msg.get("message_id")
if message_identity:
return ("message_id", str(message_identity))
# Include tool_calls so assistant messages that invoke different tools
# (but share identical empty content and same-second timestamp) are not
# collapsed by the merge-key guard at line ~4216. Without this,
# all tool-calling messages map to the same legacy key and the
# timestamp<=max_sidecar_timestamp blanket-skip at line ~4218 drops
# every state.db tool-call after the first one registered by the sidecar.
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
"legacy",
str(msg.get("role") or ""),
str(msg.get("content") or ""),
_normalized_message_timestamp_for_key(msg.get("timestamp")),
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
_tc_key,
)


Expand All @@ -3976,13 +3985,19 @@ def _session_message_dedup_key(msg: dict):
message_identity = msg.get("id") or msg.get("message_id")
if message_identity:
return ("message_id", str(message_identity))
# Include tool_calls in the key so assistant messages that carry
# different tool invocations (but identical empty content/timestamp)
# are never collapsed into one. (#3346 regression)
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
"legacy",
str(msg.get("role") or ""),
str(msg.get("content") or ""),
str(msg.get("timestamp") or ""),
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
_tc_key,
)


Expand Down Expand Up @@ -4010,9 +4025,16 @@ def _session_message_content_key(msg: dict):
def _session_message_visible_key(msg: dict):
if not isinstance(msg, dict):
return ("non_dict", repr(msg))
# Include tool_calls so assistant messages that invoke different tools
# (but share identical empty content) are not collapsed by sidecar
# prefix matching. Without this, all tool-calling messages map to
# ("assistant", "") and the merge treats state.db rows as replays.
_tc = msg.get("tool_calls")
_tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else ""
return (
str(msg.get("role") or ""),
_normalized_session_message_content(msg),
_tc_key,
)


Expand All @@ -4021,8 +4043,9 @@ def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict:
loose_by_key = {}
for key in visible_keys:
try:
role, content = key
except (TypeError, ValueError):
role = key[0]
content = key[1]
except (TypeError, IndexError):
continue
if not content:
continue
Expand All @@ -4034,24 +4057,27 @@ def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict:
def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lookup: dict | None = None):
if visible_key in visible_keys:
return visible_key
role, content = visible_key
role = visible_key[0]
content = visible_key[1] if len(visible_key) > 1 else ""
if not content:
return None
if lookup is None:
lookup = _build_visible_duplicate_lookup(visible_keys)
loose_content = None
for existing_role, existing_content in lookup.get("by_role", {}).get(role, []):
for existing_key in lookup.get("by_role", {}).get(role, []):
existing_role = existing_key[0]
existing_content = existing_key[1] if len(existing_key) > 1 else ""
if role != existing_role or not existing_content:
continue
if content in existing_content or existing_content in content:
return (existing_role, existing_content)
return existing_key
if loose_content is None:
loose_content = _loose_session_message_content(content)
loose_existing = lookup.get("loose_by_key", {}).get((existing_role, existing_content), "")
loose_existing = lookup.get("loose_by_key", {}).get(existing_key, "")
if loose_content and loose_existing and (
loose_content in loose_existing or loose_existing in loose_content
):
return (existing_role, existing_content)
return existing_key
return None


Expand Down Expand Up @@ -4253,7 +4279,13 @@ def merge_session_messages_append_only(
if key in seen_message_keys and key[0] == "message_id":
continue
if not (isinstance(key, tuple) and key[:1] == ("message_id",)):
continue
# Legacy key within sidecar timestamp range — only skip if
# this exact merge_key was already registered by the sidecar.
# Different tool_calls produce different merge_keys even with
# identical content/timestamp, so an unchecked continue here
# would drop legitimately distinct turns. (#3346 / PR #3665)
if key in seen_message_keys:
continue
if key in seen_message_keys and key[0] == "message_id":
continue
matched_visible_key = _matching_visible_duplicate(
Expand Down Expand Up @@ -4283,7 +4315,20 @@ def merge_session_messages_append_only(
and timestamp is not None
and timestamp <= max_sidecar_timestamp
):
continue
# Legacy key within sidecar timestamp range. Normally skip — the
# sidecar already has this message. Exception: if the state.db
# message has tool_calls that DIFFER from the sidecar version
# (same content_key but different dedup_key because tool_calls
# differ), preserve it — distinct tool_calls must not be collapsed.
_tc = msg.get("tool_calls")
if _tc:
_ck = _session_message_content_key(msg)
if _ck in seen_content_keys and dedup_key not in seen_dedup_keys:
pass # different tool_calls from sidecar — preserve
Comment on lines +4326 to +4327

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The dedup_key not in seen_dedup_keys sub-expression is always True here. By line 4272, any message whose dedup_key was already in seen_dedup_keys has already been skipped via continue, so the condition can never be False when execution reaches this point. The guard is harmless but misleading — a reader might assume it provides a meaningful second check. Simplifying to just _ck in seen_content_keys makes the intent clearer.

Suggested change
if _ck in seen_content_keys and dedup_key not in seen_dedup_keys:
pass # different tool_calls from sidecar — preserve
if _ck in seen_content_keys:
pass # different tool_calls from sidecar — preserve

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

else:
continue
else:
continue
seen_message_keys.add(key)
seen_dedup_keys.add(dedup_key)
seen_content_keys.add(_session_message_content_key(msg))
Expand Down
16 changes: 6 additions & 10 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5062,16 +5062,12 @@ def handle_get(handler, parsed) -> bool:
_threshold_tokens = 0
_persisted_cl = _fb_cl
_session_tool_calls = getattr(s, "tool_calls", []) if load_messages else []
if (
load_messages
and msg_limit is not None
and _messages_include_tool_metadata(_truncated_msgs)
):
# The browser ignores session-level tool_calls when the returned
# messages already carry per-message tool metadata. Avoid sending
# the full historical list with a small tail window.
_session_tool_calls = []
elif _windowed_messages:
# Always include session-level tool_calls so the browser can merge
# them with per-message tool_calls for messages that lack the
# per-message variant (older messages whose tool_calls live only
# in the session-level list). The browser-side
# _syncToolCallsForLoadedMessages handles deduplication by tid.
if _windowed_messages:
_session_tool_calls = _tool_calls_for_message_window(
_session_tool_calls,
_messages_offset,
Expand Down
15 changes: 15 additions & 0 deletions static/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -2157,6 +2157,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
S.messages=_filterRecoveryControlMessages(S.messages || []);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
if(typeof clearVisibleMessageRowCache==='function') clearVisibleMessageRowCache();
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);
Expand Down Expand Up @@ -2270,6 +2271,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
// Cooldown: prevent refreshActiveSessionIfExternallyUpdated from
// force-reloading immediately after "done" — the event already
// delivered the final messages and tool calls.
if(typeof window!=='undefined') window._streamJustFinished=true;
setTimeout(()=>{ if(typeof window!=='undefined') window._streamJustFinished=false; }, 5000);
// Expand render window to cover all messages so the done render
// doesn't hide Activity behind a tiny window (winSize=50).
if(typeof _messageRenderableMessageCount==='function'&&typeof _messageRenderWindowSize!=='undefined'){
_messageRenderWindowSize=Math.max(typeof _currentMessageRenderWindowSize==='function'?_currentMessageRenderWindowSize():50, _messageRenderableMessageCount());
}
syncTopbar();renderMessages({preserveScroll:true});
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
if(typeof noteWorkspaceMutationsFromToolCalls==='function') noteWorkspaceMutationsFromToolCalls(S.toolCalls);
Expand Down Expand Up @@ -2733,6 +2744,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.toolCalls=[];
}
if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length);
// Expand render window so the settled render doesn't hide Activity.
if(typeof _messageRenderableMessageCount==='function'&&typeof _messageRenderWindowSize!=='undefined'){
_messageRenderWindowSize=Math.max(typeof _currentMessageRenderWindowSize==='function'?_currentMessageRenderWindowSize():50, _messageRenderableMessageCount());
}
syncTopbar();renderMessages({preserveScroll:true});
}
if(_isActiveSession()) _queueDrainSid=activeSid;
Expand Down
20 changes: 19 additions & 1 deletion static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,9 @@ function _messageReloadLimitForSession(sid){

function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){
const msgs=Array.isArray(messages)?messages:[];
// During active streaming, skip — clearing S.toolCalls would lose Activity
// and the renderMessages fallback is blocked by S.busy=true.
if(S.busy||S.activeStreamId) return;
const hasMessageToolMetadata=msgs.some(m=>{
if(!m) return false;
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
Expand Down Expand Up @@ -1587,7 +1590,13 @@ async function _ensureMessagesLoaded(sid) {
// toast on every mobile message (SSE/visibility events trigger this reload path
// more aggressively on mobile).
let msgs = (data.session.messages || []).filter(m => m && m.role);
_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);
// Skip _syncToolCalls when INFLIGHT exists — the INFLIGHT restore path
// (loadSession line ~871) will overwrite S.toolCalls from INFLIGHT[sid].toolCalls.
// Clearing here and then overwriting is wasteful, and if S.busy becomes true
// before the next render, the fallback can't re-derive from messages.
if(!(typeof INFLIGHT !== 'undefined' && INFLIGHT && INFLIGHT[sid])){
_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);
}
clearLiveToolCards();
// #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration,
// _turnTps, _gatewayRouting, _statusCard) across the loadSession replace.
Expand All @@ -1603,6 +1612,11 @@ async function _ensureMessagesLoaded(sid) {
}
if(typeof clearVisibleMessageRowCache==='function') clearVisibleMessageRowCache();
S.messages = msgs;
// Expand render window to cover all loaded messages so the next
// renderMessages() doesn't hide most of them behind a tiny window.
if(typeof _messageRenderableMessageCount==='function'&&typeof _currentMessageRenderWindowSize==='function'){
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(), _messageRenderableMessageCount());
}
if(S.session&&S.session.session_id===sid){
S.session.message_count=Number(data.session.message_count || msgs.length);
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
Expand Down Expand Up @@ -2967,6 +2981,10 @@ async function refreshActiveSessionIfExternallyUpdated(reason){
if(_activeSessionExternalRefreshInFlight) return;
if(!S.session || !S.session.session_id) return;
if(S.busy || S.activeStreamId) return;
// Cooldown: don't force-reload immediately after streaming ends — the
// "done" event already delivered the final messages. Reloading here would
// clear S.toolCalls and lose Activity.
if(typeof window !== 'undefined' && window._streamJustFinished) return;
Comment on lines +2984 to +2987

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 window._streamJustFinished is not scoped to the active session. If the user switches sessions while the flag is live (within the 5-second window), refreshActiveSessionIfExternallyUpdated will be silently suppressed for the new session as well, even though that session's stream has nothing to do with the flag. A session-keyed value (e.g. window._streamJustFinishedSid = activeSid set alongside the flag, then checked as window._streamJustFinished && window._streamJustFinishedSid === S.session.session_id) would limit the cooldown to only the session whose stream just ended.

if(typeof document !== 'undefined' && document.hidden) return;
const sid = S.session.session_id;
const localCount = Number(S.session.message_count || (Array.isArray(S.messages)?S.messages.length:0) || 0);
Expand Down
27 changes: 22 additions & 5 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,11 @@ let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
// Cached visWithIdx array — invalidated when S.messages.length changes.
let _visWithIdxCache=null;
let _visWithIdxCacheLen=0;
let _visWithIdxCacheSrc=null; // S.messages reference — detects wholesale replacement with same length
function clearVisibleMessageRowCache(){
_visWithIdxCache=null;
_visWithIdxCacheLen=0;
_visWithIdxCacheSrc=null;
}
function _resetMessageRenderWindow(sid){
_messageRenderWindowSid=sid||null;
Expand Down Expand Up @@ -355,9 +357,10 @@ function _messageRenderableMessageCount(){
for(const m of (S.messages||[])){
if(!m||!m.role||m.role==='tool') continue;
if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) continue;
if(_isRecoveryControlMessage(m)) continue;
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) count++;
if(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)||_assistantMessageHasVisibleContent(m)))) count++;
}
return count;
}
Expand Down Expand Up @@ -416,9 +419,18 @@ async function jumpToSessionStart(){
_messageUserUnpinned=true;
_programmaticScroll=true;
try{
if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded();
// During active streaming, skip full message load — API response won't
// include live messages from the current turn, and replacing S.messages
// would lose user/assistant/tool messages.
if(!(S.busy||S.activeStreamId)){
if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded();
}
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
renderMessages({ preserveScroll:true });
// During streaming, skip renderMessages — it rebuilds the DOM but tool card
// insertion is blocked by !S.busy, losing Activity until "done" fires.
if(!(S.busy||S.activeStreamId)){
renderMessages({ preserveScroll:true });
}
requestAnimationFrame(()=>{
container.scrollTop=0;
_updateSessionStartJumpButton();
Expand Down Expand Up @@ -6995,7 +7007,7 @@ function renderMessages(options){
// Cache visWithIdx so expanding the render window (Load earlier) doesn't
// re-scan S.messages from scratch. Invalidate only when the message array
// length changes — i.e. new messages arrived or session was truncated.
if(!_visWithIdxCache || _visWithIdxCacheLen !== S.messages.length){
if(!_visWithIdxCache || _visWithIdxCacheLen !== S.messages.length || _visWithIdxCacheSrc !== S.messages){
const rebuilt=[];
let ri=0;
for(const m of S.messages){
Expand All @@ -7010,6 +7022,7 @@ function renderMessages(options){
}
_visWithIdxCache=rebuilt;
_visWithIdxCacheLen=S.messages.length;
_visWithIdxCacheSrc=S.messages;
}
const visWithIdx=_visWithIdxCache;
const preservedCompressionRawIdxs=[];
Expand Down Expand Up @@ -7464,7 +7477,11 @@ function renderMessages(options){
});
if(derived.length) S.toolCalls=derived;
}
if(!S.busy){
// Render tool cards: allow during streaming when S.toolCalls is already
// populated (e.g. from INFLIGHT restore or SSE events). Only the fallback
// derivation above is blocked by S.busy — DOM insertion should proceed
// whenever tool cards exist.
if(!S.busy || (S.toolCalls&&S.toolCalls.length)){
inner.querySelectorAll('.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]),.agent-activity-thinking:not([data-live-thinking="1"])').forEach(el=>el.remove());
const byAssistant = {};
for(const tc of (S.toolCalls||[])){
Expand Down
2 changes: 1 addition & 1 deletion tests/test_issue3306_loadsession_carry_forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def _load_session_clear_block() -> str:

def _ensure_messages_loaded_body() -> str:
start = SESSIONS_JS.index("async function _ensureMessagesLoaded")
return SESSIONS_JS[start: start + 2500]
return SESSIONS_JS[start: start + 3000]


def test_pending_carry_forward_snapshot_declared_at_module_scope():
Expand Down
Loading
Loading