**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`.
714 lines
29 KiB
Python
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()
|