"""Phase 166 E2E (Playwright, mock-only): NO empty answer box paints before the first token (owner TODO.md L4 — "There's an empty box where the LLM response will go that shows up before the LLM responds. It looks ugly." — the attached image.png: Thinking ▸ / Tool calls (2) ▸ / an expanded Thinking run, then a bordered, padded, EMPTY rounded box). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_no_empty_box.py -v --no-cov MOCK-ONLY suite: the gate is the deterministic paced marker flow in ``tests/e2e/mock_llm.py`` (user message contains ``paint the pre-answer window`` (``EMPTY_BUBBLE_TRIGGER``) **and** the system prompt carries the ```` section of the HIGH prompt): request 1 streams ~1.2 s of ``reasoning_content`` and one ``ls`` tool call with ZERO content deltas (the exact mid-turn state of the owner's screenshot), request 2 (id a ``tool``-result) waits ~2.0 s, then answers in three short chunks. Every visible window is ≥ 1 s by construction (the phase-109 pacing precedent), so the window assertions are race-free — the pre-delta window from the first thinking frame to the first delta is ~3.2 s. The fix is the ONE CSS rule ``.msg .bubble:empty { display: none; }`` (LOCKED A1 — hide in CSS, the JS keeps creating the empty bubble as the phase-158/161 reasoning-container anchor); nothing replaces the box (A2 — the typing indicator + the reasoning container ARE the mid-turn feedback, PLAN §7.4), and the same rule covers the restore and the shared page (A3). The unit pins live in ``tests/unit/test_no_empty_answer_bubble.py``; this file proves the browser behavior. KB fixture — the deterministic two-document pair mirrored from ``test_reasoning_bubble.py``: the marker question cosines well past the mock-calibrated 0.30 E2E threshold plus FTS hits, so the turn is solidly grounded (HIGH prompt → ````). Test → phase mapping (Playwright Mapping Rule): 1. ``test_no_box_paints_before_the_first_token`` — the defect window (D1/D3): once ``details.reasoning`` exists (the sync point — the first thinking frame, which also removes the typing indicator), ``.msg.brain .bubble`` is NOT visible and its box is zero-height while the thinking + tool frames stream, while the reasoning container IS open with a live status literal (``Thinking…`` / ``Calling ``) and the composer reads the in-flight state (``Stop``). The mid-turn screenshot is the owner's before/after record. THE SETTLE: after ``done`` the SAME locator is visible with ``height > 0`` holding the answer, the container folds, and the phase-130 rows (chips, actions) sit BELOW the bubble (hiding the bubble never hides them). 2. ``test_plain_turn_typing_indicator_carries_the_window`` — the no-thinking control: a plain answer question shows the typing indicator (dots ⇒ never ``:empty`` ⇒ painted with height > 0) while in flight, and NO empty bubble ever paints a box. The plain turn is FAST (the mock streams unpaced), so the proof is an IN-PAGE sampler on a 10 ms interval installed BEFORE the click: typing painted ≥ 1 sample, painted empty boxes 0 samples across every sample of the turn (the answer bubble is created WITH its first delta in one JS task — nothing can paint between them, and the sampler is the browser-side evidence of exactly that; A2). 3. ``test_restored_answered_chat_has_no_empty_box`` — the restore corner (A3): the auto-saved chat re-loaded through ``/?chat=`` renders its answer bubble normally and carries ZERO ``.bubble:empty`` elements. 4. ``test_shared_page_has_no_empty_box`` — the shared-page corner (A3): the phase-51 save + share flow, a fresh anonymous context on ``/shared/`` — the answer renders, zero ``.bubble:empty``. 5. ``test_empty_persisted_answer_paints_no_box`` — the empty-TEXT record corner (A3, the shared-page defect half): a saved chat whose persisted brain text is whitespace (passes the client's ``text.length > 0`` filter, renders through ``renderMarkdown(" ") === ""``) paints NO box on ``/?chat=`` AND on its shared page — the bubble exists in the DOM (the anchor contract) but zero-height and hidden. """ from __future__ import annotations import hashlib import re import time import uuid from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import Any import httpx from playwright.sync_api import Browser, BrowserContext, Page, expect from sqlalchemy import text from sqlalchemy.orm import Session from app.db import SessionLocal from app.models import Chunk, Document, GitSource, SavedChat from e2e.auth_helpers import login from tests.e2e.mock_llm import EMPTY_BUBBLE_ANSWER, EMPTY_BUBBLE_TRIGGER, embed_text REPO = Path(__file__).resolve().parents[2] SCREENSHOTS = REPO / ".agents" / "screenshots" # house convention for visual records # -------------------------------------------------------------------------- # Fixture documents (deterministic, token-controlled — mirrored from # test_reasoning_bubble.py so the grounding gate is byte-stable) # -------------------------------------------------------------------------- SEED_SOURCE = "Homelab" SEED_PATH = "aws-route53.md" READ_SOURCE = "Deployments" READ_PATH = "example-record-file.json" ROUTE53_CONTENT = ( "# AWS Route 53 Notes\n\n" "## Record file\n\n" + ( "The aws route53 hosted zone for reeselink keeps every record in " "reseelink.json — the exact JSON shape of reeselink.json is " "documented in example-record-file.json.\n" ) * 10 + "\n## Sync job\n\n" "A cron job pushes reeselink.json to the aws route53 hosted zone " "every fifteen minutes; the diff is applied through the route53 api.\n" ) RECORD_FILE_CONTENT = ( '{ "version": 3, "comment": "ReeseLink hosted zone records — the exact ' "JSON shape of reeselink.json\",\n" ' "hosted_zone_id": "Z0RESEELINK01",\n' ' "record_sets": [\n' ' { "name": "www.reeselink.example", "type": "A", "ttl": 300,\n' ' "resource_records": [ { "value": "10.0.0.20" } ] },\n' ' { "name": "api.reeselink.example", "type": "CNAME", "ttl": 300,\n' ' "resource_records": [ { "value": "www.reeselink.example" } ] }\n' " ]\n" "}\n" ) #: The phase-166 marker question: ``EMPTY_BUBBLE_TRIGGER`` + the KB #: topic — the mock-calibrated cosine keeps the turn grounded (HIGH #: prompt → ```` → the deterministic pre-answer-window flow). EMPTY_WINDOW_QUESTION = ( f"{EMPTY_BUBBLE_TRIGGER.capitalize()}: what is the exact JSON shape " "of reeselink.json for my aws route53 hosted zone?" ) #: The no-thinking control (NO trigger — the generic ``compose_answer`` #: path: no thinking frames, no tool frames; the typing indicator is #: the whole pre-answer state). PLAIN_ANSWER_QUESTION = ( "What is the exact JSON shape of reeselink.json for my aws " "route53 hosted zone?" ) #: The live status literals of the reasoning container (phase 161 D6) #: — the thinking/tool state's visible text while the box stays hidden. LIVE_STATUS_RE = re.compile(r"^(Thinking…|Calling )") SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$") # -------------------------------------------------------------------------- # DB seeding (TRUNCATE-then-seed, mirrored from test_reasoning_bubble.py) # -------------------------------------------------------------------------- def _seed(db: Session) -> None: """The two-document pair (see the module docstring), plus the phase-94 registry rows: both sources registered, ``Deployments`` FIRST (the operator's ``BOR_GIT_SOURCES`` is ignored — a non-empty table wins). COMMIT between the inserts (not flush): ``added_at`` is ``server_default now()`` and the tie-break is the random uuid ``id`` — one-transaction rows order nondeterministically.""" db.add(GitSource(url=READ_SOURCE, kind="local")) db.commit() db.add(GitSource(url=SEED_SOURCE, kind="local")) md = Document( source=SEED_SOURCE, path=SEED_PATH, full_path=f"/tmp/{SEED_PATH}", title="AWS Route 53 Notes", content=ROUTE53_CONTENT, content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(), indexed_at=datetime.now(UTC), created_at=datetime(2024, 6, 15, tzinfo=UTC), ) db.add(md) db.flush() # One chunk carrying the mock's own embedding → genuine token # overlap between the marker question and this document. db.add( Chunk( document_id=md.id, position=0, content=ROUTE53_CONTENT, embedding=embed_text(ROUTE53_CONTENT), ) ) db.add( Document( source=READ_SOURCE, path=READ_PATH, full_path=f"/tmp/{READ_PATH}", title="Example Record File", content=RECORD_FILE_CONTENT, content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(), indexed_at=datetime.now(UTC), created_at=datetime(2024, 6, 15, tzinfo=UTC), ) ) def _reset_db(seed: Callable[[Session], None] | None = None) -> None: """Truncate the KB (plus the prompt-shaping tables and the phase-55 auto-save rows), then re-seed — the E2E isolation pattern. ``steering_notes`` / ``kb_overview`` are truncated too, so the HIGH prompt is exactly ```` + ```` + ```` regardless of leftovers — byte-stable prompts, byte-stable answers.""" with SessionLocal() as db: db.execute( text( "TRUNCATE chunks, documents, query_log, " "steering_notes, kb_overview, saved_chats, git_sources" ) ) db.commit() if seed is not None: seed(db) db.commit() # -------------------------------------------------------------------------- # Page helpers # -------------------------------------------------------------------------- #: The defect's visible signature: a ``.msg .bubble`` element that is #: EMPTY (child-less — exactly what ``addMessage("brain","")`` #: produces) yet still PAINTS A BOX (height > 0). The owner's #: image.png is this number being ≥ 1; phase 166 makes it 0 at every #: sample, live, restored, and shared (the same stylesheet on the #: shared page — LOCKED A3). PAINTED_EMPTY_BOXES_JS = """ () => [...document.querySelectorAll('#messages .msg .bubble')] .filter((el) => el.matches(':empty') && el.getBoundingClientRect().height > 0) .length """ #: The IN-PAGE sampler (the no-thinking control's race-free proof): #: a 10 ms interval installed BEFORE the submit counts (a) samples #: where ``#typing-indicator .bubble`` painted a box and (b) samples #: where any EMPTY ``.msg .bubble`` painted a box. The plain turn can #: settle in ~100 ms — too fast for out-of-band polling to bracket #: reliably; the sampler sees every 10 ms slice of the page from the #: click to the settle (and a few past it) from INSIDE the page. SAMPLER_ON_JS = """ () => { window.__p166 = { samples: 0, typingPainted: 0, paintedEmpty: 0 }; window.__p166.timer = setInterval(() => { const s = window.__p166; s.samples += 1; const t = document.querySelector('#typing-indicator .bubble'); if (t && t.getBoundingClientRect().height > 0) s.typingPainted += 1; const empties = [...document.querySelectorAll('#messages .msg .bubble')] .filter((el) => el.matches(':empty') && el.getBoundingClientRect().height > 0); if (empties.length > 0) s.paintedEmpty += 1; }, 10); } """ SAMPLER_OFF_JS = """ () => { clearInterval(window.__p166.timer); const { samples, typingPainted, paintedEmpty } = window.__p166; return { samples, typingPainted, paintedEmpty }; } """ def _bubble_height(locator: Any) -> float: """The painted box height of one bubble — ``display: none`` (the phase-166 rule) yields exactly 0.""" height: float = locator.evaluate("el => el.getBoundingClientRect().height") return height def _submit(page: Page, question: str) -> None: page.fill("#message-input", question) page.click("#send-btn") # The user bubble lands synchronously with the submit handler. expect(page.locator(".msg.user .bubble").last).to_contain_text(question) def _wait_settled(page: Page) -> None: """The turn is complete: answer text in the bubble, button recovered (phase 48 — the in-flight button is the enabled Stop control, so the label assertion carries the settle wait).""" expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000) expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) # -------------------------------------------------------------------------- # Phase-51 save + share flow (the test_reasoning_bubble.py pattern — the # auto-saved row is shared from the chat page; the row is deleted in a # finally) # -------------------------------------------------------------------------- def _admin_cookies(page: Page) -> dict[str, str]: return { c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c } def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]: r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies) assert r.status_code == 200 return r.json()["chats"] def _auto_title(question: str) -> str: """The phase-50 auto-title convention: the first question, whitespace-collapsed, capped at 120 chars.""" return " ".join(question.split())[:120] def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None: return next((c for c in rows if c["title"] == title), None) def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None: """Best-effort row cleanup (a 404 — already deleted — is fine).""" httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) def _wait_saved_row( app_url: str, cookies: dict[str, str], title: str, messages: int = 2, ) -> dict[str, Any]: """Wait for the auto-saved row (phase 55 — auto-saves are SILENT, so there is no status line to wait on). The upsert is fire-and-forget from the UI's point of view, so poll the admin list until the row with the conversation's auto-title appears with the expected message count.""" deadline = time.monotonic() + 15 last: dict[str, Any] | None = None while time.monotonic() < deadline: last = _find_row(_chats(app_url, cookies), title) if last is not None and last["message_count"] >= messages: return last time.sleep(0.2) raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})") def _grant_clipboard(page: Page, app_url: str) -> None: """Grant the async-clipboard permissions on the admin context (headless Chromium requires the grant before ``writeText`` resolves — without it the inline-link fallback fires).""" page.context.grant_permissions( ["clipboard-read", "clipboard-write"], origin=app_url ) def _click_share_and_assert_status(page: Page, app_url: str) -> None: """Press the chat page's Share pill and pin the outcome on the live region (the assertion branches on the API's availability — the test_share_chat.py convention).""" page.locator("#share-chat-btn").click() if page.evaluate("() => !!navigator.clipboard"): expect(page.locator("#send-status")).to_have_text( "Share link copied.", timeout=15_000 ) else: expect(page.locator("#send-status")).to_have_text( "Share link ready — copy it from the field.", timeout=15_000 ) field = page.locator(".share-link-fallback") expect(field).to_be_visible() href = field.get_attribute("href") assert href is not None assert href.startswith(app_url) assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url)) # -------------------------------------------------------------------------- # 1. The defect window: thinking + tool frames stream and NO answer # box paints — the box appears WITH the first delta (D1/D3) # -------------------------------------------------------------------------- def test_no_box_paints_before_the_first_token( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) login(page, app_url, next="/") _submit(page, EMPTY_WINDOW_QUESTION) # THE SYNC POINT: details.reasoning exists only after the FIRST # thinking frame (ensureReasoningWrap runs in the same handler # tick as addMessage("brain", "") and removeTyping()) — so from # here on the ONLY .msg.brain .bubble is the empty answer bubble, # and the mock's pacing keeps the window open ≥ 1 s of thinking + # the tool round + the ~2 s gap (≈ 3.2 s total before the first # delta — race-free by construction). expect(page.locator("#messages .msg.brain details.reasoning")).to_be_visible() # THE DEFECT WINDOW: the answer bubble exists in the DOM (the # phase-158/161 reasoning anchor — LOCKED A1 keeps it) but paints # NO BOX: not visible AND zero-height (the owner's image.png box # is gone). The typing indicator is gone too (the live block # replaced the dots), so the single strict match doubles as the # no-stray-bubble pin. bubble = page.locator("#messages .msg.brain .bubble") expect(bubble).not_to_be_visible() assert _bubble_height(bubble) == 0, ( "the pre-delta answer bubble must paint zero box (the phase-166 defect)" ) assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0, ( "no empty .bubble may paint a box mid-turn (live window)" ) # §7.4 — the mid-turn feedback DOES paint: the reasoning container # is OPEN with a thinking run and its live status literal # (Thinking… / Calling …), and the composer carries the # in-flight state (the phase-48 Stop label). expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_be_visible() expect( page.locator("#messages .msg.brain details.reasoning .thinking-text") ).to_be_visible() expect( page.locator("#messages .msg.brain details.reasoning .reasoning-status") ).to_have_text(LIVE_STATUS_RE) expect(page.locator("#send-label")).to_have_text("Stop") # The owner's before/after record (image.png's twin): the open # reasoning run with NO empty box under it. SCREENSHOTS.mkdir(parents=True, exist_ok=True) shot = SCREENSHOTS / "166_pre_answer_window_1280.png" page.screenshot(path=str(shot)) assert shot.exists() and shot.stat().st_size > 0 # THE SETTLE: the box appears at (and after) the first token. The # SAME locator is now visible with a real box and holds the # byte-stable answer (the three chunks of the mock's request 2). _wait_settled(page) expect(bubble).to_be_visible() assert _bubble_height(bubble) > 0, "the answer box must paint with the first token" expect(bubble).to_contain_text(EMPTY_BUBBLE_ANSWER) # The container folded at done (phase 161 D2 — the settle fold). expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0) # Phase 130 ordering survives the hidden box: the chips row and # the actions row are SIBLING rows of .msg-body BELOW the bubble # (hiding an empty bubble never hides a chip or an action). order = page.evaluate( """ () => { const wraps = document.querySelectorAll('#messages .msg.brain'); const wrap = wraps[wraps.length - 1]; const body = wrap ? wrap.querySelector('.msg-body') : null; const kids = body ? [...body.children] : []; return { bubbleIdx: kids.findIndex((k) => k.classList.contains('bubble')), metaIdxs: kids .map((k, i) => (k.classList.contains('msg-meta') ? i : -1)) .filter((i) => i >= 0), }; } """ ) assert order["bubbleIdx"] >= 0, "the settled turn keeps its answer bubble" assert order["metaIdxs"], "the settled turn keeps its .msg-meta rows" assert all(idx > order["bubbleIdx"] for idx in order["metaIdxs"]), ( f"every .msg-meta row sits BELOW the bubble: {order}" ) expect( page.locator("#messages .msg.brain .msg-meta.msg-actions") ).to_have_count(1) # -------------------------------------------------------------------------- # 2. The no-thinking control: the typing indicator (dots ⇒ never # :empty) carries the pre-answer window; no empty box at any point # -------------------------------------------------------------------------- def test_plain_turn_typing_indicator_carries_the_window( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) login(page, app_url, next="/") # The plain turn is FAST (the mock streams unpaced — the whole # turn can settle in ~100 ms), so the proof runs IN-PAGE: the # sampler counts 10 ms slices from BEFORE the click to a few # hundred ms past the settle. The typing indicator lands # synchronously in the click handler (setUiState(thinking) → # addTyping(): three dot s ⇒ never :empty ⇒ painted) and # the answer bubble is created WITH its first delta in one JS # task — every slice of the turn is accounted for (locked A2: # this affordance IS the pre-answer window). page.evaluate(SAMPLER_ON_JS) _submit(page, PLAIN_ANSWER_QUESTION) _wait_settled(page) # A few extra samples past the settle (the painted answer bubble # is non-empty — the sampler must still read zero painted boxes). time.sleep(0.3) stats = page.evaluate(SAMPLER_OFF_JS) assert stats["samples"] >= 5, f"the sampler never ran: {stats}" assert stats["typingPainted"] >= 1, ( "the typing indicator was never observed painting its dots box " f"while the turn was in flight: {stats}" ) assert stats["paintedEmpty"] == 0, ( f"an empty answer box painted during the plain turn: {stats}" ) expect(page.locator(".msg.brain .bubble").last).not_to_have_text("") assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0 # -------------------------------------------------------------------------- # 3. The restore corner: an auto-saved chat re-loaded through # /?chat= renders its bubble normally, zero .bubble:empty # -------------------------------------------------------------------------- def test_restored_answered_chat_has_no_empty_box( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) login(page, app_url, next="/") _submit(page, PLAIN_ANSWER_QUESTION) _wait_settled(page) cookies = _admin_cookies(page) row = _wait_saved_row(app_url, cookies, _auto_title(PLAIN_ANSWER_QUESTION)) try: # The phase-50 restore: /?chat= boots into the saved # conversation through renderStoredMessage — the answered turn # renders its bubble normally (non-empty, painted). page.goto(app_url + f"/?chat={row['id']}") expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000) bubble = page.locator(".msg.brain .bubble").last expect(bubble).to_be_visible() expect(bubble).not_to_have_text("") assert _bubble_height(bubble) > 0 # ZERO child-less bubbles exist in the restored DOM — the # restore creates no empty anchor (the anchor is a LIVE-turn # construct), so nothing even needs hiding. assert page.locator("#messages .msg .bubble:empty").count() == 0 assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0 finally: _delete_chat(app_url, cookies, row["id"]) # -------------------------------------------------------------------------- # 4. The shared-page corner (LOCKED A3): the phase-51 share flow, a # fresh anonymous context — zero .bubble:empty, the answer painted # -------------------------------------------------------------------------- def test_shared_page_has_no_empty_box( page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None, ) -> None: _reset_db(_seed) page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page).to_have_url(app_url + "/", timeout=30_000) _submit(page, PLAIN_ANSWER_QUESTION) _wait_settled(page) # The phase-51 flow: the conversation is AUTO-SAVED by the time # the answer settles (phase 55) — the Share click shares the # linked row. cookies = _admin_cookies(page) _grant_clipboard(page, app_url) _wait_saved_row(app_url, cookies, _auto_title(PLAIN_ANSWER_QUESTION)) share = page.locator("#share-chat-btn") expect(share).to_be_visible() _click_share_and_assert_status(page, app_url) row = _find_row(_chats(app_url, cookies), _auto_title(PLAIN_ANSWER_QUESTION)) assert row is not None and row.get("share_url"), ( "the shared row must carry a share_url" ) share_url: str = row["share_url"] assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}" created: str = row["id"] anon_ctx: BrowserContext | None = None try: # A FRESH context: a separate session with no cookies — the # guest's only credential is the token in the URL. anon_ctx = browser.new_context() anon = anon_ctx.new_page() anon.set_default_timeout(30_000) anon.goto(app_url + share_url) # The shared answer bubble renders normally (non-empty, # painted) — and shared.js builds the identical # .msg/.msg-body/.bubble structure on the same stylesheet, so # ZERO child-less bubbles exist here either (locked A3). bubble = anon.locator(".msg.brain .bubble").first expect(bubble).to_be_visible() expect(bubble).not_to_have_text("") assert _bubble_height(bubble) > 0 assert anon.locator("#messages .msg .bubble:empty").count() == 0 assert anon.evaluate(PAINTED_EMPTY_BOXES_JS) == 0 finally: if anon_ctx is not None: anon_ctx.close() if created: _delete_chat(app_url, cookies, created) # -------------------------------------------------------------------------- # 5. The empty-TEXT record corner (locked A3): a persisted brain answer # of " " passes the client's length>0 filter and renders through # renderMarkdown(" ") === "" — the bubble lands CHILD-LESS in the # DOM and the SAME rule hides it on /?chat= AND the shared page # -------------------------------------------------------------------------- def test_empty_persisted_answer_paints_no_box( page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None, ) -> None: page.set_default_timeout(30_000) _reset_db(_seed) login(page, app_url, next="/") # The crafted record: the whitespace brain text passes the # restore's `text.length > 0` filter (a real empty string would be # filtered out before rendering), and renderMarkdown(" ") is "" # (unit-pinned) — so the bubble renders CHILD-LESS: exactly the # :empty shape the phase-166 rule suppresses. share_token rides # the same row so the shared page takes the identical corner. token = uuid.uuid4() with SessionLocal() as db: row = SavedChat( title="Phase 166 empty-answer corner", messages=[ {"who": "user", "text": "What is the exact JSON shape?"}, {"who": "brain", "text": " "}, ], share_token=token, ) db.add(row) db.commit() chat_id = str(row.id) anon_ctx: BrowserContext | None = None try: # The restore: the user record renders; the empty brain # bubble EXISTS in the DOM (the same structure, no restore # branch was added) but paints NO BOX. page.goto(app_url + f"/?chat={chat_id}") expect(page.locator(".msg.user .bubble").first).to_contain_text( "What is the exact JSON shape?" ) bubble = page.locator(".msg.brain .bubble").last expect(bubble).to_have_count(1) expect(bubble).not_to_be_visible() assert _bubble_height(bubble) == 0, ( "the empty persisted answer must paint zero box (restore — A3)" ) assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0 # The shared page: the same record, a fresh anonymous context # — same zero-box outcome (shared.js renders the identical # structure on the same stylesheet). anon_ctx = browser.new_context() anon = anon_ctx.new_page() anon.set_default_timeout(30_000) anon.goto(app_url + f"/shared/{token}") expect(anon.locator(".msg.user .bubble").first).to_contain_text( "What is the exact JSON shape?" ) anon_bubble = anon.locator(".msg.brain .bubble").last expect(anon_bubble).to_have_count(1) expect(anon_bubble).not_to_be_visible() assert ( anon_bubble.evaluate("el => el.getBoundingClientRect().height") == 0 ), "the empty persisted answer must paint zero box (shared page — A3)" assert anon.evaluate(PAINTED_EMPTY_BOXES_JS) == 0 finally: if anon_ctx is not None: anon_ctx.close() with SessionLocal() as db: db.execute( text("DELETE FROM saved_chats WHERE id = :id"), {"id": chat_id} ) db.commit()