Reviewer regression (reproduced): _STATE_DB_DISPLAY_ROW_BACKSTOP (newest-50k SQL
cap) was applied UNCONDITIONALLY to the GET /api/session display path's state.db
read. But merge_session_messages_append_only needs the rows at/around the
session's truncation_boundary (the preserved prefix) to reconcile correctly. A
newest-N-only cap drops those boundary rows for a >N-row session and corrupts
the merge (silently losing the preserved prefix), and also prevents older-page
(msg_before) requests from ever reaching them. So a pathological-but-real >50k-
row compressed session got a corrupted merge / unreachable older history rather
than a clean bound.
Fix: gate the backstop on the SAME conditions
_state_db_since_timestamp_for_limited_display uses to decide a read is
boundary-free — not msg_before paging, and no truncation_watermark /
truncation_boundary. Those cases stay on the full (uncapped) read; the backstop
is now a defensive net for the common uncompressed initial-tail path only.
Extracted into _state_db_backstop_limit_for_display for direct test coverage.
The get_state_db_session_messages(limit=) signature + newest-rows subquery are
unchanged — only the display-path APPLICATION of the cap is gated.
Test: tests/test_state_db_backstop_boundary_gate.py — backstop applies with no
boundary prefix; SKIPPED when truncation_boundary / truncation_watermark set or
msg_before paging; empty-string markers treated as unset (matching the existing
helper). Existing backstop + reconciliation suites (53 tests) unchanged.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
Memory: get_state_db_session_messages had no SQL LIMIT — it full-scanned the
state.db messages table and fetchall()'d into a Python list. The display path
(GET /api/session) already has a careful since_timestamp optimization that
bounds the common uncompressed tail load, but it deliberately bails (returns
None) on compressed sessions (truncation_watermark/boundary) and msg_before
paging — in those cases the read used to full-scan with no cap. A
pathological/huge state.db could then materialize unbounded rows into memory.
Add an opt-in `limit` parameter to get_state_db_session_messages (applied as a
SQL LIMIT that keeps the NEWEST rows via a DESC subquery re-sorted ASC). It is
a BACKSTOP, not a semantic window: the display window counts visible rows
post-reconciliation, so a true window LIMIT here would corrupt the
sidecar/state.db merge (see _state_db_since_timestamp_for_limited_display,
which deliberately does NOT SQL-LIMIT raw rows for exactly that reason).
Thread a generous _STATE_DB_DISPLAY_ROW_BACKSTOP (50000) from the GET /api/session
display caller only. The full-history model-context callers (reconciliation at
models.py:3437, context reconstruction at :8576, new-turn context at
streaming.py:8443) do NOT pass a limit — their semantics require the full
history, unchanged.
Test: tests/test_state_db_read_backstop.py — no-limit returns full history
oldest-first (model-context contract unchanged); limit=N keeps the newest N
re-sorted oldest-first; limit > row count returns all; limit<=0 clamps to 1
(newest); invalid limit falls back to unbounded; limit composes with
since_timestamp. Existing state.db active-filter + reconciliation suites (54
tests) unchanged.
Contract Routing
- Task type: memory-bound fix (unbounded state.db row materialization on the
display path's bail cases).
- Touched areas: state.db message reader (api/models.py) + GET /api/session
display caller (api/routes.py). NOT the model-context callers.
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md,
docs/rfcs/webui-run-state-consistency-contract.md (Model context layer).
- State layer mutated: none (read-only). The model-context read path is
UNCHANGED (no limit passed) — Invariant 1 (visible current turns enter model
context) and the reconciliation contract are preserved. Only the display path
gets the defensive cap, which does not affect what the agent receives.
- Scope boundaries: this is narrower than a naive "add LIMIT everywhere" —
_state_db_since_timestamp_for_limited_display's docstring explains why a
semantic window LIMIT would corrupt the merge; this cap is purely a
pathological-input safety net above any legitimate session size.
Release note: GET /api/session's state.db read now has a defensive row backstop,
capping memory when the bounded since_timestamp optimization bails on
compressed/paged sessions.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
Centralize custom-provider model-ID extraction into _configured_model_ids()
+ _configured_model_options(), used by both catalog construction and runtime
routing so the picker and resolver can't drift. Fixes list-shaped
custom_providers[].models allowlists silently staying on the default provider
instead of routing to their own (#6121). Existing mapping / list-of-strings
configs unaffected.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Reconcile the gateway pending-mirror in _session_attention_summary()
before counting sidebar approval attention, so a parent session's red
approval dot clears once the underlying gateway approval has drained.
Live approvals stay visible; clarify fallback unchanged. WebUI read-path
mitigation; child-session context-propagation root cause stays in #6100.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex found the body-reparented picker (z-200, later body child) could be revealed
UNDER a full-screen drawer/panel via multiple paths (swipe, breakpoint resize,
profile-switch). Rather than guard each open-site (whack-a-mole + my resize guard
over-fired on keyboard reflows), fix the class once: .model-dropdown--floating gets
z-index:190 — above the composer-mobile-config-panel (z-180) it opens from, below
the mobile sidebar + workspace panel (both z-200) — so any drawer/panel that opens
covers the picker, matching master's stacking intent. Reverted the two boot.js
guards. Co-authored-by webtecnica.
Codex found a sibling of the swipe-stacking edge: a direct landscape/tablet->phone
resize can re-reveal an already-open mobile sidebar or workspace panel under the
body-reparented picker (z-200 siblings, no click to trigger outside-close). The
window resize handler now guardedly closes+restores the picker before re-syncing
drawer state, gated on entering phone width AND a drawer/panel actually being open,
so ordinary keyboard/URL-bar reflows don't dismiss the picker mid-use.
Codex re-gate found the body-reparented picker (z-index:200, body child) would
paint on top of the mobile sidebar drawer when a left-edge swipe opened the
sidebar while the picker was open. _openMobileSidebarFromGesture() now calls
guarded closeModelDropdown() first (which also restores the dropdown to the
footer), so the swipe reveals the session drawer cleanly. Co-authored-by webtecnica.
The composer model dropdown (#composerModelDropdown) is a child of
.composer-footer, which sets container-type:inline-size (and, under the Geist
Contrast skin, a backdrop-filter). Both establish a fixed containing block, so
the phone position:fixed dropdown resolved against the FOOTER (pinned to the
bottom of the screen) instead of the visual viewport — dropping the menu below
the fold on all skins (the "tapping the model picker does nothing / opens
off-screen" symptom in #6080).
Fix (Fable UX gate recommendation on PR #6105): on the phone path
(<=640px) reparent the dropdown to <body> and switch it to position:fixed via a
.model-dropdown--floating class, exactly the working #profileDropdown idiom, so
fixed positioning is viewport-relative on every skin. The visualViewport math
from PR #6105 is preserved (viewport-relative coordinates, above/below flip
preferring the larger space, width = viewport - 16px, titlebar-bottom clamp) and
so are the rAF-coalesced visualViewport resize/scroll listeners.
Desktop (>640px) is unchanged: closeModelDropdown() and the desktop branch of
_positionModelDropdown() restore the element into its original .composer-footer
slot and clear the phone inline styles, so the desktop popover stays an
absolutely positioned footer child, byte-for-byte identical to master. Because
the menu now leaves the footer on phones, no per-skin backdrop-filter override
is needed.
Adds tests/test_issue6080_model_picker_reparent.py, including a node-executed
DOM-parentage guard that runs _positionModelDropdown()/closeModelDropdown()
against a stub DOM and asserts the open phone dropdown has NO .composer-footer
ancestor (escaping the container-type trap) and is restored into the footer on
close and on desktop.
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Scoped to the two stated fixes only: strips the #composerModelDropdown position:fixed
mobile override that #6104's branch had bundled in (that's the #6080 model-picker
mechanism, being reworked separately to reparent to body level — the fixed override
is trapped by .composer-footer's container-type so it doesn't belong here), and drops
the <=360px tool-name-hiding rules (header overflow-clip prevents the spill without
losing tool identity). Thanks @webtecnica.
Three fixes in one commit:
1. **CI test mock** — Added addEventListener/removeEventListener to the
Node.js makeElement mock so viewport.addEventListener() calls in the
touch handler registration don't crash with 'not a function'.
2. **Two-finger pan during pinch** — Replaced _setScale() call with
direct transform math. The new formula computes scale and position
simultaneously:
ratio = boundedScale / pinchStartScale
x = currMidX - (startMidX - startPosX) * ratio
This keeps the diagram under both fingers regardless of midpoint
drift, enabling simultaneous zoom + pan.
3. **Post-pinch click suppression** — _onTouchEnd now sets dragged=true
when exiting pinch mode so any synthesized click event is caught
by the existing dragged guard in _openViewerOnClick instead of
opening the lightbox.
Adds touch event handlers (touchstart/touchmove/touchend/touchcancel) to
the Mermaid viewer viewport for two-finger pinch zoom, using the existing
_setScale() function with midpoint anchoring.
- Guards pointer handlers (down/move/click) during active pinch to prevent
interference between touch and pointer event systems
- Uses {passive: false} on touchstart/touchmove to allow preventDefault()
- Falls back gracefully on non-touch devices — touch listeners are inert
without touch hardware
Fixes: Mermaid diagrams in Hermes WebUI were not zoomable via multi-touch
pinch on mobile/tablet devices, frustrating users with complex diagrams.
Codex re-gate found the same derived-title leak on the sibling /api/sessions/search
endpoint (3 branches redacted only 'title'). Extract _redact_sidebar_title_fields()
shared helper, apply it in both _sidebar_session_response_item and all 3 search
branches, read api_redact_enabled once per search response (mirrors #4662). +helper
test + source-guard that every search branch calls the redactor.
Codex gate found the #6056 derived display_title (from raw user-message content)
bypassed sidebar redaction — the serializer redacted only 'title', so a credential
in a delegated goal leaked to the sidebar even with api_redact_enabled=True.
Redact display_title + _state_db_title + parent_title on the same path; +regression
test (proven to fail on the unfixed serializer).
Codex re-gate asked to distinguish a confirmed-absent sidecar from an
indeterminate/unreadable one before entering the abandoned-shell grace path.
Add _session_sidecar_exists() (True/False/None) and only grace-evict when the
sidecar is confirmed absent (False); a present-or-indeterminate sidecar keeps
the session resident. Age-window bound retained (fully closes the leak, no
data-loss edge); an SSE-mounted-lease refinement is noted as optional follow-up.
Codex gate found the eviction-reorder made never-saved empty shells immortal
(abandoned /api/session/new tabs accumulate unbounded past sessions_cache_max).
Add a grace-window bound: protect a never-persisted empty shell only while fresh
(<30min old) OR while it carries a composer draft; a stale, empty, draftless,
never-saved shell is treated as abandoned and becomes evictable again. A real
compose persists a draft on the first keystroke (disk-backed + reloadable), so
the brick fix is preserved while the leak is closed. +2 regression tests.