From d769acf43fd1255b3554708ecbb3653a660d47e6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 14 Jun 2026 19:18:11 -0700 Subject: [PATCH] fix(sessions): paginate tool-heavy session tails by visible messages (#4069) (#4215) * fix: bound paginated session tails by visible messages * fix(#4070): always merge state.db tail rows (drop the stale-tail timestamp shortcut) Deep-review finding: the sidecar-only timestamp shortcut returned the sidecar verbatim whenever state.db had no strictly-newer rows, but a state.db row at-or-before the sidecar's newest timestamp (recovery / edited-in-place / missing-timestamp) is still absent from the sidecar -> the paginated tail diverged from the full merge path (empirically 3/6 adversarial cases). Always run the cheap in-memory append-only merge; the real latency win (skipping the lineage-parent DISK load) is preserved. Co-authored-by: t3chn0pr13st * fix(#4070): keep matching trailing tool-result row in paginated window Codex ship-gate finding: _message_window_for_display pulled end_idx back to the last renderable (user/assistant) row, dropping a trailing role:tool result row even when it's the result of the newest assistant tool-call. The renderer rebuilds tool cards from these rows (CLI-origin / empty S.toolCalls path, static/ui.js resultsByTid), so the card lost its result snippet. Now extend end_idx forward through trailing tool rows whose tool_call_id matches a tool-call present in the window; orphan trailing tool rows (no match) are still skipped to preserve the visible-row budget. +2 regression tests. Co-authored-by: t3chn0pr13st * chore(#4070): remove now-dead _max_message_timestamp + its import Opus gate nit: _max_message_timestamp was only called by the removed sidecar-timestamp shortcut, and the _message_timestamp_as_float import existed solely to feed it. Both removed; _message_timestamp_as_float still lives in api/models.py for its real callers. * chore: add #4069 CHANGELOG entry (visible-message tail pagination) * chore(release): stamp v0.51.424 (Release OK) for #4069 --------- Co-authored-by: t3chn0pr13st Co-authored-by: nesquena-hermes Co-authored-by: t3chn0pr13st --- CHANGELOG.md | 6 + api/routes.py | 226 +++++++++++++----- static/sessions.js | 12 +- ..._session_message_window_renderable_tail.py | 61 ++++- tests/test_session_tail_payload.py | 133 +++++++++++ 5 files changed, 359 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2704aba3..737a53d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.424] — 2026-06-15 — Release OK (visible-message tail pagination, #4069) + +### Fixed + +- **Tool-heavy session tails now paginate by visible messages, so a "page" shows a consistent number of real replies.** Loading a session with many tool-call rows previously let hidden tool rows consume the per-page message budget, so a page could show very few actual user/assistant messages. The paginated window is now sized by visible (user/assistant) rows while still carrying the tool-result rows needed to render each assistant tool card's result snippet, and large hidden tool outputs are bounded in the paginated payload. The paginated tail is a contiguous slice of the same merged transcript the full load produces (no separate raw read path), so it stays display-equivalent. (#4069) + ## [v0.51.423] — 2026-06-15 — Release OJ (virtualize long message transcripts, #500) ### Changed diff --git a/api/routes.py b/api/routes.py index c5913a3a..f5f58e8e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3940,15 +3940,53 @@ def _message_counts_as_renderable_for_window(message) -> bool: return bool(role and role != "tool") +def _tool_call_ids_in_messages(messages) -> set: + """Collect tool-call IDs declared on renderable rows (assistant tool_calls / + partial tool_calls / Anthropic tool_use content blocks) so trailing + tool-result rows can be matched back to a call present in the window.""" + ids = set() + for msg in messages or []: + if not isinstance(msg, dict): + continue + for key in ("tool_calls", "_partial_tool_calls"): + for call in msg.get(key) or []: + if isinstance(call, dict): + cid = call.get("id") or call.get("tool_call_id") + if cid: + ids.add(str(cid)) + content = msg.get("content") + if isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "tool_use": + cid = part.get("id") + if cid: + ids.add(str(cid)) + return ids + + +def _tool_result_matches_call_ids(message, call_ids) -> bool: + """Return True if a role:tool row's tool_call_id/tool_use_id is in ``call_ids``.""" + if not call_ids or not isinstance(message, dict): + return False + if str(message.get("role") or "").lower() != "tool": + return False + tid = message.get("tool_call_id") or message.get("tool_use_id") or "" + return bool(tid) and str(tid) in call_ids + + def _message_window_for_display(messages, msg_limit=None, msg_before=None, expand_renderable=False) -> 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. + ``msg_limit`` is a visible transcript limit, not a raw storage-row cap. + Tool result rows are hidden or folded into assistant tool cards, so they + should not consume the user's "load N messages" budget. Return the smallest + suffix containing the last ``msg_limit`` renderable user/assistant rows, plus + any intervening tool rows needed for card snippets. + + ``expand_renderable`` is accepted for compatibility with older frontend + callers. Visible-row expansion is now the default for every limited window. """ + _ = expand_renderable messages = list(messages or []) if msg_before is not None: before_idx = max(0, min(int(msg_before), len(messages))) @@ -3961,45 +3999,115 @@ def _message_window_for_display(messages, msg_limit=None, msg_before=None, expan return source, 0 limit = max(1, int(msg_limit)) end_idx = len(source) - start_idx = max(0, end_idx - limit) + last_renderable_idx = None + for idx in range(end_idx - 1, -1, -1): + if _message_counts_as_renderable_for_window(source[idx]): + last_renderable_idx = idx + break + if last_renderable_idx is None: + start_idx = max(0, end_idx - limit) + return source[start_idx:end_idx], start_idx + # Keep the last renderable row, plus any immediately-following tool-result + # rows whose tool_call_id matches a tool-call on a renderable row already in + # the window. The renderer rebuilds tool cards (CLI-origin / empty + # S.toolCalls path) from role:"tool" rows indexed by tool_call_id + # (static/ui.js resultsByTid), so dropping the result row that follows the + # newest assistant tool-call would leave that card without its snippet. + # Orphan trailing tool-only rows (no matching call in the window) are still + # skipped, preserving the visible-row budget. (#4070 ship-review) + end_idx = last_renderable_idx + 1 + window_tool_call_ids = _tool_call_ids_in_messages(source[: last_renderable_idx + 1]) + while end_idx < len(source) and not _message_counts_as_renderable_for_window( + source[end_idx] + ): + if _tool_result_matches_call_ids(source[end_idx], window_tool_call_ids): + end_idx += 1 + else: + break + start_idx = 0 + renderable_count = 0 + for idx in range(last_renderable_idx, -1, -1): + if not _message_counts_as_renderable_for_window(source[idx]): + continue + renderable_count += 1 + if renderable_count >= limit: + start_idx = idx + break 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 - if limit > 1 and window and expand_renderable: - # ``msg_limit`` is a raw-message transport cap, but the WebUI renders a - # filtered transcript: role=tool rows, empty separator assistants, and - # compression markers can all disappear. A long tool-heavy tail can - # therefore produce a cold reload that visibly contains only one or two - # messages plus a huge "Load earlier" button. Expand the raw window - # backwards until it contains roughly ``limit`` renderable transcript - # rows, while keeping the raw offset cursor honest. - # - # Gated behind the explicit ``expand_renderable`` flag, which the - # frontend sends ONLY on the initial cold-load fetch. It must NOT apply - # to the "Load earlier" cumulative path (which re-requests with a larger - # msg_limit and no msg_before) nor the msg_before fallback page — those - # keep the raw transport cap so one scroll-up can't pull a whole - # tool-heavy transcript back in a single response. Cold loads are the - # only place the "1-2 visible messages" cliff happens. - renderable_count = sum(1 for msg in window if _message_counts_as_renderable_for_window(msg)) - if renderable_count < limit: - target_renderable = min( - limit, - sum(1 for msg in source if _message_counts_as_renderable_for_window(msg)), - ) - while start_idx > 0 and renderable_count < target_renderable: - start_idx -= 1 - if _message_counts_as_renderable_for_window(source[start_idx]): - renderable_count += 1 - window = source[start_idx:end_idx] return window, start_idx +_LIMITED_TOOL_CONTENT_MAX_CHARS = 4096 +_LIMITED_TOOL_CONTENT_NOTICE = ( + "\n\n[Tool output truncated in paginated session response; " + "load the full transcript to inspect the complete result.]" +) + + +def _tool_message_for_limited_payload(message): + """Return a bounded copy of large hidden tool-result rows for paginated loads.""" + if not isinstance(message, dict) or str(message.get("role") or "").lower() != "tool": + return message + content = message.get("content") + if content in (None, ""): + return message + if isinstance(content, str): + text = content + else: + try: + text = json.dumps(content, ensure_ascii=False, default=str) + except Exception: + text = str(content) + if len(text) <= _LIMITED_TOOL_CONTENT_MAX_CHARS: + return message + clipped = dict(message) + preview = text[:_LIMITED_TOOL_CONTENT_MAX_CHARS] + _LIMITED_TOOL_CONTENT_NOTICE + if isinstance(content, str): + clipped["content"] = preview + elif isinstance(content, list): + clipped["content"] = [{"type": "text", "text": preview}] + elif isinstance(content, dict): + clipped["content"] = {"_truncated": True, "preview": preview} + else: + clipped["content"] = preview + clipped["_content_truncated"] = True + clipped["_content_original_chars"] = len(text) + return clipped + + +def _messages_for_limited_payload(messages) -> list: + """Bound hidden tool-result payloads before sending a msg_limit response.""" + return [_tool_message_for_limited_payload(msg) for msg in list(messages or [])] + + +def _limited_webui_messages_for_display(session, state_db_messages) -> list: + """Return the display sidecar plus only necessary state.db rows for msg_limit. + + Paginated session loads are latency-sensitive and should not stitch every + lineage segment before slicing the tail. Keep the lightweight + pre-compression snapshot stitch so continuation sessions can still reveal + archived history, then merge only newer state.db rows that have not reached + the sidecar yet. + """ + sidecar_messages = _webui_sidecar_lineage_messages_for_display(session) + state_db_messages = list(state_db_messages or []) + if not state_db_messages: + return sidecar_messages + # NOTE: do not short-circuit to the sidecar when state.db has no strictly + # newer rows. A state.db row whose timestamp is at-or-before the sidecar's + # newest (recovery / edited-in-place / missing-timestamp cases) is still + # absent from the sidecar and must be reconciled — dropping it would render + # a tail that differs from the full merge path (silent wrong-transcript on + # the paginated load). The append-only merge is O(n) over already-bounded + # in-memory lists; the real latency win here is skipping the lineage-parent + # DISK load above, which we still skip. (#4070 ship-review) + return merge_session_messages_append_only( + sidecar_messages, + state_db_messages, + truncation_watermark=getattr(session, "truncation_watermark", None), + ) + + def _messages_start_with_visible_prefix(messages, prefix) -> bool: """Return True when ``messages`` already replays ``prefix`` in display order.""" messages = list(messages or []) @@ -6592,10 +6700,10 @@ def handle_get(handler, parsed) -> bool: load_messages = query.get("messages", ["1"])[0] != "0" resolve_model_default = "1" if load_messages else "0" resolve_model = query.get("resolve_model", [resolve_model_default])[0] != "0" - # ?msg_limit=N returns only the last N messages (tail window). - # Used by the frontend for fast session switching — avoids serialising - # and sending hundreds of messages when the user only sees the most - # recent exchange. Older messages are loaded on-demand via scrolling. + # ?msg_limit=N returns a tail window containing the last N visible + # transcript rows. Hidden tool-result rows do not consume the budget; + # they are included only when they sit inside the selected window and + # are bounded before serialization. Older rows load on-demand. _msg_limit = query.get("msg_limit", [None])[0] try: msg_limit = max(1, int(_msg_limit)) if _msg_limit else None @@ -6609,12 +6717,9 @@ def handle_get(handler, parsed) -> bool: msg_before = int(_msg_before) if _msg_before else None except (ValueError, TypeError): msg_before = None - # ?expand_renderable=1 — sent ONLY by the initial cold-load fetch. When - # set, the tail window is expanded backward until it holds ~msg_limit - # *renderable* rows (tool/separator/compression rows are UI-filtered) so - # a tool-heavy session doesn't cold-load showing 1-2 visible messages. - # The "Load earlier" cumulative path and msg_before pages do NOT send it, - # keeping their raw transport cap (#3790). + # ?expand_renderable=1 is retained for compatibility with older + # frontends. msg_limit now counts visible transcript rows by default, so + # the flag no longer changes the server-side pagination semantics. _expand_renderable = query.get("expand_renderable", [None])[0] expand_renderable = str(_expand_renderable).strip() in ("1", "true", "True") try: @@ -6661,13 +6766,15 @@ def handle_get(handler, parsed) -> bool: # different slices of the same stitched conversation, merge # them chronologically and dedupe exact repeats. _all_msgs = _merged_session_messages_for_display(s, cli_messages) + elif msg_limit is not None: + _all_msgs = _limited_webui_messages_for_display(s, state_db_messages) else: _all_msgs = merge_session_messages_append_only( _webui_sidecar_lineage_messages_for_display(s), state_db_messages, truncation_watermark=getattr(s, "truncation_watermark", None), ) - _all_msgs = _merged_webui_lineage_messages_for_display(s, _all_msgs) + _all_msgs = _merged_webui_lineage_messages_for_display(s, _all_msgs) else: if is_messaging_session and cli_messages: _all_msgs = _merged_session_messages_for_display(s, cli_messages) @@ -6704,9 +6811,8 @@ def handle_get(handler, parsed) -> bool: msg_before=msg_before, expand_renderable=expand_renderable, ) - if msg_before is not None: - _before_idx = max(0, min(int(msg_before), len(_all_msgs))) - _slice = _all_msgs[:_before_idx] + if msg_limit is not None: + _truncated_msgs = _messages_for_limited_payload(_truncated_msgs) else: _truncated_msgs = [] _messages_offset = 0 @@ -6839,14 +6945,10 @@ def handle_get(handler, parsed) -> bool: # keep the raw count available as ``actual_message_count`` but # do not let it make the frontend expect phantom messages. raw["message_count"] = _merged_message_count - # Signal to the frontend that older messages were omitted. - # For msg_before paging, compare against the filtered set, - # not the full list — otherwise we signal truncation even when - # all older messages were returned. - if msg_before is not None: - _truncated = load_messages and msg_limit is not None and len(_slice) > msg_limit - else: - _truncated = load_messages and msg_limit is not None and len(_all_msgs) > msg_limit + # Signal to the frontend that older messages were omitted. The + # message window cursor already reflects visible-row pagination and + # avoids false positives when raw hidden tool rows exceed msg_limit. + _truncated = load_messages and msg_limit is not None and _messages_offset > 0 raw["_messages_truncated"] = _truncated raw["_messages_offset"] = _messages_offset _t4 = _time.monotonic() diff --git a/static/sessions.js b/static/sessions.js index 118f531d..9a2e1da0 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1800,7 +1800,9 @@ let _messagesTruncated = false; // Called after loadSession fetches metadata (messages=0). // Idempotent: if messages are already in S.messages, resolves immediately. // Handles streaming sessions specially: restores from INFLIGHT cache or API. -// msg_limit (default 30): only fetch the last N messages for fast switching. +// msg_limit (default 30): fetch a tail window with roughly N visible +// user/assistant rows for fast switching. Tool rows inside the window are +// server-bounded and do not consume the visible-message budget. // Older messages are loaded on-demand via _loadOlderMessages(). const _INITIAL_MSG_LIMIT = 30; let _sameSessionForceReloadHint = null; @@ -1886,11 +1888,9 @@ async function _ensureMessagesLoaded(sid) { // Fetch session messages with a tail window for fast initial load. const reloadLimit = _messageReloadLimitForSession(sid); // defaults to _INITIAL_MSG_LIMIT const reloadLimitParam = reloadLimit ? `&msg_limit=${reloadLimit}` : ''; - // expand_renderable=1 is sent ONLY here, on the initial cold load: it tells - // the server to expand the tail window backward until it holds ~msg_limit - // *renderable* rows so a tool-heavy session doesn't open showing 1-2 visible - // messages (#3790). The "Load earlier" path (_loadOlderMessages) deliberately - // omits it to keep its raw transport cap. + // Older frontends used expand_renderable=1 to request visible-row expansion. + // The server now counts msg_limit by visible transcript rows by default; keep + // the flag for compatibility with mixed-version deployments. const expandParam = reloadLimit ? '&expand_renderable=1' : ''; let data; try { diff --git a/tests/test_session_message_window_renderable_tail.py b/tests/test_session_message_window_renderable_tail.py index 54ac26bb..a2515b73 100644 --- a/tests/test_session_message_window_renderable_tail.py +++ b/tests/test_session_message_window_renderable_tail.py @@ -102,14 +102,8 @@ def test_cold_load_flag_expands_window_to_fill_renderable_rows(): assert [m["content"] for m in window if m["role"] != "tool"] == ["a5", "u6", "a7", "u8", "a9"] -def test_cumulative_load_earlier_does_not_expand_without_flag(): - """The 'Load earlier' path (larger msg_limit, no flag, no msg_before) keeps the raw cap. - - Codex CORE finding: _loadOlderMessages re-requests with a larger msg_limit - and NO msg_before, so a msg_before-based gate would still expand and pull the - whole tool-heavy transcript. With the explicit expand_renderable flag OFF - (which is how _loadOlderMessages calls it), the raw tail cap is preserved. - """ +def test_cumulative_load_earlier_counts_visible_rows_without_expand_flag(): + """The 'Load earlier' path counts user/assistant rows, not raw tool rows.""" messages = [ ({"role": "user", "content": f"u{i}"} if i % 2 == 0 else {"role": "assistant", "content": f"a{i}"}) for i in range(10) @@ -121,9 +115,8 @@ def test_cumulative_load_earlier_does_not_expand_without_flag(): # Same input as the cold-load test, but no expand flag (cumulative path). window, offset = _message_window_for_display(messages, msg_limit=5, expand_renderable=False) - # Raw tail cap honored: window is the last 5 raw rows (a9 + 4 tools), NOT expanded. - assert offset == 9 - assert [m["content"] for m in window] == ["a9", "tool 10", "tool 11", "tool 12", "tool 13"] + assert offset == 5 + assert [m["content"] for m in window] == ["a5", "u6", "a7", "u8", "a9"] def test_cold_load_expands_but_caps_at_total_renderable(): @@ -145,3 +138,49 @@ def test_cold_load_expands_but_caps_at_total_renderable(): # Only 1 renderable row in the whole session → expand back to index 0. assert offset == 0 assert window[0]["content"] == "only-user" + + +def test_initial_msg_limit_keeps_matching_trailing_tool_result_row(): + """A role:tool result row whose tool_call_id matches the newest assistant + tool-call must be retained in the paginated window — the renderer rebuilds + tool cards from these rows (CLI-origin / empty S.toolCalls path), so dropping + it leaves the card without its result snippet. (#4070 ship-review) + """ + messages = [ + {"role": "user", "content": "do a thing"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "call_1", "function": {"name": "terminal", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "the result output"}, + ] + + window, offset = _message_window_for_display(messages, msg_limit=30) + + assert [m["role"] for m in window] == ["user", "assistant", "tool"] + assert any( + m["role"] == "tool" and m.get("tool_call_id") == "call_1" for m in window + ) + assert offset == 0 + + +def test_initial_msg_limit_skips_orphan_trailing_tool_rows_without_match(): + """Trailing tool rows with NO matching tool-call in the window are still + skipped (they don't consume the visible-row budget). Guards against the + matching-tool fix over-extending the window. (#4070 ship-review) + """ + messages = [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "answer"}, + ] + [ + {"role": "tool", "tool_call_id": f"orphan_{idx}", "content": f"r{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 diff --git a/tests/test_session_tail_payload.py b/tests/test_session_tail_payload.py index 338aa483..f1a8a21c 100644 --- a/tests/test_session_tail_payload.py +++ b/tests/test_session_tail_payload.py @@ -60,6 +60,7 @@ def _invoke(session, query=None): with patch("api.routes.get_session", return_value=session), \ patch("api.routes._clear_stale_stream_state", return_value=False), \ patch("api.routes._lookup_cli_session_metadata", return_value={}), \ + patch("api.routes.get_state_db_session_messages", return_value=[]), \ patch("api.routes.redact_session_data", side_effect=lambda raw: raw), \ patch("api.routes.j", side_effect=fake_j): routes.handle_get(SimpleNamespace(), parsed) @@ -144,3 +145,135 @@ def test_msg_before_window_keeps_only_that_page_session_tool_calls(): assert session.tool_calls[0]["assistant_msg_idx"] == 1 assert session.tool_calls[1]["assistant_msg_idx"] == 2 assert payload["_messages_offset"] == 1 + + +def test_msg_limit_tail_does_not_run_heavy_webui_lineage_merge(): + session = _FakeSession([ + {"role": "user", "content": "older"}, + {"role": "assistant", "content": "visible"}, + ]) + session.parent_session_id = "parent" + session.session_source = "webui" + + with patch( + "api.routes._merged_webui_lineage_messages_for_display", + side_effect=AssertionError("limited loads must not merge parent sidecars"), + ), patch( + "api.routes.Session.load", + return_value=None, + ): + payload = _invoke(session) + + assert payload["messages"] == [session.messages[-1]] + assert payload["message_count"] == 2 + assert payload["_messages_truncated"] is True + + +def test_msg_limit_tail_keeps_pre_compression_snapshot_parent_reachable(): + parent = _FakeSession([ + {"role": "user", "content": "archived question", "timestamp": 1.0}, + {"role": "assistant", "content": "archived answer", "timestamp": 2.0}, + ]) + parent.session_id = "snapshot_parent" + parent.pre_compression_snapshot = True + + child = _FakeSession([ + {"role": "user", "content": "continuation question", "timestamp": 3.0}, + {"role": "assistant", "content": "continuation answer", "timestamp": 4.0}, + ]) + child.parent_session_id = "snapshot_parent" + child.pre_compression_snapshot = False + child.session_source = "webui" + + with patch("api.routes.Session.load", return_value=parent): + payload = _invoke( + child, + query="session_id=tail_payload_001&messages=1&resolve_model=0&msg_limit=30", + ) + + assert [m["content"] for m in payload["messages"]] == [ + "archived question", + "archived answer", + "continuation question", + "continuation answer", + ] + assert payload["_messages_offset"] == 0 + assert payload["_messages_truncated"] is False + + +def test_msg_limit_tail_truncates_large_hidden_tool_results(): + huge_tool_output = "x" * 20_000 + session = _FakeSession([ + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call_1", "function": {"name": "vision", "arguments": "{}"}}], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": huge_tool_output, + }, + {"role": "assistant", "content": "done"}, + ]) + + payload = _invoke( + session, + query="session_id=tail_payload_001&messages=1&resolve_model=0&msg_limit=2", + ) + + tool_msg = payload["messages"][1] + assert tool_msg["role"] == "tool" + assert tool_msg["_content_truncated"] is True + assert tool_msg["_content_original_chars"] == len(huge_tool_output) + assert len(tool_msg["content"]) < len(huge_tool_output) + assert "Tool output truncated" in tool_msg["content"] + + +def test_msg_limit_tail_does_not_signal_truncated_for_trailing_hidden_tool_rows(): + session = _FakeSession([ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "answer"}, + ] + [ + {"role": "tool", "content": f"hidden tool row {idx}"} + for idx in range(40) + ]) + + payload = _invoke( + session, + query="session_id=tail_payload_001&messages=1&resolve_model=0&msg_limit=30", + ) + + assert [m["role"] for m in payload["messages"]] == ["user", "assistant"] + assert payload["_messages_offset"] == 0 + assert payload["_messages_truncated"] is False + + +def test_msg_limit_tail_preserves_list_tool_content_type_when_truncated(): + large_list_content = [{"type": "text", "text": "x" * 20_000}] + session = _FakeSession([ + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call_1", "function": {"name": "vision", "arguments": "{}"}}], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": large_list_content, + }, + {"role": "assistant", "content": "done"}, + ]) + + payload = _invoke( + session, + query="session_id=tail_payload_001&messages=1&resolve_model=0&msg_limit=2", + ) + + tool_msg = payload["messages"][1] + assert tool_msg["role"] == "tool" + assert tool_msg["_content_truncated"] is True + assert isinstance(tool_msg["content"], list) + assert not isinstance(tool_msg["content"], str) + assert tool_msg["content"][0]["type"] == "text" + assert "Tool output truncated" in tool_msg["content"][0]["text"]