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 <t3chn0pr13st@users.noreply.github.com>

* 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 <t3chn0pr13st@users.noreply.github.com>

* 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 <technopriest@live.ru>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-14 19:18:11 -07:00 committed by GitHub
parent fe12eebbf3
commit d769acf43f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 359 additions and 79 deletions

View file

@ -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

View file

@ -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()

View file

@ -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 {

View file

@ -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

View file

@ -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"]