Skip to content

Release: msg_limit ceiling + row-loss-safe pagination (#6152 + #6154, @rh-id) - #6213

Merged
nesquena-hermes merged 9 commits into
masterfrom
release/stage-msglimit
Jul 18, 2026
Merged

nesquena-hermes merged 9 commits into
masterfrom
release/stage-msglimit

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Release: msg_limit ceiling + row-loss-safe pagination (#6152 + #6154)

Ships the coupled msg_limit ceiling pair by @rh-id (Ruby Hartono), gated and hardened.

What ships

Gate findings fixed before ship (maintainer follow-up commit, Co-authored-by @rh-id)

Adversarial Codex review reproduced two silent row-loss regressions, both fixed:

  1. A raw-row-heavy msg_before cursor page that textually repeated the current tail could be misclassified as the cumulative tail and wholesale-replace it (reproduced: 470 of 940 rows lost). Now gated on !useBeforePaging so cursor pages are always prepended.
  2. A same-session refresh above the ceiling could shrink an already-loaded >500-row transcript to the last 500 (backend clamp). Over-ceiling reloads now use the bare full-transcript path.

Verification

  • Codex adversarial re-gate (round 2): SAFE TO SHIP — 940/940 rows retained on cursor paging, 1000/1000 on refresh, no seam duplication, small sessions still use the fast tail window.
  • Full pytest suite: 13,249 passed (sole failure = the known editable-install box artifact test_agent_runtime_revision_guard, CI-green on all shards — not related).
  • Targeted + sessions/pagination/regression suites: green (126 + 29 focused).

Closes #6152
Closes #6154
Closes #6177

Credit: @rh-id (Ruby Hartono) — both original PRs. Note: @webtecnica's #6206 bundles this same work plus the #6177 metadata decoupling; reconciled to this pair (the original, CI-green, gated implementation). The #6177 metadata-exposure decoupling can follow as a separate step.

rh-id and others added 8 commits July 16, 2026 23:37
Memory: ?msg_limit= had no upper bound — a client could request
msg_limit=1000000 (or the frontend's msg_limit=9999 outline-jump path) and
force the server to assemble and serialize an unbounded message payload. The
frontend's real pagination grows windows by ~30 at a time (initial load 30,
load-older +30, msg_before paging 30), so only the outline-jump and
pathological/hostile cases ask for huge values.

Add _MAX_MSG_LIMIT (500) and clamp at the query-parse site
(max(1, min(int(_msg_limit), _MAX_MSG_LIMIT))). The ceiling is generous — far
above any legitimate visible-row window — so real pagination is unaffected. The
existing _messages_truncated signal already covers the clamped case: when the
request exceeds the ceiling, _message_window_for_display returns
_messages_offset > 0 and the client learns more rows exist (load-older still
works). The bare no-msg_limit path is intentionally unchanged — it is the
documented "give me everything" escape hatch used by branch/undo/jump-to-start
flows that need the full transcript for correct global indices.

Test: tests/test_session_msg_limit_ceiling.py — the ceiling constant is
reasonable (100..2000), an over-ceiling request is clamped and signals
truncation, and legit sizes (5/30/60/100) are unaltered. Existing window/tail
tests (19) unchanged.

Contract Routing
- Task type: memory-bound fix (unbounded msg_limit request payload).
- Touched areas: GET /api/session query parsing (api/routes.py).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: none (read-only endpoint response shaping).
- Contract change (additive): ?msg_limit=N is now clamped to _MAX_MSG_LIMIT=500.
  Compatibility: the only frontend caller above the ceiling is outline.js's
  msg_limit=9999, which now receives up to 500 rows + _messages_truncated=true
  (load-older still works). All other callers (30/60) are unaffected. The bare
  no-msg_limit path is unchanged.

Release note: GET /api/session now caps ?msg_limit at 500 rows server-side,
bounding the worst-case payload for oversized requests.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
PR #6152 clamps GET /api/session ?msg_limit= to a max of 500 server-side. Two
frontend paths relied on unbounded msg_limit and silently broke for sessions
> 500 visible rows:

1. outline.js _jumpToMessage sent msg_limit=9999 as a "give me everything" hack
   so it could scroll to any message by absolute index (msg-user-<rawIdx>).
   Clamped to 500, the target row's DOM id doesn't exist for early messages in
   long sessions, and the jump silently no-ops (line 119 `if (r)` is falsy).
   Fix: drop msg_limit=9999 — use the bare messages=1 path, which #6152 left
   unbounded precisely for callers that need the full transcript. This restores
   exact pre-#6152 behavior for outline jumps.

2. sessions.js _loadOlderMessages grows msg_limit by +_INITIAL_MSG_LIMIT each
   load (30 -> 60 -> ... -> 500 -> clamped). Past 500 the server returns the
   same clamped tail, olderMsgs shrinks to 0, and endless-scroll / "Load
   earlier" silently stalls — the head becomes unreachable. Fix: once the grown
   requestedLimit meets/exceeds the new _MSG_LIMIT_MAX mirror (500), switch the
   primary fetch to msg_before paging (a fixed _INITIAL_MSG_LIMIT backward page
   keyed off _oldestIdx) — the same bounded paging the existing race-fallback
   uses, so the head stays reachable for arbitrarily long transcripts. The
   response is handled by the existing prepend path (tailMatches is naturally
   false for a page, so the !tailMatches branch reuses the fetched data).

Added _MSG_LIMIT_MAX = 500 constant in sessions.js mirroring backend
_MAX_MSG_LIMIT, documented as kept-in-sync.

Verification: node --check passes on both files; the backend window-contract
tests (test_session_tail_payload, test_session_message_window_renderable_tail,
19 tests) still pass. No JS test harness exists in this repo (vanilla JS,
manual browser verification per TESTING.md) — manual verification needed: open a
> 500-message session, (a) click an outline entry for an early message and
confirm it scrolls+flashes, (b) scroll up past 500 loaded rows and confirm
"Load earlier" keeps producing older messages without stalling.

Contract Routing
- Task type: frontend follow-up to a backend memory fix (#6152).
- Touched areas: outline jump-to-message (static/outline.js), endless-scroll /
  load-older (static/sessions.js _loadOlderMessages).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: client-side S.messages (the active-session transcript),
  same as the existing load-older path. No backend state change.
- Scope boundaries: does not weaken the #6152 clamp. The bare messages=1 path
  (outline) was intentionally left unbounded by #6152; the msg_before paging
  path (load-older) is bounded by _INITIAL_MSG_LIMIT and never hits the ceiling.

Release note: fixes outline jump-to-message and endless scroll silently
breaking on sessions longer than 500 messages after the server-side msg_limit
ceiling (companion to #6152).

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
…derMessages fetch

The static-source test test_session_message_loads_keep_explicit_longer_timeouts
asserted the exact multi-line shape of the _loadOlderMessages fetch strings.
The #6154 refactor wrapped them in a useBeforePaging ternary (tail-growth vs
msg_before paging), reindenting them — the contract the test guards (both
fetches keep timeoutMs:120000 for large state.db loads) is preserved, but the
literal source shape changed, so the assertion failed on shard 0.

Updated the assertions to match the new source shape while keeping the test's
intent: assert each fetch URL + its {timeoutMs:120000} survive in sessions.js.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
…LIMIT

Reviewer guidance (#6154 round-2): the stopgap drift-test belongs in THIS PR
(#6152) because _MAX_MSG_LIMIT is defined here. The frontend _MSG_LIMIT_MAX
mirror lives on #6154 (not master yet), so the assertion SKIPS when the JS
constant is absent and activates automatically once both branches land on master.

The guard: extract _MAX_MSG_LIMIT from api/routes.py and _MSG_LIMIT_MAX from
static/sessions.js via regex; assert equality when both present. If they drift,
load-older silently stalls at the wrong value (the exact regression #6154 fixed).
Retired by #6177 (metadata-exposure), which removes the mirror entirely.

Verified: on this branch the cross-check SKIPS (_MSG_LIMIT_MAX absent, as
expected — #6154 not landed) and the backend-existence test PASSES. Extraction
logic confirmed correct against simulated match (500==500 passes) and drift
(999!=500 fails) cases.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
…te follow-up)

Adversarial review of #6152/#6154 reproduced two silent row-loss regressions in
static/sessions.js; both fixed here, plus a stale source-string assertion update
and the CHANGELOG entry:

1. _loadOlderMessages: gate the suffix-continuity heuristic on !useBeforePaging so
   a raw-row-heavy msg_before cursor page whose visible text repeats the current
   tail can't be misclassified as the cumulative tail and wholesale-replace it
   (previously dropped 470 of 940 rows). msg_before pages are always prepended.

2. loadSession same-session refresh: only apply the width-hint msg_limit when it
   is <= _MSG_LIMIT_MAX; above the ceiling the backend would clamp and silently
   shrink an already-loaded >500-row transcript to the last 500. Over-ceiling
   reloads now use the bare full-transcript path (no msg_limit / no expand_renderable).

3. tests/test_webui_external_refresh_frontend.py: update the reloadLimitParam
   source assertion to the boundedReloadLimit form.

Re-gate: Codex adversarial SAFE TO SHIP (940/940 rows retained on cursor paging,
1000/1000 on refresh, no seam duplication, small sessions still use the fast tail
window); targeted + sessions/pagination/regression suites green.

Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>
@greptile-apps

greptile-apps Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This release ships two coupled changes from @rh-id: a server-side msg_limit ceiling (_MAX_MSG_LIMIT = 500) on GET /api/session and matching frontend pagination logic that gracefully handles the ceiling — along with two silent row-loss regressions found during adversarial review that were fixed before ship.

  • Backend (api/routes.py): _parse_msg_limit() clamps ?msg_limit= to [1, 500], returning None for the bare no-msg_limit path (branch/undo/jump) so those callers still receive the full transcript. The existing _messages_truncated signal covers the clamped case.
  • Frontend (static/sessions.js): _loadOlderMessages now chooses between tail-growth (below the ceiling) and msg_before backward paging (at/above the ceiling); _ensureMessagesLoaded falls back to the bare full-transcript path when the reload hint exceeds the ceiling to prevent silent shrink on refresh; outline jump drops the msg_limit=9999 hack in favour of the bare path.
  • Row-loss fixes: useBeforePaging gating prevents a msg_before page from being misclassified as a cumulative tail (wholesale-replace), and the over-ceiling reload fallback prevents an already-loaded >500-row transcript from shrinking on refresh.

Confidence Score: 5/5

Safe to merge — all changed paths are well-guarded, the two pre-ship row-loss regressions are correctly addressed, and the test suite is comprehensive.

The backend change is a narrow, well-tested helper extraction with no schema or DB impact. The frontend pagination strategy switch is gated correctly: useBeforePaging is forced false before the suffix-continuity heuristic so cursor pages are never misclassified as cumulative tails, and the over-ceiling reload path correctly falls back to the bare full-transcript request. All boundary conditions are handled. The drift-guard test and comprehensive _parse_msg_limit suite reduce regression risk from future constant changes.

No files require special attention — the hand-mirrored _MSG_LIMIT_MAX constant is guarded by the drift test and will be retired once #6177 lands.

Important Files Changed

Filename Overview
api/routes.py Adds _parse_msg_limit() helper and _MAX_MSG_LIMIT = 500 constant; replaces inline parse/clamp logic with the tested helper. Clean and minimal change to the handler.
static/sessions.js Adds _MSG_LIMIT_MAX mirror constant, boundedReloadLimit ceiling guard in _ensureMessagesLoaded, and two-strategy logic in _loadOlderMessages. Both silent row-loss regressions are correctly fixed.
static/outline.js Removes msg_limit=9999 hack from outline jump, replacing it with the bare no-msg_limit path so the full transcript is returned regardless of the new backend ceiling.
tests/test_session_msg_limit_ceiling.py New unit test suite for _parse_msg_limit: covers absent/empty/malformed input, legitimate sizes, over-ceiling clamp, exact-ceiling pass, and zero/negative clamp-to-one.
tests/test_msg_limit_ceiling_drift.py New drift-guard test: extracts both constants from source via regex, skips gracefully when JS mirror is absent, asserts equality once both are present.
tests/test_issue3162_ensure_messages_loaded.py Replaces fragile fixed-character-window extraction with brace-balanced parsing. Robust improvement that won't need future bumps as the function grows.
tests/test_api_timeout.py Updates timeout assertions to match the new ternary-split api() calls; both the msg_before and msg_limit paths are verified to carry timeoutMs:120000.
tests/test_cross_session_message_load_isolation.py Adds globalThis._MSG_LIMIT_MAX = 500 to the JS test harness so _ensureMessagesLoaded's new boundedReloadLimit check resolves correctly.
tests/test_webui_external_refresh_frontend.py Updates source-text assertions to match the new boundedReloadLimit variable and its ceiling check in _ensureMessagesLoaded.
CHANGELOG.md Release-authored CHANGELOG entry for the msg_limit ceiling + pagination fix. Clear and detailed.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User scrolls up — _loadOlderMessages] --> B{requestedLimit >= _MSG_LIMIT_MAX?}
    B -- No: below ceiling --> C[Tail-growth request\nmsg_limit=requestedLimit]
    B -- Yes: at/above ceiling --> D[msg_before paging\nmsg_before=_oldestIdx&msg_limit=30]
    C --> E{tailMatches?\nsuffix continuity check}
    E -- Yes: tail intact --> F[Wholesale-replace S.messages\nwith larger tail window]
    E -- No: race/diverge --> G[Fallback: fetch msg_before page\nthen prepend]
    D --> H[tailMatches forced false\n!useBeforePaging short-circuit]
    H --> I[Reuse msg_before page directly\nprepend to S.messages]
    F --> J[Update _oldestIdx + _messagesTruncated\nrenderMessages]
    G --> J
    I --> J
    K[_ensureMessagesLoaded on refresh] --> L{reloadLimit > _MSG_LIMIT_MAX?}
    L -- No --> M[Bounded reload: msg_limit=reloadLimit]
    L -- Yes --> N[Bare full-transcript path\nno msg_limit — no silent shrink]
    O[Outline jump _jumpToMessage] --> P[Bare full-transcript path\nno msg_limit — gets every row]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[User scrolls up — _loadOlderMessages] --> B{requestedLimit >= _MSG_LIMIT_MAX?}
    B -- No: below ceiling --> C[Tail-growth request\nmsg_limit=requestedLimit]
    B -- Yes: at/above ceiling --> D[msg_before paging\nmsg_before=_oldestIdx&msg_limit=30]
    C --> E{tailMatches?\nsuffix continuity check}
    E -- Yes: tail intact --> F[Wholesale-replace S.messages\nwith larger tail window]
    E -- No: race/diverge --> G[Fallback: fetch msg_before page\nthen prepend]
    D --> H[tailMatches forced false\n!useBeforePaging short-circuit]
    H --> I[Reuse msg_before page directly\nprepend to S.messages]
    F --> J[Update _oldestIdx + _messagesTruncated\nrenderMessages]
    G --> J
    I --> J
    K[_ensureMessagesLoaded on refresh] --> L{reloadLimit > _MSG_LIMIT_MAX?}
    L -- No --> M[Bounded reload: msg_limit=reloadLimit]
    L -- Yes --> N[Bare full-transcript path\nno msg_limit — no silent shrink]
    O[Outline jump _jumpToMessage] --> P[Bare full-transcript path\nno msg_limit — gets every row]
Loading

Reviews (2): Last reviewed commit: "test: fix two node-harness tests broken ..." | Re-trigger Greptile

- test_issue3162_ensure_messages_loaded: replace the brittle fixed 4500-char
  window (which the boundedReloadLimit lines pushed the carry-forward past) with
  robust brace-balance function-body extraction.
- test_cross_session_message_load_isolation: define _MSG_LIMIT_MAX=500 in the node
  harness env; _ensureMessagesLoaded now references it, and an undefined value made
  boundedReloadLimit=null -> the fetch URL dropped msg_limit/expand_renderable and
  mismatched the ordered api() harness -> stall/timeout.
@nesquena-hermes
nesquena-hermes merged commit b470793 into master Jul 18, 2026
18 checks passed
@nesquena-hermes
nesquena-hermes deleted the release/stage-msglimit branch July 18, 2026 03:15
@cutter-sh

cutter-sh Bot commented Jul 18, 2026

Copy link
Copy Markdown

🎬 Cutter preview — PR #6213

Scroll to load older messages
Scroll to load older messages — Load earlier messages keeps paging older history on long transcripts without dropping already-loaded rows.
Jump to message via outline
Jump to message via outline — Outline jump scrolls the full transcript to Question 700 even in a 1400-message chat.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tech(contract): expose msg_limit ceiling in /api/session metadata so the frontend doesn't hand-mirror _MAX_MSG_LIMIT

2 participants