Release: msg_limit ceiling + row-loss-safe pagination (#6152 + #6154, @rh-id) - #6213
Conversation
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).
…-limit-ceiling # Conflicts: # api/routes.py
…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>
|
| 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]
%%{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]
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.
🎬 Cutter preview — PR #6213 |


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
GET /api/session?msg_limit=is clamped to[1, 500]via_parse_msg_limit(); sets_messages_truncatedwhen clamped. The bare no-msg_limitpath (branch/undo/jump-to-start) still returns the full transcript. Prevents an oversized/hostilemsg_limitfrom forcing an unbounded payload._loadOlderMessagesgrows the tail window below the ceiling and switches to boundedmsg_beforebackward paging once the server would clamp; outline jump uses the bare full-transcript path instead of the oldmsg_limit=9999hack. Drift-guard test keeps the mirrored constant in sync.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:
msg_beforecursor 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!useBeforePagingso cursor pages are always prepended.Verification
test_agent_runtime_revision_guard, CI-green on all shards — not related).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.