fix: avoid double rebasing legacy tool cards

This commit is contained in:
ai-ag2026 2026-05-30 16:10:52 +02:00
parent d57854807a
commit b891e4fe77
4 changed files with 44 additions and 24 deletions

View file

@ -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<offset+msgs.length){
copy.assistant_msg_idx=idx-offset;
}
return copy;
});
S.toolCalls=sessionToolCalls.map(tc=>({...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);
}

View file

@ -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

View file

@ -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

View file

@ -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