feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+629
View File
@@ -0,0 +1,629 @@
"""Phase 77 E2E (Playwright): navbar re-shows refresh the view's data —
the story suite for the phase-77 refresh hook + the History refresh
button.
Source: TODO.md L3 — "Clicking navbar icons should refresh the relevant
page. For example, clicking 'history' doesn't load new history until I
refresh. The history page should also have a refresh button."
Since the phase-76 shell, a view's data was fetched exactly once, at
mount (mount-once, hide-forever) — a History view opened at 10:00 still
showed 10:00's data at 10:30. Phase 77 makes every USER-INITIATED
re-show of an already-mounted view re-fetch its list: the router
dispatches ``bor:view-refresh`` on the view's section (a switch back
onto it, a re-click of its own nav link — NO pushState — or back/
forward onto it; the first show and boot never), the four data views
(History, RAG, Sources, Tuning) listen and re-run their loads, and the
History view gains an explicit Refresh button in its page-head (the
owner's second half of L3). The Chat view is deliberately excluded
(the in-flight stream and local conversation persist — the phase-76
contract; the unchanged ``test_nav_switch_keeps_stream.py`` suite is
the additional control).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov
The "freshness" proof (the house pattern from the phase overview):
create new backing data via the API AFTER a view has loaded, nav back
(or re-click / press Refresh), assert the new row — with the phase-76
canonical same-document sentinel (a ``window`` global set before the
nav clicks is still readable after — no document load).
Test → story mapping (Playwright Mapping Rule):
1. ``test_reshow_refetches_history`` — THE L3 REPRO: saved chat A
visible on History; chat B created via the API while Chat is up;
Chat → History: row B appears (replaced list — no duplicates), the
window sentinel survives every click (same document).
2. ``test_active_view_reclick_refetches`` — a re-click of the ACTIVE
view's own nav link re-fetches (today a no-op): chat C created via
the API while History is up; click History AGAIN → C appears, the
URL is still /history.html and ``history.length`` is unchanged (no
pushState).
3. ``test_history_refresh_button`` — the explicit Refresh control
(TODO.md L3's second half): chat D created via the API; click
#history-refresh → D appears, #history-status announces "Saved
chats refreshed.", the button is disabled while the request is in
flight (the house hold-the-request pattern — deterministic, not a
race) and re-enabled after (accessible name + keyboard focus
pinned too).
4. ``test_popstate_refetches_history`` — back/forward onto an
already-mounted view re-fetches: Chat → History (loads) → Chat →
create E via the API → ``page.go_back()`` (popstate) → History →
E present.
5. ``test_all_four_data_views_refetch_on_reshow`` — one assertion per
data view (History, RAG, Sources, Tuning): the GET to the view's
list endpoint is exactly +1 on a re-show (the request-log pattern
from task 02) — the first show (the mount) is the baseline.
6. ``test_stream_surval_control`` — the phase-76 CONTRACT control with
the hook in place: send a question (the mock LLM's ~8 s stream) →
mid-stream nav to RAG → back to Chat → the FULL answer completes
and the turn settles (one query_log row). The unchanged
``test_nav_switch_keeps_stream.py`` run in isolation is the
additional control.
"""
from __future__ import annotations
import asyncio
import re
import time
from collections.abc import Callable
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Locator, Page, Request, 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
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: The phase-11 on-topic long-answer phrasing (house pattern,
#: test_nav_switch_keeps_stream.py): the honesty gate is HIGH and the
#: ~900-word answer streams for ~8–9 s — the guaranteed mid-stream
#: window.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
#: Distinct saved-chat titles per test (the DB is truncated at each
#: test's start, so fixed titles stay unambiguous within a test).
TITLE_A = "Phase77 re-show A"
TITLE_B = "Phase77 re-show B"
TITLE_C = "Phase77 re-click C"
TITLE_D = "Phase77 refresh button D"
TITLE_E = "Phase77 popstate E"
# The four data views (task 02's refresh joiners): the nav link, the
# view's URL (the pushState target), and the list endpoint the view's
# load hits (the request-log assertion's target — the sync/upload
# pollers hit OTHER paths and only run while a job is in flight).
DATA_VIEWS: tuple[tuple[str, str, str], ...] = (
("#nav-history", "/history.html", "/api/chats"),
("#nav-sources", "/sources.html", "/api/docs"),
("#nav-git-sources", "/git-sources.html", "/api/git-sources"),
("#nav-tuning", "/tuning.html", "/api/steering"),
)
# ---------------------------------------------------------------------------
# DB reset + API helpers (the house pattern from test_nav_switch_keeps_stream)
# ---------------------------------------------------------------------------
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) -> ImportSummary:
"""House reset + the prompt-shaping tables: steering notes and the
KB overview would otherwise append deterministic suffixes to every
mock answer and break the exact-text assertions; saved_chats is
cleared too (each test creates its own rows via the API)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, "
"steering_notes, kb_overview, saved_chats"
)
)
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
"""The settled-row count over the whole (truncated) log — the
house pattern: a row finalizes ONLY when the LLM finished AND the
persistence succeeded, so 0 = cancelled, 1 = settled."""
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
the test's API side sees exactly what the signed-in browser sees."""
return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c}
def _create_chat(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]:
"""POST /api/chats (the public save surface) — the API-side
mutation the freshness proofs hinge on: the row is created while
the view is NOT showing, so only a re-fetch can surface it."""
r = httpx.post(
f"{app_url}/api/chats",
timeout=10,
cookies=cookies,
json={
"title": title,
"messages": [
{"who": "user", "text": f"Saved via the API for the phase-77 suite: {title}"},
{"who": "brain", "text": "Saved."},
],
},
)
assert r.status_code == 201, f"POST /api/chats for {title!r}: {r.status_code} {r.text}"
return r.json()
def _row(page: Page, title: str) -> Locator:
"""The saved chat's row (the Title cell's Open link text is the
title — textContent only, so an exact-text locator is stable)."""
return page.locator("#history-tbody a.history-title-link", has_text=title)
def _wait_until(page: Page, pred: Callable[[], bool], timeout: float = 15.0) -> None:
"""Poll ``pred`` until it holds — the tick is ``page.wait_for_timeout``
(NOT a raw ``time.sleep``): the sync API delivers ``page.on`` events
only while a Playwright call is in flight, so the request log the
predicate reads is drained on every tick (a raw sleep starves the
event queue)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pred():
return
page.wait_for_timeout(50)
raise AssertionError("timeout waiting for the condition")
def _get_logger(page: Page) -> list[str]:
"""Every GET the browser issues (the request-log pattern from task
02 — the re-fetch proof where a cheap row-delta is not available).
POST/PUT (auto-saves, form posts) are deliberately not logged: the
assertion is about the view's LIST fetch."""
seen: list[str] = []
def on_request(req: Request) -> None:
if req.method == "GET":
seen.append(req.url)
page.on("request", on_request)
return seen
# ---------------------------------------------------------------------------
# Shared stream helpers (mirrored from test_nav_switch_keeps_stream.py —
# the phase-76 suite must stay unchanged; this file is the story gate)
# ---------------------------------------------------------------------------
def _no_error_banner(page: Page) -> None:
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
def _ask_long(page: Page) -> None:
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
def _wait_streaming(page: Page, answer: Locator) -> Locator:
"""Wait until answer text is visibly streaming (a few delta frames
rendered — the mid-stream moment, well inside the ~8–9 s stream)."""
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the view switch"
# In flight at the switch: the button IS the enabled Stop control.
expect(page.locator("#send-label")).to_have_text("Stop")
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
return answer
def _wait_done(page: Page, answer: Locator) -> str:
"""Wait for the ``done`` settle: the Send button is back and the
bubble carries the unique final line — no error banner on the way."""
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000)
_no_error_banner(page)
return answer.inner_text()
def _assert_full_answer(text: str) -> None:
"""The bubble carries the FULL mock answer — every one of the 40
numbered steps plus the unique final line (a truncated stream
would be missing its tail)."""
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer"
assert LONG_ANSWER_END in text
# ---------------------------------------------------------------------------
# 1. THE L3 REPRO: a switch back onto History re-fetches — the new row
# appears (list replaced, no duplicates), same document throughout
# ---------------------------------------------------------------------------
def test_reshow_refetches_history(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""Sign in → create saved chat A via POST /api/chats → nav to
History (row A visible) → create chat B via the API while Chat is
up → nav to Chat → nav back to History: row B is visible (the
re-show re-fetched — before phase 77 the 10:00 list stayed), row A
appears EXACTLY ONCE (the re-load replaces the list, never
duplicates it), and the window sentinel set before the nav clicks
is still readable after (the phase-76 canonical no-document-load
proof)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-history")).to_be_visible()
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000)
# THE REPRO: B is created while History is NOT showing…
page.evaluate("() => { window.__shell_boot = 'phase77'; }")
_create_chat(app_url, cookies, TITLE_B)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# …and the switch back re-fetches: B is visible without a reload.
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000)
# Replaced list, never duplicated: each title EXACTLY ONCE.
expect(_row(page, TITLE_A)).to_have_count(1)
expect(_row(page, TITLE_B)).to_have_count(1)
# Same document through every click (a real navigation would wipe
# the window sentinel).
assert page.evaluate("() => window.__shell_boot") == "phase77", (
"a real navigation would have wiped the window sentinel — "
"the switches must be same-document"
)
# ---------------------------------------------------------------------------
# 2. A re-click of the ACTIVE view's own nav link re-fetches (no-op
# before phase 77) — NO pushState (the URL is already the path)
# ---------------------------------------------------------------------------
def test_active_view_reclick_refetches(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""With History visible (rows A, B): create C via the API; click
the History nav link AGAIN (the active view's own link) → C
appears. The URL is still /history.html and history.length is
UNCHANGED — the re-click dispatches the refresh event instead of
a bare return, and it must not pushState (the URL already IS this
view's path)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
_create_chat(app_url, cookies, TITLE_B)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000)
length_before = page.evaluate("() => window.history.length")
_create_chat(app_url, cookies, TITLE_C)
page.click("#nav-history") # re-click the ACTIVE link
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_C)).to_be_visible(timeout=15_000)
expect(_row(page, TITLE_A)).to_have_count(1)
length_after = page.evaluate("() => window.history.length")
assert length_after == length_before, (
"the active re-click must NOT pushState — the URL is already "
f"this view's path ({length_before} → {length_after})"
)
# ---------------------------------------------------------------------------
# 3. The explicit Refresh button (TODO.md L3's second half): the
# visible, keyboard-reachable control with its in-flight lifecycle
# ---------------------------------------------------------------------------
def test_history_refresh_button(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""The Refresh control (TODO.md L3: "The history page should also
have a refresh button."): visible for admin in the page-head,
keyboard-reachable (Tab-able, focusable, accessible name), and the
in-flight lifecycle — create D via the API; click #history-refresh
→ D appears, #history-status announces "Saved chats refreshed.",
the button is disabled DURING the in-flight request (asserted
deterministically: the request is held in the browser via
page.route — the house pattern from test_archive_upload_sources)
and re-enabled after."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
_create_chat(app_url, cookies, TITLE_A)
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000)
btn = page.locator("#history-refresh")
expect(btn).to_be_visible()
expect(btn).to_be_enabled()
# WCAG 2.1 AA basics: the accessible name (icon-only below 640px —
# the aria-label carries it in both) + keyboard-reachable.
assert btn.get_attribute("aria-label") == "Refresh saved chats"
btn.focus()
active = page.evaluate("() => document.activeElement && document.activeElement.id")
assert active == "history-refresh", "the Refresh button must be keyboard-focusable"
# Hold the refresh's GET in the browser so the in-flight state is
# observable deterministically instead of racing the fast
# localhost round-trip (the house pattern): the flag is armed only
# around the button's click.
hold = {"on": False}
def handle(route: Any) -> None:
if hold["on"]:
time.sleep(1.0)
route.continue_()
page.route("**/api/chats", handle)
_create_chat(app_url, cookies, TITLE_D)
hold["on"] = True
btn.click()
# In flight (§7.4): the button is disabled — no double-fire.
expect(btn).to_be_disabled()
hold["on"] = False
# Settled: D is visible (the re-fetch replaced the list), the live
# region carries the exact success line, the button is re-enabled.
expect(_row(page, TITLE_D)).to_be_visible(timeout=15_000)
expect(_row(page, TITLE_A)).to_have_count(1)
expect(page.locator("#history-status")).to_have_text("Saved chats refreshed.")
expect(btn).to_be_enabled()
# ---------------------------------------------------------------------------
# 4. Back/forward (popstate) onto an already-mounted view re-fetches
# ---------------------------------------------------------------------------
def test_popstate_refetches_history(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Chat → History (the mount's own load) → back to Chat → create E
via the API → ``page.go_back()`` (popstate onto the already-mounted
History view) → E is present: the popstate path flows through
switchTo, so it inherits the wasMounted gating — a re-show
dispatches bor:view-refresh, the first show never did (E was not
visible on the original show)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
page.click("#nav-history") # Chat → History (mount + first load)
expect(page).to_have_url(app_url + "/history.html")
expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000)
page.click('a.nav-link[href="/"]') # → Chat (pushState)
expect(page).to_have_url(app_url + "/")
_create_chat(app_url, cookies, TITLE_E)
page.go_back() # popstate → /history.html (the already-mounted view)
expect(page).to_have_url(app_url + "/history.html")
expect(page.locator("#view-history")).not_to_be_hidden()
expect(_row(page, TITLE_E)).to_be_visible(timeout=15_000)
# ---------------------------------------------------------------------------
# 5. All four data views re-fetch on a re-show (the request-log pattern
# from task 02 — one assertion per view)
# ---------------------------------------------------------------------------
def test_all_four_data_views_refetch_on_reshow(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""One assertion per data view (History, RAG, Sources, Tuning):
after the first show (the mount — its load is the baseline), a
switch back onto the view issues EXACTLY ONE more GET to the
view's list endpoint — the re-fetch the phase is about. The
windowed counts (before/after the re-show click) make the
assertion immune to the boot-time fetches (the header's steering
panel loads once at shell boot) and to the sync/upload pollers
(other paths, and only while a job is in flight). For History the
row-delta rides along: a chat created before the re-show appears
after it."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible() # admin: every link revealed
expect(page.locator("#nav-history")).to_be_visible()
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
cookies = _admin_cookies(page)
log = _get_logger(page)
def count(endpoint: str) -> int:
return sum(1 for u in log if u.endswith(endpoint))
for nav_sel, view_path, endpoint in DATA_VIEWS:
# First show (the mount) — wait until the mount's own load
# lands in the log (the baseline).
page.click(nav_sel)
expect(page).to_have_url(app_url + view_path)
_wait_until(page, lambda ep=endpoint: count(ep) >= 1)
before = count(endpoint)
# History: create the row-delta's backing data while hidden.
if endpoint == "/api/chats":
_create_chat(app_url, cookies, "Phase77 four-view row")
# Back to Chat, then a re-show of the view: the refresh event
# must issue exactly one more list GET.
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
page.click(nav_sel)
expect(page).to_have_url(app_url + view_path)
_wait_until(page, lambda b=before, ep=endpoint: count(ep) >= b + 1)
assert count(endpoint) == before + 1, (
f"{nav_sel}: a re-show must re-fetch exactly once "
f"({before} → {count(endpoint)} GETs to {endpoint})"
)
if endpoint == "/api/chats":
expect(_row(page, "Phase77 four-view row")).to_be_visible(timeout=15_000)
# ---------------------------------------------------------------------------
# 6. The phase-76 CONTRACT control: with the refresh hook in place, an
# in-flight stream still survives a mid-stream nav switch
# ---------------------------------------------------------------------------
def test_stream_survival_control(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
"""The phase-76 contract holds with the hook in place: send a
question (the mock LLM's ~8 s stream) → mid-stream nav to RAG (a
re-show-capable, LISTENING view — the hook is live) → back to Chat
→ the FULL answer completes and the turn settles (one query_log
row). The Chat view never listens for bor:view-refresh (the
negative unit pin) — its in-flight SSE reader and local
conversation persist through every switch. The unchanged
tests/e2e/test_nav_switch_keeps_stream.py run in isolation is the
additional control."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
# THE SWITCH (mid-stream): the window sentinel set BEFORE the click
# is still readable AFTER it — same document.
page.evaluate("() => { window.__shell_boot = 'phase77'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase77", (
"a real navigation would have wiped the window sentinel"
)
# The RAG view actually showed (the fixture docs' rows are listed)
# and the chat view is hidden (the stream fills it in the
# background — that persistence IS the phase-76 fix).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Stay on RAG while the stream keeps running, then return to Chat.
page.wait_for_timeout(2000)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
assert page.evaluate("() => window.__shell_boot") == "phase77"
# The answer COMPLETED — the FULL mock answer, no error banner —
# and the turn SETTLED (one query_log row; a cancelled turn would
# leave none).
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
assert _query_log_count() == 1, "the completed turn must finalize exactly one settled row"
# ---------------------------------------------------------------------------
# 7. Sanity: the API-side rows the suite creates carry the bor.chat.v1
# record shape (guards _create_chat against a schema drift that
# would silently change what History renders)
# ---------------------------------------------------------------------------
def test_api_created_chats_carry_the_bor_chat_v1_shape(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The suite's API-side mutation helper (POST /api/chats) produces
rows in the exact bor.chat.v1 record shape the History table and
the ?chat= restore path render: who/text messages, the message
count, and no stray keys (the schema's extra=forbid rejects them
at the boundary with a 422 — a 201 here proves the shape) — and
History renders the row (the Open link's text IS the title)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
cookies = _admin_cookies(page)
created = _create_chat(app_url, cookies, "Phase77 shape check")
r = httpx.get(f"{app_url}/api/chats/{created['id']}", timeout=10, cookies=cookies)
assert r.status_code == 200
body = r.json()
assert body["title"] == "Phase77 shape check"
assert [m["who"] for m in body["messages"]] == ["user", "brain"]
assert body["message_count"] == 2
# The bor.chat.v1 record shape: who/text present; only the schema's
# optional keys may accompany them (explicit nulls are preserved —
# the plain model_dump round-trips byte-identical).
allowed = {"who", "text", "sources", "deflected", "suggestions",
"thinking", "tools", "stopped"}
assert all({"who", "text"} <= set(m) <= allowed for m in body["messages"])
# The History row renders it (the Open link's text IS the title).
page.click("#nav-history")
expect(page).to_have_url(app_url + "/history.html")
expect(_row(page, "Phase77 shape check")).to_be_visible(timeout=15_000)