Files
ducoterra ab9391d019
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 166_hide_empty_answer_bubble
**Phase 166 — final verification pass: green.** All 3 task files already in `complete/`, so this was verification + defect/doc fixing.

- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
  - `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
  - `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
  - `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
2026-10-08 04:27:03 -04:00

714 lines
29 KiB
Python

"""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 ``<tools>`` 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 → ``<tools>``).
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=<id>``
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/<token>`` — 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=<id>`` 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 → ``<tools>`` → 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 ``<relevance>`` + ``<documents>`` +
``<tools>`` 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 <name>…), 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 <span>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=<id> 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=<id> 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=<id> 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()