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
63 changes: 54 additions & 9 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3950,13 +3950,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 @@ -3973,13 +3982,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 @@ -4007,9 +4022,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 @@ -4018,8 +4040,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 @@ -4031,24 +4054,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 @@ -4250,7 +4276,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 @@ -4280,7 +4312,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
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 @@ -5158,16 +5158,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:
Comment thread
greptile-apps[bot] marked this conversation as resolved.
_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 @@ -2259,6 +2260,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 @@ -2718,6 +2729,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 @@ -1543,6 +1543,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 @@ -1585,7 +1588,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 @@ -1601,6 +1610,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 @@ -2965,6 +2979,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 thread
greptile-apps[bot] marked this conversation as resolved.
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 @@ -6965,7 +6977,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 @@ -6980,6 +6992,7 @@ function renderMessages(options){
}
_visWithIdxCache=rebuilt;
_visWithIdxCacheLen=S.messages.length;
_visWithIdxCacheSrc=S.messages;
}
const visWithIdx=_visWithIdxCache;
const preservedCompressionRawIdxs=[];
Expand Down Expand Up @@ -7434,7 +7447,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