Files
hermes-webui/tests/test_issue5966_transparent_stream_lazy_dom.py
9643cdd5c1 Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966) (#5981)
* fix(transparent-stream): defer settled tool-detail DOM + cap per-turn rows (#5966)

Reported by @curtisszmania-cytocync.

Transparent Stream built a DOM node for every activity event of every settled
turn at load, and eagerly materialized each tool row's full detail body — a
15+ prompt reasoning-heavy history produced thousands of subtrees and tipped the
tab into a multi-GB freeze. The #5860/#5839 lazy-worklog fix did not cover
Transparent Stream (it never collapses).

Two-part surgical fix in static/ui.js, mirroring #5860's proven machinery:
- Row-detail deferral: a settled+collapsed transparent tool row renders
  header-only; its heavy .tool-card-detail body builds on first expand
  (_materializeTransparentToolDetail), recovering the tool call from the scene
  after an innerHTML cache round-trip. Same anchor-suppressed post-processing as
  the eager path, so an expanded row is byte-identical.
- Per-turn row cap: a settled turn with >CAP+SLACK (30+10) rows renders the last
  30 behind a clean 'Show earlier steps (N)' affordance that materializes the
  prefix in place with viewport compensation. Just-settled turn (keep-open token)
  and already-revealed turns are exempt; true Trace tool-count is stashed so the
  label stays honest; Expand-all reveals capped rows; cache-restore re-wires the
  affordance. Live rendering is untouched.

Server-side #5858 (scene serialize-after-messages) already covers the container
RSS half on the experimental channel; this is the browser-DOM half.

* style(#5966): compact 'Show earlier steps' pill (hug content, left-aligned)

Constrain the affordance to fit-content width + align-self:flex-start so it
reads as a light inline control on the activity rail, not a full-width bar.

* fix(#5966): address Codex gate findings + Fable UX fast-follows

Codex SHIP-ONLY-WITH-FIXES (3 SILENT defects), all fixed + browser-verified:
- F1: buildToolCard PRE-BUILDS .tool-card-detail when a tool has args/output, so
  the old `!detail` defer guard skipped exactly the heavy rows. Defer now fires on
  settled+collapsed+has-detail and STRIPS any prebuilt body (verified: 400 shell
  tool rows -> 0 detail bodies mounted; expand still materializes byte-identically).
- F2: stamp owner rawIdx on rows + affordance; dataset recovery and cache-restore
  rebind resolve the scene-owning message (multi-segment turns own it on a later
  segment, not the first) with a scene-owning-segment fallback.
- F3: persist revealed-turn state in a session/owner-keyed Set + invalidate the
  session HTML cache on reveal, so a rebuild/switch-away never re-caps a turn the
  user expanded (verified: reveal 30->400, full re-render stays 400).

Fable SHIP-UX fast-follows: t()-with-fallback label (+ en locale keys), 640px
touch breakpoint, stale comment fixed.

Tests: 21 pass (added has-detail deferral+strip, detail-less no-defer, owner-idx
recovery, persistent-reveal, i18n).

* fix(#5966): address Codex re-gate findings (r2)

Codex re-gate SHIP-ONLY-WITH-FIXES — 2 more SILENT defects, both fixed + browser-verified:
- F1(r2): _materializeTransparentToolDetail rebuilt via the thinner
  _transparentToolDetailHtml(), dropping buildToolCard's richer detail (diff
  coloring / show-more / canonical shell-command lead). Now transplants
  buildToolCard(tc)'s own .tool-card-detail node so an expanded deferred row is
  byte-identical to the eager path (verified: canonical structure + 595-char body).
- F2(r2): after an in-session renderMessages() rebuild (next send) an already-open
  settled tool row got re-deferred, and disclosure-restore only toggled .open →
  open-but-empty card. _setWorklogDetailDisclosureOpen now materializes a deferred
  transparent row BEFORE toggling open (verified: expand → rebuild → still open
  WITH content). Likely the same defect the RegressionGate caught intermittently.

Tests: 22 pass (added canonical-detail + disclosure-restore-materialize).

* fix(review): add show_earlier_steps i18n keys to all locales (#5966)

The transparent-stream row-cap "Show N earlier steps" pill added two new
i18n keys (show_earlier_steps, show_earlier_step_one) to the English block
only, so the strict locale-parity tests (zh/ko/ru/zh-Hant/...) failed with
missing-key assertions and CI shard 0 went red across 3.11/3.12/3.13.

Add both keys to all 14 non-English locale blocks with the English string as
fallback (the repo's established add-with-English-fallback convention for new
UI keys). Inserted after each block's collapse_all anchor. All 197 locale
tests + the #5966 suite green; node --check clean.

* fix(review): extract _transparentToolRowHasDetail into the #5700 harness

#5966 added a _transparentToolRowHasDetail() call into the settled transparent
render (_anchorSceneTransparentNodeForRow) to decide whether to defer a
collapsed tool row's detail body. The pre-existing #5700 timestamp test extracts
that render fn into a node harness but didn't extract the new helper, so the
extracted fn hit a ReferenceError → node exited non-zero → 3 #5700 tests failed
(RED across CI shards; the PR updated its own #5966 harness but missed this one).

Add `eval(extractFunc('_transparentToolRowHasDetail'))` to the harness. The
helper self-guards its _toolActionKind/_toolCardAllowsDetail deps (returns true
when absent), and the #5700 timestamp lives on the row HEADER (rendered even when
detail is deferred), so all 20 #5700 assertions pass unchanged — confirming this
was a harness gap, not a timestamp regression.

* fix(#5974): hide Show-earlier-steps pill when a capped transparent turn is collapsed (Fable UX gate)

Fable UX gate SHIP-WITH-UX-FIXES: the .transparent-earlier-steps pill wasn't in the
.assistant-turn[data-transparent-turn-collapsed=1] hide rule, so collapsing a capped
turn via the name-tag toggle left an orphan pill. Added it to the hide rule.
(i18n finding was already resolved — both pill keys present in all 15 locales w/
English fallback + show_earlier_step_one is live at ui.js:12918, not dead.)

* Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966)

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-07-11 21:56:20 -07:00

303 lines
14 KiB
Python

"""Regression coverage for #5966: Transparent Stream memory blowup on long histories.
A reasoning-heavy chat (15+ prompts) in Transparent Stream mode built a DOM node
for EVERY activity event of EVERY settled turn at load, and eagerly materialized
each tool row's full detail body — thousands of subtrees, tipping the tab into a
multi-GB freeze. This is the Transparent-Stream analogue of the compact-worklog
fix (#5860 / #5839), which did not cover Transparent Stream (it never collapses).
The fix has two parts, both in static/ui.js:
A. Row-detail deferral — a SETTLED, COLLAPSED transparent tool row renders
header-only; its heavy `.tool-card-detail` body is built on first expand
(_materializeTransparentToolDetail), recovering the tool call from the scene
after an innerHTML cache round-trip drops the JS stash.
B. Per-turn row cap — a settled turn with > CAP+SLACK rows renders only the last
CAP rows behind a "Show earlier steps (N)" affordance that materializes the
omitted prefix in place, with viewport compensation. The just-settled turn
and an already-revealed turn are exempt; the true Trace tool-count is stashed
so the label stays honest while capped.
Source-assertion tests (repo convention for anchor-scene UI) + a behavioral
node-vm harness that proves the mounted-node reduction is real and non-vacuous.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 0
for idx in range(brace, len(src)):
if src[idx] == "{":
depth += 1
elif src[idx] == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : idx]
raise AssertionError(f"{name} body not found")
# --------------------------------------------------------------------------- #
# Part A — row-detail deferral
# --------------------------------------------------------------------------- #
def test_settled_collapsed_tool_row_defers_detail_body():
body = _function_body(UI_JS, "_decorateTransparentEventRow")
# Defer on settled + collapsed + has-detail (live / already-open build eagerly).
assert "opts.settled===true&&!card.classList.contains('open')&&_transparentToolRowHasDetail(tc)" in body
assert "row.setAttribute('data-transparent-detail-deferred','1')" in body
assert "row._deferredToolCall=tc" in body
# Codex F1: buildToolCard PRE-BUILDS the detail body when the tool has
# args/output, so the defer branch must STRIP that prebuilt body — a bare
# `!detail` guard skipped exactly the heavy rows. Assert the strip + the
# no-detail class removal.
assert "detail.remove(); detail=null;" in body
assert "card.classList.remove('tool-card-no-detail')" in body
# The eager build path remains for the non-deferred branch.
assert "_transparentToolDetailHtml(tc,status)" in body
def test_defer_gate_only_fires_when_detail_worth_building():
# Codex F1 corollary: a detail-less tool row must NOT be marked deferred/
# expandable (no empty chevron). The has-detail guard mirrors buildToolCard.
body = _function_body(UI_JS, "_transparentToolRowHasDetail")
assert "tc.snippet" in body
assert "Object.keys(tc.args)" in body
assert "_toolCardAllowsDetail" in body
def test_expand_materializes_deferred_detail_before_open():
body = _function_body(UI_JS, "_setTransparentCardOpen")
# Materialize must run BEFORE flipping .open so detail exists for the paint.
assert "data-transparent-detail-deferred" in body
assert "_materializeTransparentToolDetail(row)" in body
mat_at = body.index("_materializeTransparentToolDetail(row)")
open_at = body.index("card.classList.toggle('open'")
assert mat_at < open_at, "detail must materialize before .open flips"
def test_materialize_helper_recovers_and_postprocesses():
body = _function_body(UI_JS, "_materializeTransparentToolDetail")
# Idempotent (gated on marker, cleared before build).
assert "data-transparent-detail-deferred" in body
# In-memory stash first, dataset recovery fallback after cache round-trip.
assert "row._deferredToolCall" in body
assert "_transparentToolCallFromRowDataset(row)" in body
# Codex F1(r2): rebuild via the CANONICAL buildToolCard() detail (richer: diff
# coloring / show-more / shell detail), not the thinner _transparentToolDetailHtml.
assert "buildToolCard(tc)" in body
assert "rebuilt&&rebuilt.querySelector('.tool-card-detail')" in body
# Same post-processing as the eager path (highlight/copy/katex/mermaid).
assert "_postProcessWithAnchorSuppression(card)" in body
assert "requestAnimationFrame" in body
def test_disclosure_restore_materializes_deferred_transparent_detail():
# Codex F2(r2): restoring an OPEN state on a deferred transparent row must
# materialize the body, or the card restores open-but-empty after an
# in-session rebuild (next send re-defers, then restore toggles .open only).
body = _function_body(UI_JS, "_setWorklogDetailDisclosureOpen")
assert 'transparent-event-row[data-transparent-detail-deferred="1"]' in body
assert "_materializeTransparentToolDetail(drow)" in body
# Materialize must run BEFORE the .open toggle.
mat_at = body.index("_materializeTransparentToolDetail(drow)")
open_at = body.index("el.classList.toggle('open'")
assert mat_at < open_at
def test_dataset_recovery_maps_row_to_scene():
body = _function_body(UI_JS, "_transparentToolCallFromRowDataset")
assert "data-anchor-row-id" in body
assert "S.messages" in body
assert "_anchor_activity_scene" in body
assert "_anchorSceneToolCallFromRow(match,{settled:true})" in body
# --------------------------------------------------------------------------- #
# Part B — per-turn cap + affordance
# --------------------------------------------------------------------------- #
def test_settled_transparent_render_caps_rows():
body = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage")
# Cap only when over cap+slack, and only when NOT just-settled / not revealed.
assert "rows.length>cap+slack" in body
assert "!justSettled&&!alreadyRevealed" in body
assert "startIdx=rows.length-cap" in body
# Just-settled exemption keyed off the keep-open token (no STREAM_DONE shrink).
assert "_shouldKeepSettledWorklogOpenForStreamSettle(streamId)" in body
# True tool count stashed so the Trace label stays honest while capped.
assert "data-transparent-total-tool-count" in body
def test_cap_constants_are_sane():
assert "_TRANSPARENT_SETTLED_ROW_CAP=30" in UI_JS
assert "_TRANSPARENT_SETTLED_ROW_CAP_SLACK=10" in UI_JS
def test_earlier_steps_affordance_is_accessible_and_counted():
body = _function_body(UI_JS, "_buildTransparentEarlierStepsAffordance")
assert "role','button'" in body
assert "tabindex','0'" in body
assert "data-earlier-count" in body
assert "aria-label" in body
# Singular/plural label.
assert "Show 1 earlier step" in body
assert "earlier steps" in body
def test_reveal_holds_viewport_and_clears_cap_count():
body = _function_body(UI_JS, "_revealTransparentEarlierSteps")
# Full run mounted -> drop the capped-count stash so the label recomputes.
assert "removeAttribute('data-transparent-total-tool-count')" in body
# Viewport compensation: add the inserted height delta to scrollTop.
assert "msgsEl.scrollHeight-prevScrollHeight" in body
assert "msgsEl.scrollTop=prevScrollTop+delta" in body
# Marks the turn revealed so rebuilds don't re-cap it.
assert "data-transparent-earlier-revealed" in body
def test_trace_count_uses_stashed_total_when_capped():
body = _function_body(UI_JS, "_syncTransparentEventControls")
assert "data-transparent-total-tool-count" in body
assert "stashedTotal>mountedToolCount" in body
def test_expand_all_reveals_capped_rows_first():
body = _function_body(UI_JS, "_setTransparentRowsExpanded")
assert "transparent-earlier-steps" in body
assert "el.click()" in body
def test_cache_restore_rewires_affordance():
body = _function_body(UI_JS, "_rehydrateTransparentStreamDom")
assert 'transparent-earlier-steps[data-anchor-earlier-steps="1"]' in body
assert "_revealTransparentEarlierSteps(msg,seg,idx,el)" in body
assert "data-earlier-rewired" in body
def test_affordance_has_polished_styling():
# Clean pill in the app's own visual language, touch target, reduced-motion.
assert ".transparent-earlier-steps{" in STYLE_CSS
assert "border-radius:999px" in STYLE_CSS
assert "min-height:44px" in STYLE_CSS # mobile touch target
assert "prefers-reduced-motion:reduce" in STYLE_CSS
# --------------------------------------------------------------------------- #
# Behavioral (node vm) — the mounted-node reduction is real + non-vacuous
# --------------------------------------------------------------------------- #
def _run_cap_harness(row_count):
"""Exercise the REAL cap arithmetic from the source against a scene of
row_count rows; returns how many rows the initial settled render would mount
and the hidden-prefix count. Proves the cap actually bounds the DOM."""
node = shutil.which("node")
if not node: # pragma: no cover
pytest.skip("node not available")
# Extract the two constants from source so the test tracks the real values.
cap = int(UI_JS.split("_TRANSPARENT_SETTLED_ROW_CAP=")[1].split(";")[0])
slack = int(UI_JS.split("_TRANSPARENT_SETTLED_ROW_CAP_SLACK=")[1].split(";")[0])
harness = textwrap.dedent(
"""
const CAP=%(cap)d, SLACK=%(slack)d, N=%(n)d;
// Mirror the source's startIdx computation for a not-just-settled,
// not-revealed turn (the load path the reporter hits).
let startIdx=0;
if(N>CAP+SLACK) startIdx=N-CAP;
const mounted=N-startIdx; // rows actually inserted at load
const hidden=startIdx; // rows behind "Show earlier steps"
console.log(JSON.stringify({mounted,hidden,capped:startIdx>0}));
"""
) % {"cap": cap, "slack": slack, "n": row_count}
proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_long_turn_mounts_bounded_rows():
# 400-row reasoning turn (the pathological case) mounts only CAP rows.
out = _run_cap_harness(400)
assert out["capped"] is True
assert out["mounted"] == 30, f"long turn must mount only the cap; got {out}"
assert out["hidden"] == 370
def test_normal_turn_is_never_capped():
# A normal multi-tool turn (well under cap+slack) mounts everything — no
# affordance, zero behavior change. This is the non-vacuity guard: if the cap
# fired here it would be a UX regression.
out = _run_cap_harness(12)
assert out["capped"] is False
assert out["mounted"] == 12
assert out["hidden"] == 0
def test_boundary_no_stub_affordance():
# At exactly cap+slack (40) we must NOT cap (no "show 10 earlier" stub); at
# cap+slack+1 (41) we cap. Proves the slack guard works.
assert _run_cap_harness(40)["capped"] is False
assert _run_cap_harness(41)["capped"] is True
# --------------------------------------------------------------------------- #
# Codex gate fixes — F2 (owner index) + F3 (persistent reveal)
# --------------------------------------------------------------------------- #
def test_owner_index_stamped_on_rows_and_affordance():
# Codex F2: a multi-segment turn's scene is owned by a later segment, so
# recovery keyed off the turn's FIRST segment resolves the wrong message.
# Rows and the affordance must carry the owner rawIdx.
body = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage")
assert "node.setAttribute('data-anchor-owner-idx',String(rawIdx))" in body
assert "earlier.setAttribute('data-anchor-owner-idx',String(rawIdx))" in body
def test_dataset_recovery_uses_owner_index():
body = _function_body(UI_JS, "_transparentToolCallFromRowDataset")
assert "data-anchor-owner-idx" in body
# And still has a scene-owning-segment fallback if the stamp is absent.
assert "_anchor_activity_scene" in body
def test_rehydrate_binds_affordance_to_owner_message():
body = _function_body(UI_JS, "_rehydrateTransparentStreamDom")
assert "data-anchor-owner-idx" in body
assert "_revealTransparentEarlierSteps(msg,seg,idx,el)" in body
def test_reveal_state_persists_and_invalidates_cache():
# Codex F3: the DOM-only revealed flag is lost across the HTML-cache round-trip,
# silently re-capping a turn the user already expanded. Reveal state lives in a
# persistent session/owner-keyed set, and reveal invalidates the session cache.
assert "const _transparentRevealedTurns=new Set()" in UI_JS
render = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage")
assert "_transparentRevealedTurns.has(revealKey)" in render
reveal = _function_body(UI_JS, "_revealTransparentEarlierSteps")
assert "_transparentRevealedTurns.add(revealKey)" in reveal
assert "_sessionHtmlCache.delete(sid)" in reveal
def test_label_uses_i18n_with_fallback():
# Fable fast-follow: label resolves via t() when the key exists, else the
# English literal (t() returns the key name for unknown keys, so a plain
# `t()||literal` wouldn't fall back).
body = _function_body(UI_JS, "_tOrDefault")
assert "v!==key" in body
assert "show_earlier_step_one: 'Show 1 earlier step'" in (
(ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
)
assert "show_earlier_steps: 'Show {0} earlier steps'" in (
(ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
)