diff --git a/api/routes.py b/api/routes.py index 420dffe6..c957fe64 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 diff --git a/tests/test_session_message_window_renderable_tail.py b/tests/test_session_message_window_renderable_tail.py new file mode 100644 index 00000000..01fc99ae --- /dev/null +++ b/tests/test_session_message_window_renderable_tail.py @@ -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