From d57854807a9518eabef5a96026989e08dfb473ab Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 30 May 2026 11:02:54 +0200 Subject: [PATCH 1/2] fix: keep tool cards anchored during history paging --- CHANGELOG.md | 4 +++ api/routes.py | 12 +++++++-- static/sessions.js | 37 ++++++++++++++++++-------- tests/test_issue401.py | 8 +++--- tests/test_session_tail_payload.py | 10 +++++-- tests/test_tool_call_history_paging.py | 27 +++++++++++++++++++ 6 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 tests/test_tool_call_history_paging.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ec94654..6ad5761e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Tool-call cards stay anchored when scrolling back through paginated history; legacy session-level tool-call indices are rebased to the returned message window and the browser refreshes tool-call anchors whenever a larger history window is loaded (#3120). + ## [v0.51.171] — 2026-05-30 — Release EQ (stage-batch53 — tool-output card badge + Neon opt-in skin) ### Added diff --git a/api/routes.py b/api/routes.py index d448a382d..665bbc8c5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2257,7 +2257,13 @@ def _messages_include_tool_metadata(messages) -> bool: def _tool_calls_for_message_window(tool_calls, start_idx: int, message_count: int) -> list: - """Keep session-level tool calls that point into a returned message window.""" + """Keep session-level tool calls that point into a returned message window. + + ``assistant_msg_idx`` is stored in the full transcript coordinate space, but + the frontend renders the returned ``messages`` array from index 0. Rebase the + index into the returned window so legacy session-level tool cards still + anchor to their visible assistant turn after paginated loads. + """ if not isinstance(tool_calls, list) or message_count <= 0: return [] end_idx = start_idx + message_count @@ -2269,7 +2275,9 @@ def _tool_calls_for_message_window(tool_calls, start_idx: int, message_count: in if isinstance(assistant_idx, bool) or not isinstance(assistant_idx, int): continue if start_idx <= assistant_idx < end_idx: - filtered.append(tool_call) + rebased = dict(tool_call) + rebased["assistant_msg_idx"] = assistant_idx - start_idx + filtered.append(rebased) return filtered diff --git a/static/sessions.js b/static/sessions.js index 0c89c1dab..8a9a4ed40 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1310,6 +1310,29 @@ let _messagesTruncated = false; // Older messages are loaded on-demand via _loadOlderMessages(). const _INITIAL_MSG_LIMIT = 30; +function _syncToolCallsForLoadedMessages(messages, sessionToolCalls, windowOffset=0){ + const msgs=Array.isArray(messages)?messages:[]; + const hasMessageToolMetadata=msgs.some(m=>{ + if(!m) return false; + 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'); + return hasTc||hasTu; + }); + if(!hasMessageToolMetadata&&Array.isArray(sessionToolCalls)&&sessionToolCalls.length){ + const offset=Number.isFinite(Number(windowOffset))?Number(windowOffset):0; + S.toolCalls=sessionToolCalls.map(tc=>{ + const copy={...tc,done:true}; + const idx=copy.assistant_msg_idx; + if(Number.isInteger(idx)&&idx>=offset&&idx 0 && S.messages[0] && S.messages[0].role) { @@ -1327,17 +1350,7 @@ 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); - // Check for tool-call metadata on messages (for tool-call card rendering) - const hasMessageToolMetadata = msgs.some(m => { - 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'); - return hasTc || hasTu; - }); - if (!hasMessageToolMetadata && data.session.tool_calls && data.session.tool_calls.length) { - S.toolCalls = data.session.tool_calls.map(tc => ({...tc, done: true})); - } else { - S.toolCalls = []; - } + _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls, data.session._messages_offset||0); clearLiveToolCards(); // #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration, // _turnTps, _gatewayRouting, _statusCard) across the loadSession replace. @@ -1500,6 +1513,7 @@ async function _loadOlderMessages() { const container = $('messages'); const prevScrollH = container ? container.scrollHeight : 0; S.messages = nextMessages; + _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls, responseSession._messages_offset||0); // renderMessages() windows long transcripts from the end. If we do not // expand that window before rendering, the newly prepended page stays // hidden and the "hidden" counter rises while the viewport appears stuck. @@ -1584,6 +1598,7 @@ async function _ensureAllMessagesLoaded() { S.messages = msgs; _messagesTruncated = false; _oldestIdx = 0; + _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls, data.session._messages_offset||0); if (S.session && S.session.session_id === sid) { S.session.message_count = Number(data.session.message_count || msgs.length); } diff --git a/tests/test_issue401.py b/tests/test_issue401.py index 174645e43..8519a2002 100644 --- a/tests/test_issue401.py +++ b/tests/test_issue401.py @@ -28,10 +28,10 @@ def test_loadsession_preserves_tool_rows(): def test_loadsession_uses_session_toolcalls_only_as_fallback(): """Session summaries are the fallback, not the primary reload source.""" - assert ("if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length)" in SESSIONS_JS or - "if (!hasMessageToolMetadata && data.session.tool_calls && data.session.tool_calls.length)" in SESSIONS_JS) - assert ("S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));" in SESSIONS_JS or - "S.toolCalls = data.session.tool_calls.map(tc => ({...tc, done: true}));" in SESSIONS_JS) + assert "function _syncToolCallsForLoadedMessages(messages, sessionToolCalls, windowOffset=0)" in SESSIONS_JS + assert "if(!hasMessageToolMetadata&&Array.isArray(sessionToolCalls)&&sessionToolCalls.length)" in SESSIONS_JS + assert "const offset=Number.isFinite(Number(windowOffset))?Number(windowOffset):0;" in SESSIONS_JS + assert "copy.assistant_msg_idx=idx-offset;" in SESSIONS_JS assert "S.toolCalls=[];" in SESSIONS_JS diff --git a/tests/test_session_tail_payload.py b/tests/test_session_tail_payload.py index da6cad85f..82e9aee0f 100644 --- a/tests/test_session_tail_payload.py +++ b/tests/test_session_tail_payload.py @@ -92,7 +92,10 @@ def test_tail_window_keeps_only_visible_session_tool_calls_for_legacy_messages_w payload = _invoke(session) assert payload["messages"] == [session.messages[-1]] - assert payload["tool_calls"] == [session.tool_calls[-1]] + assert payload["tool_calls"] == [ + {"name": "visible-tool", "snippet": "visible snippet", "assistant_msg_idx": 0} + ] + assert session.tool_calls[-1]["assistant_msg_idx"] == 1 def test_full_load_keeps_all_session_tool_calls_for_legacy_messages_without_metadata(): @@ -129,5 +132,8 @@ def test_msg_before_window_keeps_only_that_page_session_tool_calls(): ) assert payload["messages"] == session.messages[1:3] - assert payload["tool_calls"] == [session.tool_calls[0]] + assert payload["tool_calls"] == [ + {"name": "first-page-tool", "snippet": "kept", "assistant_msg_idx": 0} + ] + assert session.tool_calls[0]["assistant_msg_idx"] == 1 assert payload["_messages_offset"] == 1 diff --git a/tests/test_tool_call_history_paging.py b/tests/test_tool_call_history_paging.py new file mode 100644 index 000000000..218d383fc --- /dev/null +++ b/tests/test_tool_call_history_paging.py @@ -0,0 +1,27 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def test_sessions_js_resyncs_tool_calls_after_history_window_replacement(): + """History paging replaces S.messages with a larger window. + + Legacy sessions keep tool card data in session.tool_calls, so that side data + must be refreshed alongside the message window. Otherwise renderMessages() + can keep stale anchors and show unloaded/thinking placeholders while the + user scrolls through history. + """ + assert "function _syncToolCallsForLoadedMessages(messages, sessionToolCalls, windowOffset=0)" in SESSIONS_JS + assert "_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls, data.session._messages_offset||0);" in SESSIONS_JS + assert "S.messages = nextMessages;\n _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls, responseSession._messages_offset||0);" in SESSIONS_JS + assert "S.messages = msgs;\n _messagesTruncated = false;\n _oldestIdx = 0;\n _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls, data.session._messages_offset||0);" in SESSIONS_JS + + +def test_sessions_js_clears_session_tool_calls_when_messages_have_own_metadata(): + assert "const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;" in SESSIONS_JS + assert "const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');" in SESSIONS_JS + assert "const offset=Number.isFinite(Number(windowOffset))?Number(windowOffset):0;" in SESSIONS_JS + assert "const copy={...tc,done:true};" in SESSIONS_JS + assert "S.toolCalls=[];" in SESSIONS_JS From b891e4fe7717ff206dec47662f9179a8b23a497e Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 30 May 2026 16:10:52 +0200 Subject: [PATCH 2/2] fix: avoid double rebasing legacy tool cards --- static/sessions.js | 18 +++++----------- tests/test_issue401.py | 30 +++++++++++++++++++++++--- tests/test_session_tail_payload.py | 7 ++++-- tests/test_tool_call_history_paging.py | 13 +++++------ 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 8a9a4ed40..a4ae889cb 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1310,7 +1310,7 @@ let _messagesTruncated = false; // Older messages are loaded on-demand via _loadOlderMessages(). const _INITIAL_MSG_LIMIT = 30; -function _syncToolCallsForLoadedMessages(messages, sessionToolCalls, windowOffset=0){ +function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){ const msgs=Array.isArray(messages)?messages:[]; const hasMessageToolMetadata=msgs.some(m=>{ if(!m) return false; @@ -1319,15 +1319,7 @@ function _syncToolCallsForLoadedMessages(messages, sessionToolCalls, windowOffse return hasTc||hasTu; }); if(!hasMessageToolMetadata&&Array.isArray(sessionToolCalls)&&sessionToolCalls.length){ - const offset=Number.isFinite(Number(windowOffset))?Number(windowOffset):0; - S.toolCalls=sessionToolCalls.map(tc=>{ - const copy={...tc,done:true}; - const idx=copy.assistant_msg_idx; - if(Number.isInteger(idx)&&idx>=offset&&idx({...tc,done:true})); }else{ S.toolCalls=[]; } @@ -1350,7 +1342,7 @@ 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, data.session._messages_offset||0); + _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls); clearLiveToolCards(); // #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration, // _turnTps, _gatewayRouting, _statusCard) across the loadSession replace. @@ -1513,7 +1505,7 @@ async function _loadOlderMessages() { const container = $('messages'); const prevScrollH = container ? container.scrollHeight : 0; S.messages = nextMessages; - _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls, responseSession._messages_offset||0); + _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls); // renderMessages() windows long transcripts from the end. If we do not // expand that window before rendering, the newly prepended page stays // hidden and the "hidden" counter rises while the viewport appears stuck. @@ -1598,7 +1590,7 @@ async function _ensureAllMessagesLoaded() { S.messages = msgs; _messagesTruncated = false; _oldestIdx = 0; - _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls, data.session._messages_offset||0); + _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls); if (S.session && S.session.session_id === sid) { S.session.message_count = Number(data.session.message_count || msgs.length); } diff --git a/tests/test_issue401.py b/tests/test_issue401.py index 8519a2002..ab8b3c260 100644 --- a/tests/test_issue401.py +++ b/tests/test_issue401.py @@ -28,10 +28,10 @@ def test_loadsession_preserves_tool_rows(): def test_loadsession_uses_session_toolcalls_only_as_fallback(): """Session summaries are the fallback, not the primary reload source.""" - assert "function _syncToolCallsForLoadedMessages(messages, sessionToolCalls, windowOffset=0)" in SESSIONS_JS + assert "function _syncToolCallsForLoadedMessages(messages, sessionToolCalls)" in SESSIONS_JS assert "if(!hasMessageToolMetadata&&Array.isArray(sessionToolCalls)&&sessionToolCalls.length)" in SESSIONS_JS - assert "const offset=Number.isFinite(Number(windowOffset))?Number(windowOffset):0;" in SESSIONS_JS - assert "copy.assistant_msg_idx=idx-offset;" in SESSIONS_JS + assert "windowOffset" not in SESSIONS_JS + assert "copy.assistant_msg_idx=idx-offset;" not in SESSIONS_JS assert "S.toolCalls=[];" in SESSIONS_JS @@ -114,3 +114,27 @@ def test_reload_uses_session_summary_when_messages_have_no_tool_metadata(): assert result["has_metadata"] is False assert result["fallback_len"] == 1 assert result["done_flag"] is True + + +def test_rebased_legacy_toolcalls_stay_distinct_in_paged_window(): + """Backend-rebased legacy tool calls must not be rebased a second time.""" + result = _run_js(""" + const messages = [ + { role: 'assistant', content: 'first legacy page row' }, + { role: 'assistant', content: 'second legacy page row' } + ]; + const sessionToolCalls = [ + { name: 'first_tool', assistant_msg_idx: 0, snippet: 'first' }, + { name: 'second_tool', assistant_msg_idx: 1, snippet: 'second' } + ]; + const loaded = loadSessionShape(messages, sessionToolCalls); + const renderedRawIdxs = messages.map((_, idx) => idx); + process.stdout.write(JSON.stringify({ + indices: loaded.toolCalls.map(tc => tc.assistant_msg_idx), + distinct_indices: new Set(loaded.toolCalls.map(tc => tc.assistant_msg_idx)).size, + anchors_visible: loaded.toolCalls.every(tc => renderedRawIdxs.includes(tc.assistant_msg_idx)) + })); + """) + assert result["indices"] == [0, 1] + assert result["distinct_indices"] == 2 + assert result["anchors_visible"] is True diff --git a/tests/test_session_tail_payload.py b/tests/test_session_tail_payload.py index 82e9aee0f..dee5600c4 100644 --- a/tests/test_session_tail_payload.py +++ b/tests/test_session_tail_payload.py @@ -117,11 +117,12 @@ def test_msg_before_window_keeps_only_that_page_session_tool_calls(): session = _FakeSession([ {"role": "user", "content": "first"}, {"role": "assistant", "content": "second legacy message"}, - {"role": "user", "content": "third"}, + {"role": "assistant", "content": "third legacy message"}, {"role": "assistant", "content": "fourth legacy message"}, ]) session.tool_calls = [ {"name": "first-page-tool", "snippet": "kept", "assistant_msg_idx": 1}, + {"name": "second-page-tool", "snippet": "also kept", "assistant_msg_idx": 2}, {"name": "tail-tool", "snippet": "not in page", "assistant_msg_idx": 3}, {"name": "unindexed-tool", "snippet": "cannot place"}, ] @@ -133,7 +134,9 @@ def test_msg_before_window_keeps_only_that_page_session_tool_calls(): assert payload["messages"] == session.messages[1:3] assert payload["tool_calls"] == [ - {"name": "first-page-tool", "snippet": "kept", "assistant_msg_idx": 0} + {"name": "first-page-tool", "snippet": "kept", "assistant_msg_idx": 0}, + {"name": "second-page-tool", "snippet": "also kept", "assistant_msg_idx": 1}, ] assert session.tool_calls[0]["assistant_msg_idx"] == 1 + assert session.tool_calls[1]["assistant_msg_idx"] == 2 assert payload["_messages_offset"] == 1 diff --git a/tests/test_tool_call_history_paging.py b/tests/test_tool_call_history_paging.py index 218d383fc..23cf9b344 100644 --- a/tests/test_tool_call_history_paging.py +++ b/tests/test_tool_call_history_paging.py @@ -13,15 +13,16 @@ def test_sessions_js_resyncs_tool_calls_after_history_window_replacement(): can keep stale anchors and show unloaded/thinking placeholders while the user scrolls through history. """ - assert "function _syncToolCallsForLoadedMessages(messages, sessionToolCalls, windowOffset=0)" in SESSIONS_JS - assert "_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls, data.session._messages_offset||0);" in SESSIONS_JS - assert "S.messages = nextMessages;\n _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls, responseSession._messages_offset||0);" in SESSIONS_JS - assert "S.messages = msgs;\n _messagesTruncated = false;\n _oldestIdx = 0;\n _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls, data.session._messages_offset||0);" in SESSIONS_JS + assert "function _syncToolCallsForLoadedMessages(messages, sessionToolCalls)" in SESSIONS_JS + assert "_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);" in SESSIONS_JS + assert "S.messages = nextMessages;\n _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls);" in SESSIONS_JS + assert "S.messages = msgs;\n _messagesTruncated = false;\n _oldestIdx = 0;\n _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);" in SESSIONS_JS def test_sessions_js_clears_session_tool_calls_when_messages_have_own_metadata(): assert "const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;" in SESSIONS_JS assert "const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');" in SESSIONS_JS - assert "const offset=Number.isFinite(Number(windowOffset))?Number(windowOffset):0;" in SESSIONS_JS - assert "const copy={...tc,done:true};" in SESSIONS_JS + assert "windowOffset" not in SESSIONS_JS + assert "copy.assistant_msg_idx=idx-offset;" not in SESSIONS_JS + assert "S.toolCalls=sessionToolCalls.map(tc=>({...tc,done:true}));" in SESSIONS_JS assert "S.toolCalls=[];" in SESSIONS_JS