**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`.
368 lines
17 KiB
Python
368 lines
17 KiB
Python
"""Phase 109 E2E (Playwright): the never-frozen turn — the reported
|
||
repro (delta → tool → thinking-after-delta) replayed deterministically.
|
||
|
||
Story: n/a — owner request 2026-09-16 (TODO.md L3): "Thinking can
|
||
happen after the model starts responding. This sometimes results in a
|
||
the chat appearing 'frozen' because the model responds, calls a tool,
|
||
then continues thinking without re-expanding the thinking block. There
|
||
should be a visual that the chat is still progressing regardless of
|
||
what state it's in (some kind of loader will do)."
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov
|
||
|
||
Marker contract: the mock's ``answer first, then list, then think``
|
||
marker (``TURN_PROGRESS_TRIGGER`` — see the ``mock_llm.py`` module
|
||
docstring) drives the deterministic reported repro. Model call 1
|
||
streams a short content delta (3 chunks, NO reasoning) after a ~2 s
|
||
pre-delay (model latency — the loader's start-state window), then the
|
||
no-arg ``ls`` tool call; model call 2 (after the server ran the ``ls``)
|
||
streams a ~7 s frameless gap (past the phase-87 tool-line ``(Ns)``
|
||
counter's 5 s gate), then a ``reasoning_content`` stream (10 × 0.3 s),
|
||
a content delta (3 chunks ending in the distinctive final sentence
|
||
carrying ``marker-progress-42``), and a FINAL ``reasoning_content``
|
||
chunk group (3 × 0.3 s). The server is position-independent over the
|
||
wire (``app/rag/llm.py`` — each ``reasoning_content`` chunk → a
|
||
``thinking`` SSE frame, each ``content`` chunk → a ``delta`` frame;
|
||
the tool call materializes after its request's stream), so the turn's
|
||
SSE is exactly ``delta → tool → thinking → delta → thinking → done`` —
|
||
the owner's repro, and every window is deterministic (the mock's
|
||
baked delays, the ``slow_llm.py`` pacing precedent).
|
||
|
||
Phase 158 supersession (owner direction 2026-10-04, recorded): phase 109's
|
||
D15 re-open contract (ONE ``details.thinking`` re-opened by every later
|
||
thinking frame) is REPLACED by the per-run timeline (D1/D5): each
|
||
contiguous thinking run streams into its OWN block, and the first frame
|
||
of a new kind closes the previous run. The SSE stream is unchanged, so
|
||
this turn renders ONE ``.tool-calls-disclosure`` + TWO ``.thinking``
|
||
blocks (arrival order) — wrapped since phase 161 in the ONE
|
||
``details.reasoning`` container ABOVE the answer bubble (D1), the
|
||
container open from creation until the settle fold (D2). The
|
||
2026-10-06 D5 refinement (owner, shipped in ``690fd98``): the per-run
|
||
blocks are PLAIN DIVS — always visible inside the container, never
|
||
themselves collapsible (the container is the ONE disclosure). The
|
||
loader pins
|
||
(``test_loader_visible_from_send_through_the_tool_gap``,
|
||
``test_loader_a11y_and_reduced_motion``) stay unchanged — the
|
||
never-stale loader contract (D16) is intact.
|
||
|
||
Each test sends the marker question in its OWN fresh conversation
|
||
(one full turn per test — the ``page`` fixture is a fresh browser
|
||
context, ``_reset_db`` reseeds the KB deterministically):
|
||
|
||
* ``test_loader_visible_from_send_through_the_tool_gap`` — the loader
|
||
is visible right after the send (the thinking state, no frame yet —
|
||
the start-state window), is STILL visible when the ``.tool-call``
|
||
line lands (the turn is provably in flight), the tool line carries
|
||
the phase-87 elapsed counter in the tool gap, and ``#send-status``
|
||
carries a state text (not empty) throughout.
|
||
* ``test_thinking_runs_open_in_live_window_with_visible_loader`` —
|
||
THE reported symptom's state, re-pinned for phase 158 + the phase-161
|
||
D5 refinement (a NEW plain-div run instead of a re-open; the runs
|
||
never fold, the ONE container does): while the
|
||
``details.reasoning`` container is OPEN, exactly one ``.thinking``
|
||
run is VISIBLE with non-empty ``.thinking-text`` while the bubble
|
||
already carries call 1's content — the run starts AFTER the first
|
||
delta — with the loader STILL visible (the frozen window is gone —
|
||
the user sees the new thinking in its own live run); the terminal
|
||
state is clean (loader hidden, the container folded at the settle —
|
||
zero nested ``<details>`` inside it — the scratchpad holds TWO
|
||
``.thinking`` runs EACH carrying its own round's text, the bubble
|
||
carries BOTH answers, the send button reads "Send" not "Stop",
|
||
``#send-status`` back to the idle shape).
|
||
* ``test_loader_a11y_and_reduced_motion`` — after a full turn: the
|
||
loader element is ``aria-hidden="true"`` in the DOM and
|
||
``#send-status`` (the sole ``aria-live`` announcer) is back to the
|
||
``SEND_STATUS`` idle shape (empty — not stuck on a mid-turn label);
|
||
then, in a context with ``reducedMotion: "reduce"`` (the Playwright
|
||
context option), a second turn shows the loader STILL visible
|
||
mid-turn — the reduced-motion variant stills the sweep (the phase-
|
||
117 brain wave: computed ``animation-name: none`` on the ``.bw-pulse``
|
||
path), it does not HIDE the cue: visibility is
|
||
owned by the JS ``hidden`` attribute in ``setUiState`` (D16), not by
|
||
CSS.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import re
|
||
from pathlib import Path
|
||
from threading import Thread
|
||
from typing import Any
|
||
|
||
from playwright.sync_api import Browser, Page, expect
|
||
from sqlalchemy import text
|
||
|
||
from app.config import Settings
|
||
from app.db import SessionLocal
|
||
from app.rag.importer import ImportSummary, import_sources
|
||
from app.rag.llm import LLMClient
|
||
from e2e.auth_helpers import login
|
||
|
||
REPO = Path(__file__).resolve().parents[2]
|
||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||
|
||
#: The marker question — the mock's ``TURN_PROGRESS_TRIGGER`` phrase +
|
||
#: an on-topic tail (HIGH gate → the ``<tools>`` section, so the
|
||
#: scripted ``ls`` round is reachable — the phase-37/70 convention).
|
||
QUESTION = "answer first, then list, then think — how is my Kubernetes cluster set up?"
|
||
|
||
#: The mock's byte-stable sentinels (``mock_llm.py``): call 1's
|
||
#: content, call 2's final sentence, call 2's scratchpad, call 2's
|
||
#: FINAL scratchpad (the thinking after the answer's last delta).
|
||
SENTINEL_FIRST = "marker-progress-41"
|
||
SENTINEL_FINAL = "marker-progress-42"
|
||
SENTINEL_THOUGHT = "marker-thought-42"
|
||
SENTINEL_FINAL_THOUGHT = "marker-final-thought-42"
|
||
|
||
|
||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
|
||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||
return await import_sources([FIXTURES], LLMClient(settings))
|
||
|
||
|
||
def _run_in_thread(coro: Any) -> Any:
|
||
"""Run a coroutine on a worker thread.
|
||
|
||
Playwright's sync API keeps an asyncio loop running on the test
|
||
thread, so ``asyncio.run`` cannot be called directly from a test
|
||
body.
|
||
"""
|
||
box: dict[str, Any] = {}
|
||
|
||
def runner() -> None:
|
||
try:
|
||
box["value"] = asyncio.run(coro)
|
||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||
box["error"] = e
|
||
|
||
t = Thread(target=runner)
|
||
t.start()
|
||
t.join()
|
||
if "error" in box:
|
||
raise box["error"]
|
||
return box["value"]
|
||
|
||
|
||
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||
"""Truncate the KB (+ query log + steering notes — deterministic
|
||
mock answers), then optionally re-import fixtures."""
|
||
with SessionLocal() as db:
|
||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||
db.commit()
|
||
if not seed:
|
||
return None
|
||
return _run_in_thread(_import_fixtures(mock_port))
|
||
|
||
|
||
def _send(page: Page) -> None:
|
||
"""Send the marker question (one full reported-repro turn)."""
|
||
page.fill("#message-input", QUESTION)
|
||
page.click("#send-btn")
|
||
|
||
|
||
def _answer_bubble(page: Page) -> Any:
|
||
"""The answer bubble (the typing indicator's bubble is ``.typing``
|
||
— scoped out)."""
|
||
return page.locator(".msg.brain .bubble:not(.typing)")
|
||
|
||
|
||
def test_loader_visible_from_send_through_the_tool_gap(
|
||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||
) -> None:
|
||
summary = _reset_db(mock_llm, seed=True)
|
||
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
_send(page)
|
||
|
||
# Right after the send — inside call 1's ~2 s pre-delay window:
|
||
# the turn is in flight (the thinking state, NO frame has arrived
|
||
# yet) and the loader is ALREADY visible — the start-state cue.
|
||
# ``setUiState(thinking)`` runs synchronously in the submit path,
|
||
# so this is race-free: the button is the Stop control, the
|
||
# loader is out, and no frame-derived surface exists yet (no
|
||
# answer bubble, no thinking block, no tool line — only the
|
||
# pre-delta typing bubble).
|
||
loader = page.locator("#turn-loader")
|
||
expect(loader).to_be_visible()
|
||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||
expect(page.locator("#typing-indicator")).to_be_visible()
|
||
expect(_answer_bubble(page)).to_have_count(0)
|
||
expect(page.locator(".msg.brain .thinking")).to_have_count(0)
|
||
expect(page.locator(".tool-call")).to_have_count(0)
|
||
|
||
# The ``.tool-call`` line lands (call 1's content + the ls): the
|
||
# turn is provably in flight — and the loader is STILL visible.
|
||
expect(page.locator(".tool-call")).to_be_visible(timeout=30_000)
|
||
expect(loader).to_be_visible()
|
||
expect(_answer_bubble(page)).to_contain_text(SENTINEL_FIRST)
|
||
|
||
# The tool gap (call 2's ~7 s frameless delay — deliberately past
|
||
# the phase-87 counter's 5 s gate): the tool line's elapsed
|
||
# counter appears and ticks BEFORE the first thinking frame
|
||
# settles the line ...
|
||
elapsed = page.locator(".tool-call .tool-elapsed")
|
||
expect(elapsed).to_be_visible(timeout=20_000)
|
||
expect(elapsed).to_have_text(re.compile(r"\(\d+s\)"))
|
||
# ... and the loader + the #send-status state text hold through
|
||
# the whole gap (the status line carries the streaming state's
|
||
# text — it is never empty while a frame is in flight).
|
||
expect(loader).to_be_visible()
|
||
expect(page.locator("#send-status")).not_to_be_empty()
|
||
|
||
|
||
def test_thinking_runs_open_in_live_window_with_visible_loader(
|
||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||
) -> None:
|
||
summary = _reset_db(mock_llm, seed=True)
|
||
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
_send(page)
|
||
|
||
blocks = page.locator(".msg.brain .thinking")
|
||
bubble = _answer_bubble(page)
|
||
loader = page.locator("#turn-loader")
|
||
|
||
# THE reported symptom's state (phase 158 D5 replaces phase-109
|
||
# D15 — the post-delta thinking frames open a NEW per-run block,
|
||
# and the phase-161 D5 refinement (owner 2026-10-06, 690fd98) made
|
||
# the runs PLAIN DIVS inside the ONE details.reasoning container —
|
||
# always visible, the CONTAINER is the disclosure): while the
|
||
# container is OPEN, exactly ONE ``.thinking`` run exists and
|
||
# paints non-empty text WHILE the answer bubble already carries
|
||
# call 1's content — the first thinking run starts AFTER the first
|
||
# delta (the owner's "thinking after responding" repro), and the
|
||
# loader is STILL visible (the frozen window is gone — the user
|
||
# sees the new thinking in its own live run).
|
||
page.wait_for_function(
|
||
"""() => {
|
||
const cont = document.querySelector(
|
||
'.msg.brain details.reasoning[open]');
|
||
if (!cont) return false;
|
||
const b = cont.querySelectorAll('.thinking');
|
||
const t = b.length === 1 ? b[0].querySelector('.thinking-text') : null;
|
||
return b.length === 1 && !!t && t.textContent.trim().length > 0;
|
||
}""",
|
||
timeout=30_000,
|
||
)
|
||
expect(blocks).to_have_count(1)
|
||
expect(blocks.first).to_be_visible()
|
||
expect(
|
||
page.locator(".msg.brain details.reasoning").first
|
||
).to_have_attribute("open", "")
|
||
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
|
||
# The run is genuinely AFTER a delta: call 1's content is already
|
||
# in the bubble ...
|
||
expect(bubble).to_contain_text(SENTINEL_FIRST)
|
||
# ... and the loader is STILL visible — the frozen window is gone.
|
||
expect(loader).to_be_visible()
|
||
|
||
# Terminal approach: call 2's distinctive final sentence landed in
|
||
# the bubble ...
|
||
expect(bubble).to_contain_text(SENTINEL_FINAL, timeout=30_000)
|
||
# ... and the turn settles (done → idle through setUiState): the
|
||
# loader is HIDDEN (its sole owner — D16, no per-handler cleanup) ...
|
||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||
expect(loader).to_be_hidden()
|
||
# ... the scratchpad holds TWO ``.thinking`` blocks (the first
|
||
# delta closed run 1; the final round — thinking AFTER the
|
||
# answer's last delta — opened its OWN block rather than
|
||
# re-opening run 1's) ...
|
||
expect(blocks).to_have_count(2)
|
||
# ... EACH carrying its OWN round's text (the flow's two distinct
|
||
# reasoning streams — separate blocks, not one lump) ...
|
||
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
|
||
expect(blocks.nth(1).locator(".thinking-text")).to_contain_text(SENTINEL_FINAL_THOUGHT)
|
||
# ... and the ONE container FOLDED at the settle (phase 161 D2 —
|
||
# the container is the ONLY disclosure; the inner runs are plain
|
||
# divs, never collapsible: zero nested <details>; the phase-109
|
||
# symptom — the frozen chat — stays fixed) ...
|
||
expect(page.locator(".msg.brain details.reasoning[open]")).to_have_count(0)
|
||
expect(page.locator(".msg.brain details.reasoning details")).to_have_count(0)
|
||
# ... the bubble carries BOTH answers (call 1 + call 2) ...
|
||
expect(bubble).to_contain_text(SENTINEL_FIRST)
|
||
expect(bubble).to_contain_text(SENTINEL_FINAL)
|
||
# ... the send button is back to "Send" (asserted above via the
|
||
# wait) and NOT the Stop treatment ...
|
||
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
|
||
# ... and #send-status is back to the idle shape (not stuck on a
|
||
# mid-turn label).
|
||
expect(page.locator("#send-status")).to_have_text("")
|
||
|
||
|
||
def test_loader_a11y_and_reduced_motion(
|
||
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||
) -> None:
|
||
summary = _reset_db(mock_llm, seed=True)
|
||
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
_send(page)
|
||
# The full turn lands (the distinctive final sentence + done →
|
||
# the Send button back).
|
||
expect(_answer_bubble(page)).to_contain_text(SENTINEL_FINAL, timeout=60_000)
|
||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||
|
||
# The a11y split: the loader element is ``aria-hidden="true"`` in
|
||
# the DOM (decorative — it never carries meaning) ...
|
||
expect(page.locator("#turn-loader")).to_have_attribute("aria-hidden", "true")
|
||
# ... and #send-status (the sole ``aria-live`` announcer) is back
|
||
# to the SEND_STATUS idle shape after done — EMPTY, not stuck on a
|
||
# mid-turn label ("… is thinking" / "… is answering").
|
||
status = page.locator("#send-status")
|
||
expect(status).to_have_attribute("aria-live", "polite")
|
||
expect(status).to_have_text("")
|
||
# Control (default-motion context): the CONTAINER carries no
|
||
# animation — the SWEEP path runs the phase-117 bwdraw travel —
|
||
# the reduced-motion check below stills it.
|
||
assert (
|
||
page.evaluate(
|
||
"getComputedStyle(document.querySelector('#turn-loader')).animationName"
|
||
)
|
||
== "none"
|
||
)
|
||
assert (
|
||
page.evaluate(
|
||
"getComputedStyle(document.querySelector('#turn-loader .bw-pulse')).animationName"
|
||
)
|
||
== "bwdraw"
|
||
)
|
||
|
||
# Reduced motion: a FRESH context with ``reducedMotion: "reduce"``
|
||
# (the Playwright context option) — send a second turn and pin
|
||
# that the loader is STILL visible mid-turn: the reduced-motion
|
||
# variant stills the dots (the CSS rule changes — computed
|
||
# ``animation-name: none``), but visibility is owned by the JS
|
||
# ``hidden`` attribute in setUiState (D16), not by CSS.
|
||
rm_context = browser.new_context(
|
||
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
|
||
)
|
||
try:
|
||
rm_page = rm_context.new_page()
|
||
rm_page.set_default_timeout(30_000)
|
||
login(rm_page, app_url, next="/")
|
||
_send(rm_page)
|
||
# Mid-turn (the tool gap — call 2's ~7 s frameless delay): the
|
||
# loader is still the visible cue ...
|
||
expect(rm_page.locator(".tool-call")).to_be_visible(timeout=30_000)
|
||
expect(rm_page.locator("#turn-loader")).to_be_visible()
|
||
# ... and the reduced-motion CSS variant IS applied: the full
|
||
# trace shows static, no travel (the animation is gone from
|
||
# the sweep path's computed style — the loader itself is
|
||
# unchanged).
|
||
assert (
|
||
rm_page.evaluate(
|
||
"getComputedStyle(document.querySelector('#turn-loader .bw-pulse')).animationName"
|
||
)
|
||
== "none"
|
||
)
|
||
finally:
|
||
rm_context.close()
|