Files
brain-of-reese/tests/e2e/test_sources_midstream_bug.py
T
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

560 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Story: mid-stream navigation — the phase-76 SPA shell fix (phase 20
story, re-purposed by phase 76 task 02).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov
Phase 20 (bug 24) pinned a REAL departure from the chat mid-answer: a
full page navigation (the "Sources" navbar link → /sources.html) aborted
the stream via the unload, and a single ``pagehide`` save point in
app.js persisted the partial raw answer (``rememberBrainTurn``) so the
user came back to their question WITH the partial, rendered as
"Partial answer — navigation interrupted the stream."
Phase 76 (task 02) folded /sources.html (and /git-sources.html) into
the ONE-document shell: from this phase on, a navbar click is a
CLIENT-SIDE view switch — the document (and its in-flight SSE reader)
survive, so the pinned behavior is "the stream survives and the answer
COMPLETES." The phase-20 pagehide partial-persist REMAINS for REAL
departures only (a cross-document navigation still aborts the fetch),
and its coverage home is scenario 1 below in its renamed form.
Timing is deterministic by construction:
* scenario 1 keys off the mock's ``write a long answer`` trigger — a
~5400-char / ~450-frame / ~9s content stream, so the departure lands
mid-stream with a wide margin;
* scenarios 2–3 key off the same long stream (mid-stream view switch)
and the ``think out loud then hesitate`` trigger — the phase-17
thinking stream followed by a 4s silence before the first content
frame, so the switch lands inside pure thinking;
* scenario 4 (the pre-token real-departure pin) uses the same
hesitate trigger; scenarios 5 and 6 settle the turn fully (send
button re-enabled) before any navigation.
Test → story mapping (Playwright Mapping Rule):
1. ``test_partial_answer_survives_real_departure_midstream``
2. ``test_full_answer_completes_after_rag_nav_midstream``
3. ``test_nav_switch_before_first_token_completes``
4. ``test_no_orphan_brain_message_when_navigated_before_first_token``
5. ``test_completed_turn_unaffected``
6. ``test_new_chat_still_clears_conversation``
"""
from __future__ import annotations
import asyncio
import json
import re
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import 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
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
STORAGE_KEY = "bor.chat.v1"
# --- scenario 1: a turn that is guaranteed to still be in flight --------
#: The mock's long-answer trigger (~9s content stream at 0.02s/frame).
LONG_QUESTION = "write a long answer about how my kubernetes cluster is set up"
FULL_LONG = long_answer()
#: The mock's first 12-char content slice (the same cut ``_sse_stream``
#: makes) — the stored partial must START with it (raw text, pre-render).
FIRST_CHUNK_RAW = re.findall(r".{1,12}", FULL_LONG, re.S)[0]
#: The rendered form of those first frames: the shared escape-first
#: markdown renderer converts the "1. " numbered line into a list item,
#: dropping the marker (pinned by test_long_answers).
FIRST_LINE_DOM = "Step 1: configure node-1"
# --- scenario 3: navigation during pure thinking (no answer tokens) -----
HESITATE_QUESTION = (
"think out loud then hesitate — how is my kubernetes cluster set up?"
)
#: Tail of the mock's deterministic scratchpad (mock_llm.compose_thinking)
#: — when it is rendered, the thinking stream has just ended and the 4s
#: pre-content pause (SLOW_PRETOKEN_TRIGGER) is running.
THINKING_TAIL = "nothing is invented"
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 (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
"""The settled-row count over the whole (truncated) log.
The query log finalizes a row ONLY when the LLM finished AND the
persistence succeeded (phase 48); a cancelled turn — a real
departure mid-stream, or one before the first token — leaves no
settled row, so the count IS the settled-row signal (0 = cancelled,
1 = settled; the house pattern from tests/e2e/test_hidden_tab_
stream.py).
"""
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
def _stored(page: Page) -> str | None:
"""Raw localStorage payload for the chat (None when the key is absent)."""
return page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
def _stored_parsed(page: Page) -> dict[str, Any]:
raw = _stored(page)
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully landed."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _no_error_banner(page: Page) -> None:
"""The never-stale contract: a restored/partial state must never
present an error banner (role=alert) — the turn is simply partial.
Phase 76 (task 02): the shell's hidden views carry their own
ship-hidden role=alert surfaces (sync banner, upload banner, …),
so the pin is VIEW-SCOPED IN EFFECT — NO alert may be VISIBLE,
whatever the document carries hidden."""
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
@pytest.fixture()
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
"""A fresh KB seeded from ``tests/fixtures/docs`` (13 docs since phase
47, A9 formats), truncated again on teardown. ``db_ready`` (conftest)
skips with clear instructions when Postgres is down."""
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13
yield
_reset_db(mock_llm, seed=False)
# ---------------------------------------------------------------------------
# 1. REAL departure mid-stream (pagehide partial persist — the phase-20
# contract, now exercised via a genuine cross-document navigation;
# a navbar click is no longer a departure — that is scenario 2)
# ---------------------------------------------------------------------------
def test_partial_answer_survives_real_departure_midstream(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
# Admin (phase 16/19): only the admin sees the #nav-sources link.
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
# Start the ~9s long answer.
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
# Wait until the first streamed frames have rendered (the first line,
# in its list-rendered form) — the turn is now provably mid-stream.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(FIRST_LINE_DOM, timeout=30_000)
# The turn is still in flight (the stream runs ~9s; navigation takes
# well under that) — phase 48: the in-flight button is the enabled
# Stop control, not the old disabled busy state.
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# THE DEPARTURE, in its phase-76 form: a REAL cross-document
# navigation — page.goto to a genuine other document. /shared.html
# is a plain document (stable for every session state) and — unlike
# /login.html, which auto-redirects a signed-in session straight
# back into the shell — it is a real departure, so the in-flight SSE
# fetch is aborted by the unload (the point).
page.goto(app_url + "/shared.html")
expect(page).to_have_url(app_url + "/shared.html")
expect(page.locator("#shared-title")).to_be_visible(timeout=15_000)
# The turn was CANCELLED — the phase-48 query_log row only lands
# when the LLM finished AND the persistence succeeded, so a
# cancelled mid-stream turn must leave NO settled row.
assert _query_log_count() == 0, (
"a cancelled mid-stream turn must not finalize a query_log row"
)
# Return to the chat.
page.goto(app_url + "/")
# The question AND the already-streamed partial answer are both
# rendered — no empty state, no error banner.
expect(page.locator("#empty-state")).to_be_hidden()
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble").first).to_contain_text(LONG_QUESTION)
restored = page.locator(".msg.brain .bubble")
expect(restored).to_have_count(1)
expect(restored.first).to_contain_text(FIRST_LINE_DOM)
_no_error_banner(page)
# Storage holds the partial as a plain brain message: raw text starting
# with the first streamed chunk — and SHORTER than the full answer
# (navigation landed mid-stream), with no done metadata (no
# sources/deflected/suggestions/thinking: this turn had none and a
# partial never carries the done fields).
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert msgs[0]["text"] == LONG_QUESTION
brain = msgs[1]
assert brain["text"].startswith(FIRST_CHUNK_RAW)
assert len(brain["text"]) < len(FULL_LONG), "the stored answer must be partial"
assert brain["text"] != FULL_LONG
assert "sources" not in brain
assert "deflected" not in brain
assert "suggestions" not in brain
assert "thinking" not in brain
# The partial renders like any brain message (the existing
# bubble contract — no new surface) and is tunable like one.
expect(page.locator("details.thinking")).to_have_count(0)
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
# ---------------------------------------------------------------------------
# 2. Navbar click to RAG mid-stream = a client-side VIEW SWITCH (phase 76,
# task 02): the in-flight stream keeps running while the RAG view
# shows, and the answer COMPLETES — the phase-20 "answer cut short"
# outcome is impossible now (the fetch was never cancelled)
# ---------------------------------------------------------------------------
def test_full_answer_completes_after_rag_nav_midstream(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
# Start the ~9s long answer and wait until visible streaming (the
# house pattern: first line rendered + the enabled Stop control).
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(FIRST_LINE_DOM, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Same-document proof: a window sentinel set before the click is
# still readable after — no load happened (the navigation-entries
# length is NOT used: it resets on a real load).
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# The RAG view actually mounted (the source rows are listed — phase
# 97: the top level lists the sources; the file table is per-level
# and hidden at the top).
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
# Stay on the RAG view while the stream keeps running in the
# background (the switch is ~t+2s; the full answer needs ~9s).
page.wait_for_timeout(2000)
# Back to the chat (the header link — a router-intercepted switch).
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# The answer COMPLETED — the final sentinel line is in the bubble
# (not a partial), no error banner.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(LONG_ANSWER_END, timeout=30_000)
text_now = bubble.inner_text()
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text_now
_no_error_banner(page)
# Settle, then storage: EXACTLY ONE brain turn — the FULL answer,
# with done metadata (the settle, not a partial).
page.wait_for_timeout(500)
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
brain = msgs[1]
assert brain["text"] == FULL_LONG
assert brain["deflected"] is False
# Phase 119 (LOCKED A1): the turn read nothing, so its done sources
# — and the persisted record — are EMPTY (the retired phase-118 A4
# union is gone).
assert brain["sources"] == []
# The turn SETTLED — the phase-48 query_log row exists (a
# cancelled turn would leave no row at all).
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
# ---------------------------------------------------------------------------
# 3. Navbar switch in the pre-first-token window (pure thinking — no
# content frame yet): the surviving reader completes the answer, and
# bor.chat.v1 holds exactly ONE brain turn (the no-orphan invariant
# in its new form — a pre-token view switch neither kills the turn
# nor persists a partial)
# ---------------------------------------------------------------------------
def test_nav_switch_before_first_token_completes(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
page.fill("#message-input", HESITATE_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
# The pre-first-token window, pinned the same way as scenario 4:
# the scratchpad's tail is rendered (the thinking stream has just
# ended) and the 4s pre-content pause (SLOW_PRETOKEN_TRIGGER) is
# running — NO content frame has landed yet. (The .msg.brain bubble
# element exists from turn start with its thinking block — the
# pre-token state is "no content text", not "no bubble element".)
# 690fd98 refinement: the live thinking block is a plain div.thinking
# RUN (never folding mid-turn) inside the details.reasoning container.
thinking = page.locator(".msg.brain").last.locator("div.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text").last).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48
# — the old disabled "Thinking…" busy state is gone).
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Switch to the RAG view NOW — mid-pause, still before the first
# content token.
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# Phase 97: the source rows are the top-level catalog-rendered
# signal (the file table is per-level, hidden at the top).
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
# Let the 4s pre-token pause elapse WHILE the RAG view is up — the
# first content frames land while the chat view is still hidden —
# then return to the chat: the surviving reader completes the
# answer.
page.wait_for_timeout(4500)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# The answer COMPLETED (full text — the deterministic mock answer),
# no error banner.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
_no_error_banner(page)
# Storage: EXACTLY ONE brain turn — the completed answer with done
# metadata (no partial, no orphan, no duplicate).
page.wait_for_timeout(500)
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert MOCK_ANSWER_MARKER in msgs[1]["text"]
assert msgs[1]["deflected"] is False
# Phase 119 (LOCKED A1): the turn read nothing, so its done sources
# — the stored record's sources list — are EMPTY (the retired
# phase-118 A4 suggested-citation is gone; the full retrieval
# stays in query_log, pinned below).
assert msgs[1]["sources"] == []
# The turn settled — one finalized row (a cancelled turn would
# leave no row at all).
assert _query_log_count() == 1
# ---------------------------------------------------------------------------
# 4. REAL departure BEFORE the first answer token (pure thinking): nothing
# brain-side is persisted — the pre-token no-orphan convention,
# unchanged (a direct page.goto to /sources.html REMAINS a real
# departure in the SPA — the shell is served, the fetch is aborted
# by the unload)
# ---------------------------------------------------------------------------
def test_no_orphan_brain_message_when_navigated_before_first_token(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", HESITATE_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
# Wait until the scratchpad's tail is rendered (phase-17 thinking body,
# ~2 700 chars / ≈4.5s, lengthened in phase 21) — the 4s pre-content
# pause (SLOW_PRETOKEN_TRIGGER) is now running, so the navigation below
# lands inside pure thinking with a wide margin. Explicit timeout:
# the mock streams the tail at ≈12 chars / 0.02s (≈4.7s end-to-end),
# which outruns Playwright's 5s assertion auto-wait on a loaded host
# (the wait started at the FIRST thinking frame — the pre-existing
# race, phase 55 task 02 fix).
# 690fd98 refinement: the live thinking block is a plain div.thinking
# RUN (never folding mid-turn) inside the details.reasoning container.
thinking = page.locator(".msg.brain").last.locator("div.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text").last).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48 —
# the old disabled "Thinking…" busy state is gone).
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Leave during the pause via a REAL cross-document departure (no
# answer token has streamed — acc is empty, so the pagehide save
# point must persist nothing brain-side).
page.goto(app_url + "/sources.html")
# Return to the chat.
page.goto(app_url + "/")
# The question is restored — with NO brain message behind it: no empty
# bubble, no partial, no thinking block (owner-confirmed A1.2).
expect(page.locator("#empty-state")).to_be_hidden()
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble").first).to_contain_text(HESITATE_QUESTION)
expect(page.locator(".msg")).to_have_count(1)
expect(page.locator(".msg.brain")).to_have_count(0)
expect(page.locator("details.thinking")).to_have_count(0)
_no_error_banner(page)
# Storage agrees: exactly the user message, nothing brain-side.
msgs = _stored_parsed(page)["messages"]
assert len(msgs) == 1
assert msgs[0] == {"who": "user", "text": HESITATE_QUESTION}
# ---------------------------------------------------------------------------
# 5. Completed turn: the done save point is byte-identical to before —
# the new pagehide save point must not duplicate or alter it (the
# direct gotos are real departures — unaffected by the fold)
# ---------------------------------------------------------------------------
def test_completed_turn_unaffected(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, QUESTION)
# The done save point: full answer + metadata, exactly as phase 14.
before = _stored_parsed(page)
assert [m["who"] for m in before["messages"]] == ["user", "brain"]
brain = before["messages"][1]
assert MOCK_ANSWER_MARKER in brain["text"]
assert brain["deflected"] is False
# Phase 119 (LOCKED A1): the turn read nothing, so its done sources
# — and the persisted record — are EMPTY (the retired phase-118 A4
# union is gone).
assert brain["sources"] == []
# A trip to Sources and back (the turn finished long ago — uiState is
# idle, so the pagehide save point must be a no-op).
page.goto(app_url + "/sources.html")
page.goto(app_url + "/")
# Full answer rendered with ZERO citation chips (the zero-read turn
# persisted an empty sources list — phase 119, LOCKED A1; the
# retired phase-118 A4 chip pin is gone); no error banner.
expect(page.locator("#empty-state")).to_be_hidden()
expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION)
bubble = page.locator(".msg.brain .bubble")
expect(bubble).to_have_count(1)
expect(bubble.first).to_contain_text(MOCK_ANSWER_MARKER)
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
_no_error_banner(page)
# Storage is byte-identical to the pre-navigation payload — the
# completed turn persisted exactly as before (one brain message, done
# metadata intact; no duplicate from the pagehide path).
assert _stored_parsed(page) == before
# ---------------------------------------------------------------------------
# 6. The DELIBERATE clear is untouched: New chat (chat-page only since
# the owner rework 2026-08-28) still clears the conversation
# (phase 14 contract)
# ---------------------------------------------------------------------------
def test_new_chat_still_clears_conversation(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
_ask(page, QUESTION)
assert _stored(page) is not None
# The New chat button is chat-page only (it left the shared bar at
# owner request, 2026-08-28) — the deliberate clear runs right here.
new_chat = page.locator("#new-chat-btn")
expect(new_chat).to_be_visible()
new_chat.click()
# The chat lands on its empty state and the conversation key is
# GONE — the deliberate clear is unaffected by the phase-20 pagehide
# save point.
expect(page.locator("#empty-state")).to_be_visible()
expect(page.locator(".msg")).to_have_count(0)
assert _stored(page) is None, "New Chat must clear the localStorage key"
_no_error_banner(page)