mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
Merge branch 'pr-3187' into release/stage-batch55
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
|
||||
- Hidden pre-compression snapshots no longer keep stale pin state or count toward the visible pinned-session quota (#3181).
|
||||
|
||||
- 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)
|
||||
|
||||
|
||||
+10
-2
@@ -2276,7 +2276,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
|
||||
@@ -2288,7 +2294,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
|
||||
|
||||
|
||||
|
||||
+18
-11
@@ -1310,6 +1310,21 @@ let _messagesTruncated = false;
|
||||
// Older messages are loaded on-demand via _loadOlderMessages().
|
||||
const _INITIAL_MSG_LIMIT = 30;
|
||||
|
||||
function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){
|
||||
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){
|
||||
S.toolCalls=sessionToolCalls.map(tc=>({...tc,done:true}));
|
||||
}else{
|
||||
S.toolCalls=[];
|
||||
}
|
||||
}
|
||||
|
||||
async function _ensureMessagesLoaded(sid) {
|
||||
// Already have messages? (e.g. from INFLIGHT restore path, already set)
|
||||
if (S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) {
|
||||
@@ -1327,17 +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);
|
||||
// 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);
|
||||
clearLiveToolCards();
|
||||
// #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration,
|
||||
// _turnTps, _gatewayRouting, _statusCard) across the loadSession replace.
|
||||
@@ -1500,6 +1505,7 @@ async function _loadOlderMessages() {
|
||||
const container = $('messages');
|
||||
const prevScrollH = container ? container.scrollHeight : 0;
|
||||
S.messages = nextMessages;
|
||||
_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.
|
||||
@@ -1584,6 +1590,7 @@ async function _ensureAllMessagesLoaded() {
|
||||
S.messages = msgs;
|
||||
_messagesTruncated = false;
|
||||
_oldestIdx = 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);
|
||||
}
|
||||
|
||||
+28
-4
@@ -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)" in SESSIONS_JS
|
||||
assert "if(!hasMessageToolMetadata&&Array.isArray(sessionToolCalls)&&sessionToolCalls.length)" 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
|
||||
|
||||
@@ -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():
|
||||
@@ -114,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"},
|
||||
]
|
||||
@@ -129,5 +133,10 @@ 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},
|
||||
{"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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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)" 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 "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
|
||||
Reference in New Issue
Block a user