fix(session): anchor message windows on renderable tail rows

This commit is contained in:
ai-ag2026
2026-05-26 23:37:08 +02:00
parent 48a2e79224
commit fceb255252
2 changed files with 112 additions and 16 deletions
+54 -16
View File
@@ -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