mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-28 04:30:18 +00:00
fix(session): anchor message windows on renderable tail rows
This commit is contained in:
+54
-16
@@ -2163,6 +2163,54 @@ def _tool_calls_for_message_window(tool_calls, start_idx: int, message_count: in
|
||||
return filtered
|
||||
|
||||
|
||||
def _message_counts_as_renderable_for_window(message) -> bool:
|
||||
"""Return true when a paginated window should include this transcript row.
|
||||
|
||||
Tool result rows are rendered through their assistant anchor or hidden as raw
|
||||
tool output. A tail page containing only tool rows makes the frontend set
|
||||
``S.messages`` to a non-empty array while the visible transcript and topbar
|
||||
count stay empty. Anchor small tail windows on the newest non-tool row so
|
||||
long sessions do not open to a blank chat with only transient metadata.
|
||||
"""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
role = str(message.get("role") or "").strip().lower()
|
||||
return bool(role and role != "tool")
|
||||
|
||||
|
||||
def _message_window_for_display(messages, msg_limit=None, msg_before=None) -> tuple[list, int]:
|
||||
"""Return a paginated message window plus its offset in ``messages``.
|
||||
|
||||
The normal fast path is a raw tail window. If that window contains no
|
||||
renderable transcript rows because state.db appended hidden tool rows after
|
||||
the visible assistant tail, shift the window end back to the newest
|
||||
renderable row. This preserves the raw index cursor while avoiding the
|
||||
WebUI blank-transcript trap.
|
||||
"""
|
||||
messages = list(messages or [])
|
||||
if msg_before is not None:
|
||||
before_idx = max(0, min(int(msg_before), len(messages)))
|
||||
else:
|
||||
before_idx = len(messages)
|
||||
source = messages[:before_idx]
|
||||
if not source:
|
||||
return [], 0
|
||||
if not msg_limit:
|
||||
return source, 0
|
||||
limit = max(1, int(msg_limit))
|
||||
end_idx = len(source)
|
||||
start_idx = max(0, end_idx - limit)
|
||||
window = source[start_idx:end_idx]
|
||||
if window and not any(_message_counts_as_renderable_for_window(msg) for msg in window):
|
||||
for idx in range(end_idx - 1, -1, -1):
|
||||
if _message_counts_as_renderable_for_window(source[idx]):
|
||||
end_idx = idx + 1
|
||||
start_idx = max(0, end_idx - limit)
|
||||
window = source[start_idx:end_idx]
|
||||
break
|
||||
return window, start_idx
|
||||
|
||||
|
||||
def _merged_session_messages_for_display(session, cli_messages=None) -> list:
|
||||
"""Return the message coordinate space exposed by ``GET /api/session``.
|
||||
|
||||
@@ -4090,29 +4138,19 @@ def handle_get(handler, parsed) -> bool:
|
||||
_summary_message_count = None
|
||||
_summary_last_message_at = None
|
||||
if load_messages:
|
||||
_truncated_msgs, _messages_offset = _message_window_for_display(
|
||||
_all_msgs,
|
||||
msg_limit=msg_limit,
|
||||
msg_before=msg_before,
|
||||
)
|
||||
if msg_before is not None:
|
||||
# Scroll-to-top paging: msg_before is a 0-based index into
|
||||
# the full message list. Return the msg_limit messages that
|
||||
# appear *before* this index (i.e. older messages).
|
||||
# Using index instead of timestamp avoids issues with
|
||||
# duplicate/missing timestamps.
|
||||
_before_idx = max(0, min(int(msg_before), len(_all_msgs)))
|
||||
_slice = _all_msgs[:_before_idx]
|
||||
_truncated_msgs = _slice[-msg_limit:] if msg_limit else _slice
|
||||
elif msg_limit and len(_all_msgs) > msg_limit:
|
||||
_truncated_msgs = _all_msgs[-msg_limit:]
|
||||
else:
|
||||
_truncated_msgs = _all_msgs
|
||||
else:
|
||||
_truncated_msgs = []
|
||||
_messages_offset = 0
|
||||
# Index of the first returned message in the full message array.
|
||||
# Frontend uses this as cursor for scroll-to-top paging.
|
||||
if load_messages and msg_before is not None:
|
||||
_messages_offset = max(0, _before_idx - len(_truncated_msgs))
|
||||
elif load_messages:
|
||||
_messages_offset = max(0, len(_all_msgs) - len(_truncated_msgs))
|
||||
else:
|
||||
_messages_offset = 0
|
||||
_windowed_messages = (
|
||||
load_messages
|
||||
and msg_limit is not None
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from api.routes import _message_window_for_display
|
||||
|
||||
|
||||
def test_initial_msg_limit_skips_trailing_tool_only_rows():
|
||||
messages = [
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "answer"},
|
||||
] + [
|
||||
{"role": "tool", "content": f"tool result {idx}"}
|
||||
for idx in range(40)
|
||||
]
|
||||
|
||||
window, offset = _message_window_for_display(messages, msg_limit=5)
|
||||
|
||||
assert [m["role"] for m in window] == ["user", "assistant"]
|
||||
assert offset == 0
|
||||
|
||||
|
||||
def test_msg_limit_keeps_raw_tail_when_it_has_renderable_rows():
|
||||
messages = [
|
||||
{"role": "user", "content": f"u{idx}"} if idx % 2 == 0 else {"role": "assistant", "content": f"a{idx}"}
|
||||
for idx in range(10)
|
||||
]
|
||||
|
||||
window, offset = _message_window_for_display(messages, msg_limit=4)
|
||||
|
||||
assert [m["content"] for m in window] == ["u6", "a7", "u8", "a9"]
|
||||
assert offset == 6
|
||||
|
||||
|
||||
def test_msg_before_anchors_page_before_trailing_tool_rows():
|
||||
messages = [
|
||||
{"role": "user", "content": "older"},
|
||||
{"role": "assistant", "content": "visible before tools"},
|
||||
] + [
|
||||
{"role": "tool", "content": f"hidden {idx}"}
|
||||
for idx in range(12)
|
||||
] + [
|
||||
{"role": "assistant", "content": "newer visible"},
|
||||
]
|
||||
|
||||
window, offset = _message_window_for_display(messages, msg_limit=3, msg_before=14)
|
||||
|
||||
assert [m["role"] for m in window] == ["user", "assistant"]
|
||||
assert [m["content"] for m in window] == ["older", "visible before tools"]
|
||||
assert offset == 0
|
||||
|
||||
|
||||
def test_all_tool_session_keeps_tail_fallback():
|
||||
messages = [
|
||||
{"role": "tool", "content": f"tool {idx}"}
|
||||
for idx in range(6)
|
||||
]
|
||||
|
||||
window, offset = _message_window_for_display(messages, msg_limit=3)
|
||||
|
||||
assert [m["content"] for m in window] == ["tool 3", "tool 4", "tool 5"]
|
||||
assert offset == 3
|
||||
Reference in New Issue
Block a user