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

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:
nesquena-hermes
2026-06-07 15:22:44 -07:00
committed by GitHub
parent 59de540b3d
commit 9d94298278
8 changed files with 191 additions and 16 deletions

View File

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

View File

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

View File

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

View File

@@ -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}"
)

View File

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

View File

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

View File

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

View File

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