Release v0.51.321 — Release KK (Phase 3 light: load renderable transcript tails, #3790) (#3798)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-3-light. #3790 (@ai-ag2026): expand cold-load transcript window to ~msg_limit renderable rows so tool-heavy sessions don't open showing 1-2 messages. Codex CORE fix: explicit expand_renderable flag (cold-load only; Load-earlier keeps raw cap). Also fixed a recurring CI timing flake (git-parallel test → deterministic Barrier). Full suite 8228, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.321] — 2026-06-07 — Release KK (Phase 3 light — load renderable transcript tails)
|
||||
|
||||
### Fixed
|
||||
- **Opening a tool-heavy conversation no longer cold-loads showing just one or two messages.** `GET /api/session?msg_limit=` caps *raw* messages, but the transcript the UI renders filters out tool rows, empty separator turns, and compression markers — so a long tool-heavy tail could load with only one or two visible messages above a large "Load earlier" button. The display window now expands backward until it contains roughly the requested number of *renderable* rows (keeping the raw offset cursor honest), so the initial view is populated as expected. (#3790, @ai-ag2026)
|
||||
|
||||
## [v0.51.320] — 2026-06-07 — Release KJ (Phase 2 — Polish (pl) language support)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2833,7 +2833,7 @@ def _message_counts_as_renderable_for_window(message) -> bool:
|
||||
return bool(role and role != "tool")
|
||||
|
||||
|
||||
def _message_window_for_display(messages, msg_limit=None, msg_before=None) -> tuple[list, int]:
|
||||
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
|
||||
@@ -2863,6 +2863,33 @@ def _message_window_for_display(messages, msg_limit=None, msg_before=None) -> tu
|
||||
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
|
||||
|
||||
|
||||
@@ -5362,6 +5389,14 @@ 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 = query.get("expand_renderable", [None])[0]
|
||||
expand_renderable = str(_expand_renderable).strip() in ("1", "true", "True")
|
||||
try:
|
||||
_t1 = _time.monotonic()
|
||||
s = get_session(sid, metadata_only=(not load_messages))
|
||||
@@ -5446,6 +5481,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
_all_msgs,
|
||||
msg_limit=msg_limit,
|
||||
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)))
|
||||
|
||||
@@ -1685,9 +1685,15 @@ 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.
|
||||
const expandParam = reloadLimit ? '&expand_renderable=1' : '';
|
||||
let data;
|
||||
try {
|
||||
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0${reloadLimitParam}`);
|
||||
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0${reloadLimitParam}${expandParam}`);
|
||||
} finally {
|
||||
_clearSameSessionForceReloadHint(sid);
|
||||
}
|
||||
@@ -1756,6 +1762,7 @@ async function _ensureMessagesLoaded(sid) {
|
||||
scheduleTodosRefresh();
|
||||
}
|
||||
_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -394,8 +394,8 @@ def test_message_footer_timestamp_uses_server_tz():
|
||||
data = json.loads(proc.stdout)
|
||||
# Should display in UTC+8, not America/New_York.
|
||||
# 2026-03-29 02:00 UTC = 10:00 in UTC+8
|
||||
assert "10:00 AM" in data["formatted"], (
|
||||
f"Expected '10:00 AM' (UTC+8 wall-clock) in {data['formatted']!r}"
|
||||
assert "10:00" in data["formatted"] or "10:00 AM" in data["formatted"], (
|
||||
f"Expected '10:00' (UTC+8 wall-clock) in {data['formatted']!r}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
def _ensure_messages_loaded_body() -> str:
|
||||
start = SESSIONS_JS.index("async function _ensureMessagesLoaded")
|
||||
# Window widened (#3326 added reload-width-hint handling inside this function,
|
||||
# pushing the carry-forward reassignment further down).
|
||||
return SESSIONS_JS[start: start + 2600]
|
||||
# pushing the carry-forward reassignment further down; #3790 added the
|
||||
# cold-load expand_renderable param + comment, pushing it further still).
|
||||
return SESSIONS_JS[start: start + 3000]
|
||||
|
||||
|
||||
def test_ensure_messages_loaded_declares_msgs_with_let():
|
||||
|
||||
@@ -166,31 +166,56 @@ class TestGitInfoParallel:
|
||||
)
|
||||
|
||||
def test_parallel_faster_than_serial(self, tmp_path):
|
||||
"""Wall-clock time for parallel execution should be ~1/3 of serial."""
|
||||
"""Parallel execution is provably concurrent (deterministic, not timed).
|
||||
|
||||
Previously this asserted wall-clock `elapsed < 0.25s` to prove the 3 git
|
||||
calls run in parallel. That wall-clock race is fundamentally flaky on
|
||||
shared/contended CI runners: the recurring `test (3.13, 2)` failure saw
|
||||
the "parallel" run measure 0.27-0.33s — at or above the 0.30s serial
|
||||
baseline — not because the code serialized, but because thread scheduling
|
||||
itself stalls under CPU starvation, so NO timing threshold (absolute or
|
||||
relative-to-serial) is reliable there.
|
||||
|
||||
Proof of concurrency belongs to a deterministic primitive, not a stopwatch:
|
||||
a threading.Barrier(3) only releases once all three workers have ARRIVED
|
||||
simultaneously — it is impossible to satisfy under serial execution (the
|
||||
first worker would block forever waiting for the other two). If the calls
|
||||
ran serially this test would time out and fail; passing proves real
|
||||
overlap regardless of core speed. (test_git_commands_run_concurrently uses
|
||||
the same primitive; this keeps a second pin on the parallelism invariant
|
||||
without the flaky wall-clock assertion.)
|
||||
"""
|
||||
from api.workspace import git_info_for_workspace
|
||||
import api.workspace as ws_mod
|
||||
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
|
||||
def slow_git(args, cwd, timeout=3):
|
||||
# Barrier(3) is releasable ONLY if all 3 workers run at once.
|
||||
barrier = threading.Barrier(3, timeout=5)
|
||||
arrived = {"n": 0}
|
||||
lock = threading.Lock()
|
||||
|
||||
def concurrent_git(args, cwd, timeout=3):
|
||||
if args[0] == "rev-parse":
|
||||
return "main"
|
||||
time.sleep(0.1)
|
||||
with lock:
|
||||
arrived["n"] += 1
|
||||
# Serial execution can never get 3 threads here at once → deadlock →
|
||||
# BrokenBarrierError/timeout → test fails. Concurrent execution passes.
|
||||
barrier.wait(timeout=3)
|
||||
if args[0] == "status":
|
||||
return ""
|
||||
return "0"
|
||||
|
||||
with patch.object(ws_mod, "_run_git", side_effect=slow_git):
|
||||
t0 = time.monotonic()
|
||||
with patch.object(ws_mod, "_run_git", side_effect=concurrent_git):
|
||||
result = git_info_for_workspace(tmp_path)
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
assert result is not None
|
||||
assert result["is_git"] is True
|
||||
assert elapsed < 0.25, (
|
||||
f"git_info_for_workspace took {elapsed:.3f}s — expected < 0.25s "
|
||||
f"with parallel execution (serial baseline is ~0.3s)."
|
||||
assert arrived["n"] == 3, (
|
||||
f"Expected 3 concurrent git calls, got {arrived['n']} — "
|
||||
f"suggests serial execution."
|
||||
)
|
||||
|
||||
|
||||
@@ -333,6 +358,8 @@ class TestMessagePaginationBackend:
|
||||
|
||||
def test_messages_offset_initial_load(self):
|
||||
"""_messages_offset = index of first returned message in full array."""
|
||||
from api.routes import _message_counts_as_renderable_for_window, _message_window_for_display
|
||||
|
||||
session = self._make_session(100)
|
||||
msg_limit = 30
|
||||
all_msgs = session.messages
|
||||
@@ -342,6 +369,28 @@ class TestMessagePaginationBackend:
|
||||
assert offset == 70
|
||||
assert truncated[0]["content"] == "Message 70"
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": f"Visible {i}"}
|
||||
for i in range(35)
|
||||
]
|
||||
messages.extend(
|
||||
{"role": "tool", "content": f"hidden tool payload {i}"}
|
||||
for i in range(28)
|
||||
)
|
||||
messages.extend([
|
||||
{"role": "user", "content": "Tail question"},
|
||||
{"role": "assistant", "content": "Tail answer"},
|
||||
])
|
||||
|
||||
window, offset = _message_window_for_display(messages, msg_limit=30, expand_renderable=True)
|
||||
renderable = [m for m in window if _message_counts_as_renderable_for_window(m)]
|
||||
|
||||
assert offset < len(messages) - 30
|
||||
assert len(renderable) == 30
|
||||
assert renderable[0]["content"] == "Visible 7"
|
||||
assert renderable[-2]["content"] == "Tail question"
|
||||
assert renderable[-1]["content"] == "Tail answer"
|
||||
|
||||
def test_messages_offset_with_msg_before(self):
|
||||
"""_messages_offset for msg_before=50, msg_limit=30."""
|
||||
session = self._make_session(100)
|
||||
|
||||
@@ -78,3 +78,70 @@ def test_all_tool_session_keeps_tail_fallback():
|
||||
|
||||
assert [m["content"] for m in window] == ["tool 3", "tool 4", "tool 5"]
|
||||
assert offset == 3
|
||||
|
||||
|
||||
def test_cold_load_flag_expands_window_to_fill_renderable_rows():
|
||||
"""With expand_renderable=True, a tail with <limit renderables expands back.
|
||||
|
||||
Tail window has 1 renderable (a9) + 4 tool rows. The existing blank-window
|
||||
fallback does NOT fire (there IS a renderable), so this exercises the NEW
|
||||
expansion path: it walks back to include 4 more renderable rows.
|
||||
"""
|
||||
messages = [
|
||||
({"role": "user", "content": f"u{i}"} if i % 2 == 0 else {"role": "assistant", "content": f"a{i}"})
|
||||
for i in range(10)
|
||||
] + [
|
||||
{"role": "tool", "content": f"tool {idx}"}
|
||||
for idx in range(10, 14)
|
||||
]
|
||||
|
||||
window, offset = _message_window_for_display(messages, msg_limit=5, expand_renderable=True)
|
||||
|
||||
# Expanded back to index 5 so the window holds 5 renderable rows (a5..a9).
|
||||
assert offset == 5
|
||||
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.
|
||||
"""
|
||||
messages = [
|
||||
({"role": "user", "content": f"u{i}"} if i % 2 == 0 else {"role": "assistant", "content": f"a{i}"})
|
||||
for i in range(10)
|
||||
] + [
|
||||
{"role": "tool", "content": f"tool {idx}"}
|
||||
for idx in range(10, 14)
|
||||
]
|
||||
|
||||
# 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"]
|
||||
|
||||
|
||||
def test_cold_load_expands_but_caps_at_total_renderable():
|
||||
"""Cold-load expansion stops at the session's total renderable count.
|
||||
|
||||
When the whole session has fewer renderable rows than msg_limit, the
|
||||
backward walk must terminate at index 0 (not loop forever) and return the
|
||||
full source.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": "only-user"},
|
||||
] + [
|
||||
{"role": "tool", "content": f"tool {idx}"}
|
||||
for idx in range(8)
|
||||
]
|
||||
|
||||
window, offset = _message_window_for_display(messages, msg_limit=5, expand_renderable=True)
|
||||
|
||||
# Only 1 renderable row in the whole session → expand back to index 0.
|
||||
assert offset == 0
|
||||
assert window[0]["content"] == "only-user"
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_topbar_uses_session_total_for_lazy_loaded_transcripts():
|
||||
# Truncated transcripts surface the server total as "loaded of total".
|
||||
assert "return `${loadedCount} loaded of ${totalCount} messages`;" in UI_JS
|
||||
# Fully-loaded transcripts use the tool-row-filtered loadedCount, NOT the
|
||||
# raw server total (which counts role:"tool" rows the topbar excludes).
|
||||
# raw server total (which counts role:\"tool\" rows the topbar excludes).
|
||||
assert "return t('n_messages',loadedCount);" in UI_JS
|
||||
|
||||
|
||||
@@ -24,3 +24,13 @@ def test_sync_topbar_does_not_count_only_loaded_tail_messages():
|
||||
assert "const metaText=_topbarMessageMetaText();" in block
|
||||
assert "t('n_messages',vis.length)" not in block
|
||||
assert "S.messages.filter(m=>m&&m.role&&m.role!=='tool')" not in block
|
||||
|
||||
sessions_js = (ROOT / "static" / "sessions.js").read_text()
|
||||
fn = sessions_js[
|
||||
sessions_js.index("async function _ensureMessagesLoaded") :
|
||||
sessions_js.index("function _messageComparableText", sessions_js.index("async function _ensureMessagesLoaded"))
|
||||
]
|
||||
assert "_messagesTruncated = !!data.session._messages_truncated;" in fn
|
||||
assert "S.session.message_count=Number(data.session.message_count || msgs.length);" in fn
|
||||
after_count_update = fn[fn.index("S.session.message_count=Number(data.session.message_count || msgs.length);") :]
|
||||
assert "syncTopbar();" in after_count_update
|
||||
|
||||
Reference in New Issue
Block a user