phase: 87_big_read_progress
Build and Push Containers / build-and-push-app (push) Successful in 2m48s
Build and Push Containers / build-and-push-db (push) Successful in 19s

All criteria verified — no defects found, nothing to fix. Final report:

**Phase 87 — big read progress: final verification pass (all tasks already complete in `complete/`)**
- Verified implementation vs. overview: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, single live `armToolLineClock` site, settle on thinking/retry/delta, `stopToolLineClock` in `setUiState`, visible `.typing-elapsed` (aria kept byte-identical), CSS AA pairing — all match design; `app/` byte-identical (0 changes)
- **Tests/lint (exact outcomes):**
  - `uv run pytest --cov=app --cov-report=term` → 1732 passed, coverage **99%** (>90% ✓)
  - `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` → **4 passed** (ticking suffix, visible hint, settle, no-timer restore)
  - `test_thinking_display.py` → 5 passed · `test_agent_document_tools.py` → 4 passed · `test_smoke.py` → 3 passed (all isolated)
  - 3 pinned frontend suites + new unit pins → 62 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors
- **Completion criteria:** E2E pins 1–4 ✓ · guard/state-machine byte-identical ✓ (diff is additive only) · diff scope limited to `app.js`, `styles.css`, 2 new test files, phase files; nothing in `app/` ✓
- **Notable:** no deviations; commit + `00_phase.md` move left to the harness per executor rules (task files already in `complete/`)
- **Next pending phase:** none — `todo/` contains only this phase (87 is the last)
This commit is contained in:
2026-09-08 05:51:23 -04:00
parent 0f6b9ff7e6
commit 7cfe58fb21
24 changed files with 1650 additions and 0 deletions
+695
View File
@@ -0,0 +1,695 @@
"""Phase 87 E2E (Playwright, mock-only): progress indication during a
deterministic big-read gap.
Source: ``TODO.md`` L5 — "Need indication that prompt processing is
happening during a big read, it can look frozen."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported (the tests skip)
— the deterministic gaps this story pins need the mock's "use your
tools" marker flow (``tests/e2e/mock_llm.py``) behind the phase-64
delay-injecting reverse proxy (``tests/e2e/slow_llm.py``).
**Mechanism** — the mock's deterministic single-read tool flow, with
every inter-frame gap made deterministic at ≥6 s by the slow proxy
(``SLOW_LLM_DELAY_S = 6.0`` — this suite passes its OWN value: the
phase-64 default 0.15 is far below the 5 s tool-line threshold):
NOTE the prelude: the chat flow's QUESTION EMBEDDING also travels
through the proxy (one 6 s sleep) before the agent loop starts, so
the wall-clock timeline of one turn is:
1. t≈12 s — the first SSE ``tool`` frame ("🔎 Listing documents"): the
6 s embedding sleep + request 1's 6 s sleep (``tools`` offered, no
tool results yet — streams ONLY the ``ls`` ``tool_calls`` delta,
id ``call_0``) + the mock's ~0.2 s tool-call stream;
2. t≈19 s — the second ``tool`` frame ("📄 Reading
Deployments/example-record-file.json"): request 2's 6 s sleep (a
``tool``-role catalog result → streams a ``read`` ``tool_calls``
delta on the JOINED combined ``source/path`` of the FIRST catalog
line, id ``call_1``);
3. t≈25 s — the content answer ``Read <source/path>. <first 80 chars
of the read document's content>``: request 3's 6 s sleep (a
``tool``-role read result) → the first answer ``delta``.
So each post-tool-frame gap (≈6.3 s — the 6 s sleep + the short
tool-call stream) is PAST the 5 s tool-line threshold
(``TOOL_LINE_ELAPSED_AFTER_MS = 5_000``), and the 10 s pre-token
typing hint (the existing ``startThinkingClock`` gate, ticking from
t≈0) is visible throughout both post-tool gaps — everything before the
answer's first delta (the whole turn runs ≈25 s + overhead, acceptable
for an isolated story suite). The four tests pin: (1) the latest tool
line's ticking "(Ns)" suffix, (2) the typing indicator's visible "Ns"
hint (+ the kept aria channel), (3) BOTH settling the instant the
answer arrives, (4) a RELOAD restoring the tool lines with NO timer
(A6 — the restore path never arms the clock).
**Aria-channel note (test 2):** the tool-frame branch overwrites the
typing bubble's ``aria-label`` with its status copy ("… is listing
documents" at ≈12 s, "… is reading …" at ≈19 s); the 1 s thinking
clock RE-SETS it to "… is still thinking (Ns)" on every tick (the same
task that writes the visible span) — so the two channels read
"still thinking" SIMULTANEOUSLY right after each tick, and test 2
polls until they align (the deterministic same-moment pin).
**KB fixture** — byte-identical to the phase-37 suite
(``tests/e2e/test_agent_document_tools.py``): ``Homelab/aws-route53.md``
carries one chunk embedded with the mock's own bag-of-words vector —
the marker question (phase 37's exact question, which carries the
trigger phrase) cosines ≈0.69 against it, well past the E2E 0.30
threshold, so the turn is grounded and the HIGH prompt carries the
``<tools>`` section; ``Deployments/example-record-file.json`` is
indexed WITHOUT chunks, and its ``(source, path)`` sorts FIRST in the
catalog (``Deployments`` < ``Homelab``) — exactly the line the mock's
second request reads, so the answer is byte-stable.
Test → story mapping (Playwright Mapping Rule):
1. ``test_tool_line_shows_ticking_elapsed``
2. ``test_typing_indicator_shows_visible_elapsed``
3. ``test_indicators_settle_when_the_answer_arrives``
4. ``test_restored_turn_has_no_timer``
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import Settings as _Settings
from app.db import SessionLocal
from app.models import Chunk, Document
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from tests.e2e.mock_llm import embed_text
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_BIGREAD", "8128"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: This suite's slow-LLM proxy port (the conftest's mock LLM stays on
#: MOCK_PORT; the phase-64 suite owns 8902, so the ports follow the
#: same allocation rule: one port pair per module app).
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_BIGREAD", "8903"))
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
#: Per-LLM-request delay on the proxy — this suite's timing fixture.
#: 6.0 s puts every inter-frame gap past the 5 s tool-line threshold
#: and keeps the whole turn pre-token until the first delta (the 10 s
#: typing hint ticks across the prelude AND both post-tool gaps); the
#: whole turn then runs ≈25 s + overhead — acceptable for an isolated
#: story suite (the phase-64 suite has the same shape, at 0.15 s).
SLOW_DELAY_S = "6.0"
# --------------------------------------------------------------------------
# KB fixture — byte-identical to the phase-37 suite (see the module
# docstring)
# --------------------------------------------------------------------------
SEED_SOURCE = "Homelab"
SEED_PATH = "aws-route53.md"
READ_SOURCE = "Deployments"
READ_PATH = "example-record-file.json"
READ_SP = f"{READ_SOURCE}/{READ_PATH}"
#: The retrievable document: references the JSON file "for the exact JSON
#: shape of reeselink.json" but never includes it. The repeated
#: record-file lines carry the marker question's key tokens (aws,
#: route53, hosted, zone, reeselink, json, exact, shape) — verified
#: ≈0.69 cosine against the mock's embeddings (E2E threshold 0.30) plus
#: FTS hits, so the turn is solidly grounded (HIGH prompt → <tools>).
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"
)
#: The referenced document: the exact JSON shape. Its FIRST line is longer
#: than 80 chars, so the mock's first-80-chars quote is newline-free (the
#: rendered-text assertions below match it verbatim). Pinned by the
#: assert below (phase 37's pin, kept).
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"
)
assert "\n" not in RECORD_FILE_CONTENT[:80] # the quote must stay one line
#: Phase 37's exact marker question — carries the mock trigger phrase
#: "use your tools" (case-insensitive ``TOOLS_TRIGGER``) and is grounded
#: against the byte-identical seed above, so the deterministic single-
#: read tool flow runs.
MARKER_QUESTION = (
"Use your tools: what is the exact JSON shape of reeselink.json "
"for my aws route53 hosted zone?"
)
#: The mock's deterministic answer for the single-read flow (the
#: phase-37 shapes, kept): "Read <source/path>. <first 80 chars>".
ANSWER_PREFIX = f"Read {READ_SP}."
ANSWER_QUOTE = RECORD_FILE_CONTENT[:80]
# --------------------------------------------------------------------------
# Timeouts — every wait carries ≥2× headroom on its expected duration
# (the flakiness discipline: the timing assertions themselves are
# strictly-increasing, never exact-value)
# --------------------------------------------------------------------------
#: The first ``tool`` frame lands at ≈12.4 s (the 6 s embedding sleep
#: + request 1's 6 s proxy sleep + the mock's short tool-call stream)
#: — 1.6× headroom.
TOOL_LINE_TIMEOUT_MS = 20_000
#: The "(Ns)" suffix appears 5 s after the line's OWN arm (1 s tick
#: granularity) — the task's generous 12 s, measured from the line's
#: appearance: 2.4× the threshold.
SUFFIX_TIMEOUT_MS = 12_000
#: The visible typing hint first ticks at 10 s from submit, but this
#: suite only starts checking it AFTER the first tool line (≈12.4 s)
#: — the first tick where the visible hint and the aria channel align
#: is ≈13 s (the next tick after the ls frame's aria overwrite), and
#: the indicator is removed at the first delta (≈25 s): 20 s from the
#: check start cannot flake.
TYPING_HINT_TIMEOUT_MS = 20_000
#: The answer settles at ≈19 s from submit; every settle wait here starts
#: ≥12 s in — 30 s keeps ≥2× headroom on the remaining window.
SETTLE_TIMEOUT_MS = 30_000
#: The restore is a synchronous boot re-render — 15 s is ample.
RESTORE_TIMEOUT_MS = 15_000
#: The tool-line suffix format: parenthesized "(Ns)" (the task's pin).
ELAPSED_PAT = r"\((\d+)s\)"
#: The typing hint format: bare "Ns" (task 01's pin).
TYPING_ELAPSED_PAT = r"(\d+)s"
#: The two-sample spacing the task pins (≥1.5 s apart).
SAMPLE_GAP_S = 1.5
# ---------------------------------------------------------------------------
# Fixtures (the phase-64 pattern — module app behind the slow proxy; the
# conftest already spawns the mock LLM)
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _mock_only() -> None:
"""The deterministic gaps need the mock's marker flow — skip
(rather than fail) when pointed at the real LLM."""
if USE_REAL_LLM:
pytest.skip(
"mock-only suite: the deterministic big-read gap needs the "
"mock's 'use your tools' flow behind the slow proxy"
)
def _wait_slow_http(url: str, delay_s: float, timeout: float = 60.0) -> None:
"""Readiness probe for the slow proxy — the conftest's
``_wait_http`` pattern with one adaptation forced by THIS suite's
delay: the house helper gives each probe attempt a 2.0 s client
timeout, but a request to this proxy takes ≥ ``SLOW_LLM_DELAY_S``
(6.0 s — it sleeps per request), so with the house helper NO probe
request ever completes and the 40 s deadline expires (the phase-64
suite never hit this: its 0.15 s delay is under 2 s). Each attempt
here gets ``delay + 5 s``; the deadline loop, 0.5 s cadence, and
failure behavior are the conftest pattern verbatim."""
deadline = time.monotonic() + timeout
last_err = "unknown"
while time.monotonic() < deadline:
try:
httpx.get(url, timeout=delay_s + 5.0)
return
except Exception as e: # noqa: BLE001 — retry until deadline
last_err = str(e)
time.sleep(0.5)
raise RuntimeError(f"server at {url} did not come up: {last_err}")
@pytest.fixture(scope="module")
def slow_llm(mock_llm: int) -> Iterator[int]:
"""The delay-injecting reverse proxy in front of the mock LLM
(``tests/e2e/slow_llm.py``) — this suite's timing fixture: the
per-request ``SLOW_DELAY_S`` makes every inter-frame gap
deterministic (see the module docstring). Mirrors the phase-64
fixture (process handling unchanged; only the readiness probe
adapts — see ``_wait_slow_http``)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_slow_http(f"{SLOW_URL}/v1/models", float(SLOW_DELAY_S))
yield SLOW_PORT
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_server(mock_llm: int, slow_llm: int) -> Iterator[str]:
"""The real app under test — the conftest's session-app env shape
(including the phase-61/62 leak guards) with one swap: the LLM base
URL is the SLOW PROXY in front of the mock (the timing fixture)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"{SLOW_URL}/v1"
)
# The E2E mock's token-overlap embeddings have their own score
# distribution (phase 09) — the mock-calibrated threshold so the
# marker question stays grounded (conftest pattern).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
# Phase 67: instant retry waits (no retry fires in this suite — the
# conftest env shape, kept).
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# Phase 61/62 leak guards (the conftest pattern): force the code
# defaults so an operator's local (gitignored) ``.env`` cannot leak
# into the app under test.
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
_Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_THEME"] = _Settings.model_fields["theme"].default
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB seeding (phase-37's TRUNCATE-then-seed pattern)
# ---------------------------------------------------------------------------
def _seed(db: Session) -> None:
"""The phase-37 two-document pair, byte-identical (see the module
docstring)."""
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),
)
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),
)
)
# The referenced JSON: indexed, catalogued, readable — but NO chunks,
# so retrieval never puts it in context; its (source, path) sorts
# FIRST in the catalog — the line the mock reads.
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),
)
)
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 (phase 37; ``saved_chats`` added since phase 55, when
every send auto-saves — a leftover row would pollute the
saved-chats suites on the shared DB)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, "
"steering_notes, kb_overview, saved_chats"
)
)
db.commit()
if seed is not None:
seed(db)
db.commit()
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _submit_tools_turn(page: Page, app_url: str) -> None:
"""Type the marker trigger and submit, then wait for the FIRST
``.tool-call`` line (the "Listing documents" line — the mock flow's
first call) to appear in ``#messages``.
Auth: phase 37's agent-tools E2E logs in as admin — mirrored
exactly (the mock flow + proxy are identical either way; the
house pattern for this flow wins)."""
login(page, app_url, next="/")
page.fill("#message-input", MARKER_QUESTION)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(MARKER_QUESTION)
# The first tool frame lands at ≈6 s (the proxy's per-request
# sleep) — generous timeout (≥2× headroom).
expect(page.locator("#messages .tool-call").first).to_be_visible(
timeout=TOOL_LINE_TIMEOUT_MS
)
def _elapsed_value(raw: str | None, pattern: str) -> int:
"""Parse the integer seconds out of one indicator text sample
(fail loud when the sample does not carry the pinned shape)."""
match = re.search(pattern, raw or "")
assert match is not None, f"no value matching {pattern} in {raw!r}"
return int(match.group(1))
def _sample_twice(page: Page, selector: str, pattern: str) -> tuple[int, int]:
"""Two text samples of the FIRST element matching *selector*,
≥``SAMPLE_GAP_S`` (1.5 s) apart — the "the clock ticks while the
gap holds" check. The strictly-increasing assert stays in the
test."""
first = page.locator(selector).first
v1 = _elapsed_value(first.text_content(), pattern)
page.wait_for_timeout(int(SAMPLE_GAP_S * 1000))
v2 = _elapsed_value(first.text_content(), pattern)
return v1, v2
def _wait_visible_hint_and_aria(page: Page, min_value: int, timeout_ms: int) -> int:
"""Poll until BOTH: the typing indicator's visible hint value is
≥ *min_value* AND the bubble's ``aria-label`` carries "still
thinking" — and return that value.
Why the combined poll (the deterministic same-moment pin): the
tool-frame branch overwrites the bubble's ``aria-label`` with its
status copy ("… is listing documents" when the ls frame lands at
≈12 s, "… is reading …" at ≈19 s), while the 1 s thinking clock
re-sets it to "… is still thinking (Ns)" on EVERY tick — the same
task that writes the visible ``.typing-elapsed`` span (app.js
``startThinkingClock``: the ``setAttribute`` precedes the span
write in one JS task). The two channels therefore read "still
thinking" simultaneously right after each tick, and a check that
starts in a tool-frame's overwrite window (this suite checks only
after the first tool line) must wait for the next tick — exactly
the house polling-until pattern (cf. the phase-44 flake fix for
transient windows)."""
deadline = time.monotonic() + timeout_ms / 1000
last: tuple[int | None, str] = (None, "")
while time.monotonic() < deadline:
el = page.locator("#typing-indicator .typing-elapsed")
if el.count() >= 1:
value = _elapsed_value(el.first.text_content(), TYPING_ELAPSED_PAT)
aria = (
page.locator("#typing-indicator .bubble").get_attribute("aria-label")
or ""
)
last = (value, aria)
if value >= min_value and "still thinking" in aria:
return value
time.sleep(0.25)
raise AssertionError(
f"the visible hint (≥{min_value}s) and the 'still thinking' aria "
f"never aligned (last: value={last[0]} aria={last[1]!r})"
)
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button
recovered (the phase-37/48 pattern — the in-flight button is the
enabled Stop control, so the label assertion carries the settle
wait with an explicit timeout)."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text(
"", timeout=SETTLE_TIMEOUT_MS
)
expect(page.locator("#send-btn")).to_be_enabled(timeout=SETTLE_TIMEOUT_MS)
expect(page.locator("#send-label")).to_have_text("Send", timeout=SETTLE_TIMEOUT_MS)
#: The FIRST tool line's suffix, pinned to the line ("first .tool-call
#: child of the .tool-calls container" — the "Listing documents" line;
#: the read frame's second line arms its OWN clock 5 s later and is
#: never the target here).
FIRST_LINE_SUFFIX = "#messages .tool-calls .tool-call:first-child .tool-elapsed"
# ---------------------------------------------------------------------------
# 1. The latest tool line grows a ticking "(Ns)" suffix during the gap
# ---------------------------------------------------------------------------
def test_tool_line_shows_ticking_elapsed(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The owner's "it can look frozen" (sighted channel): after 5 s of
frame silence the latest tool line shows a ticking "(Ns)" suffix.
The first line's ("Listing documents") suffix is sampled twice ≥1.5
s apart: the second value is STRICTLY greater — the clock ticks
while the gap holds (the next frame is ≥6 s away, past the 5 s
threshold). The turn is still in flight at both samples: the button
is the enabled "Stop" control (the phase-48 contract)."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
# The suffix appears 5 s after the line's own arm (1 s tick
# granularity) — the generous 12 s timeout keeps this unflaky.
expect(page.locator(FIRST_LINE_SUFFIX)).to_be_visible(timeout=SUFFIX_TIMEOUT_MS)
v1, v2 = _sample_twice(page, FIRST_LINE_SUFFIX, ELAPSED_PAT)
assert v2 > v1, f"the tool-line suffix did not tick: ({v1}s) -> ({v2}s)"
# Still in flight (a cheap invariant): the enabled Stop control.
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# ---------------------------------------------------------------------------
# 2. The typing indicator shows the visible "Ns" hint (kept aria channel)
# ---------------------------------------------------------------------------
def test_typing_indicator_shows_visible_elapsed(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The 10 s pre-token clock promoted from aria-only to sighted
users: during the post-tool-frame gap (no thinking/delta frame has
arrived in the mock flow) the typing indicator shows a VISIBLE
ticking "Ns" hint. Poll until the visible value (≥10 — the gate)
and the bubble's aria-label (the kept "still thinking" sentence —
the screen-reader channel the unit pins protect) read together at
the same moment; a second value sample ≥1.5 s later is strictly
greater (the clock ticks while the gap holds — the next frame is
≥6 s away)."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
# The hint first ticks at 10 s from submit (1 s granularity), but
# the check starts after the first tool line (≈12.4 s) — the ls
# frame has just overwritten the bubble's aria-label with its
# status copy, so wait for the NEXT tick, where the visible value
# and the aria channel align (same JS task — see the helper).
v1 = _wait_visible_hint_and_aria(page, 10, TYPING_HINT_TIMEOUT_MS)
assert v1 >= 10, f"the hint appeared below the 10 s gate: {v1}s"
# The clock ticks while the gap holds: ≥1.5 s later the value is
# strictly greater (the read frame is ≥6 s away; the first delta
# — which removes the whole indicator — is ≈25 s from submit).
page.wait_for_timeout(int(SAMPLE_GAP_S * 1000))
v2 = _elapsed_value(
page.locator("#typing-indicator .typing-elapsed").first.text_content(),
TYPING_ELAPSED_PAT,
)
assert v2 > v1, f"the typing hint did not tick: {v1}s -> {v2}s"
# ---------------------------------------------------------------------------
# 3. On the answer's arrival BOTH indications settle (no stale timers)
# ---------------------------------------------------------------------------
def test_indicators_settle_when_the_answer_arrives(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Settle contract: once the answer arrives the ``#typing-indicator``
is removed (the state machine owns it) and NO ``.tool-elapsed``
element exists anywhere in ``#messages`` (the delta frame's
settle removed them — both lines' worth); 1.5 s later still none
(the turn is over — the transition stopped the clock, no
re-appearance). The tool lines themselves remain, byte-identical
text — the permanent record (phase 37's pin), and the answer bubble
is complete."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
# First the ticking state (the test-1 wait, reused)…
expect(page.locator(FIRST_LINE_SUFFIX)).to_be_visible(timeout=SUFFIX_TIMEOUT_MS)
# …then the answer arrives (≈25 s from submit — see the module
# docstring's timeline) and settles.
_wait_settled(page)
# Both indications are gone — the typing indicator removed by the
# first delta's setUiState(streaming), the suffixes by the delta
# frame's settle (the settle contract).
expect(page.locator("#typing-indicator")).to_have_count(0)
expect(page.locator("#messages .tool-elapsed")).to_have_count(0)
# No re-appearance 1.5 s later — the turn is over, the clock is
# stopped (settle cleared the interval, the transition nulled the
# wrap — no residue).
page.wait_for_timeout(int(SAMPLE_GAP_S * 1000))
expect(page.locator("#messages .tool-elapsed")).to_have_count(0)
expect(page.locator("#typing-indicator")).to_have_count(0)
# The tool lines remain — the permanent record, both present with
# their pinned text (phase 37's pattern).
lines = page.locator("#messages .tool-call")
expect(lines).to_have_count(2)
expect(lines.nth(0)).to_contain_text("Listing documents")
expect(lines.nth(1)).to_contain_text("Reading ")
expect(lines.nth(1)).to_contain_text(READ_SP)
# The answer bubble is complete (the mock's deterministic quote).
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(ANSWER_PREFIX)
expect(bubble).to_contain_text(ANSWER_QUOTE)
# ---------------------------------------------------------------------------
# 4. A reload restores the tool lines WITHOUT any timer (A6)
# ---------------------------------------------------------------------------
def test_restored_turn_has_no_timer(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""A6 end-to-end: after the turn completes (the auto-save persisted
it — the local ``bor.chat.v1`` record is written synchronously at
the save points, so the reload sees the full conversation), a
RELOAD in the same context (phase-14 local session) re-renders the
persisted tool lines through the SAME ``appendToolLine`` — and they
carry NO ``.tool-elapsed`` element (the restore path never arms the
clock — the live-only contract), with the page otherwise settled:
no ``#typing-indicator``, button "Send", answer intact."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
# Let it COMPLETE (answer rendered — the phase-50/55 auto-save
# contract).
_wait_settled(page)
# Reload — the same context keeps bor.chat.v1 (the phase-14
# restore path).
page.reload()
expect(page.locator("#empty-state")).to_be_hidden(timeout=RESTORE_TIMEOUT_MS)
# The persisted record re-renders the tool lines, in saved order
# (the phase-37 reload pin, mirrored)…
restored = page.locator("#messages .tool-call")
expect(restored).to_have_count(2, timeout=RESTORE_TIMEOUT_MS)
expect(restored.nth(0)).to_contain_text("Listing documents")
expect(restored.nth(1)).to_contain_text("Reading ")
expect(restored.nth(1)).to_contain_text(READ_SP)
# …with NO timer (A6 — the restore never arms the clock) and the
# page otherwise settled.
expect(page.locator("#messages .tool-elapsed")).to_have_count(0)
expect(page.locator("#typing-indicator")).to_have_count(0)
expect(page.locator("#send-label")).to_have_text("Send")
# The answer is intact (phase-14 restore path).
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(ANSWER_PREFIX)
expect(bubble).to_contain_text(ANSWER_QUOTE)
+333
View File
@@ -0,0 +1,333 @@
"""Unit: the phase-87 big-read progress contract (source-level pins).
Phase 87 (TODO.md L5 — "Need indication that prompt processing is
happening during a big read, it can look frozen."): during any
frameless gap of an in-flight turn the UI must show, to sighted AND
screen-reader users, that processing is ongoing — a ticking
elapsed-seconds suffix on the latest tool line (after 5s of silence)
and a VISIBLE elapsed hint on the typing indicator (the existing 10s
pre-token clock promoted from aria-only to visible text). Both settle
the instant content resumes; persisted/restored turns never show
timers (A6 — the arming call lives only in the live frame branches).
Locked decisions (phase overview): frontend-only (A4 — the server, the
SSE event set, and the 120s guard are byte-identical), named
thresholds owned by the state machine (A5 — ``TOOL_LINE_ELAPSED_AFTER_MS``),
and the exact tool-line template literals stay byte-identical (the
suffix is a separate element the clock appends; ``appendToolLine``
renders exactly as before, which is also what makes A6 fall out for
free on restore).
This module reads ``frontend/assets/app.js`` + ``styles.css`` as text
(no browser — house pattern, cf. test_frontend_feedback.py); the live
behavior is E2E-gated by ``tests/e2e/test_big_read_progress.py``
(task 03). Task 01 pins the visible typing-indicator hint below;
task 02 extends the module with the tool-line clock pins.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
# ---------- task 01: the visible typing-indicator elapsed hint ----------
def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> None:
"""Phase 87 task 01: after 10s of pre-token silence the typing
bubble gains a visible ``.typing-elapsed`` "Ns" hint — ensured
(created at most once, appended as the bubble's LAST child, after
the three dot spans) and written with ``textContent`` only. No
``innerHTML`` anywhere on the typing bubble: the ``addTyping``
template writes ``wrap.innerHTML`` (the static skeleton), and the
clock must never rewrite turn data into the DOM as HTML."""
js = _js()
assert ".typing-elapsed" in js, "the hint class must exist in app.js"
# The ensure pattern: find-or-create, then append to the bubble.
assert 'bubble.querySelector(".typing-elapsed")' in js
assert 'el.className = "typing-elapsed"' in js
assert "document.createElement(\"span\")" in js
assert "bubble.appendChild(el)" in js, "the hint is the bubble's last child"
# textContent-only write (the bubble is role="status" — announced).
assert 'el.textContent = secs + "s"' in js, (
"the hint text is the plain ticking \"Ns\" (A5 — no added wording)"
)
# No innerHTML on the typing bubble — the skeleton template
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
# write, and it must not gain a second one.
assert js.count("bubble.innerHTML") == 0, (
"the clock must never assign innerHTML to the typing bubble"
)
def test_typing_elapsed_hint_lives_in_start_thinking_clock() -> None:
"""The hint belongs to the EXISTING 10s pre-token clock (A5 — the
typing hint reuses its ``secs < 10`` gate): the ensure/write logic
sits inside ``startThinkingClock``'s 1s interval, right after the
aria-label update. ``addTyping``/``removeTyping`` stay untouched —
the hint lives and dies with the indicator the state machine owns."""
js = _js()
fn = js.find("function startThinkingClock")
assert fn != -1, "startThinkingClock must exist"
body = js[fn : js.find("\n}\n", fn)]
assert ".typing-elapsed" in body, "the hint is ensured inside the clock"
assert 'el.textContent = secs + "s"' in body
# The 10s gate is kept (aria + visible share it).
assert "secs < 10" in body
# addTyping/removeTyping never touch the hint (state-machine-owned).
for name in ("function addTyping", "function removeTyping"):
f = js.find(name)
assert f != -1, f"{name} must exist"
b = js[f : js.find("\n}\n", f)]
assert ".typing-elapsed" not in b, f"{name} must stay untouched"
def test_typing_aria_label_contract_stays_byte_identical() -> None:
"""The pinned aria channel survives the promotion to visible text —
both users, same clock (mirrors the phase-39 brand pin so this
module is self-documenting): the exact template literal, built at
call time via brand(), after the 10s gate."""
js = _js()
assert "`${brand()} is still thinking (${secs}s)`" in js
assert "secs < 10" in js, "the hint must only appear after 10s of silence"
assert "role=\"status\"" in js, "the typing bubble stays role=status"
def test_typing_elapsed_css_rule_is_the_aa_pairing() -> None:
"""The ``.typing-elapsed`` rule: small mono in ink-soft on the
bubble's --surface (the documented ≥4.5:1 AA pairing), the same
language as every status line. Plain text — ``animation: none`` +
no background, and the dot-geometry reset (the span is a sibling of
the dots inside the .typing bubble, so without the reset it would
render as an 8px bouncing dot, not a hint)."""
css = _css()
block = re.search(r"\.typing-elapsed \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .typing-elapsed"
body = block.group(1)
for prop in (
"font-family: var(--mono)",
"font-size: 0.75rem",
"color: var(--ink-soft)",
"margin-left: 0.5rem",
# the dot-geometry reset (see the rule's comment):
"width: auto",
"background: none",
"opacity: 1",
"animation: none",
):
assert prop in body, f".typing-elapsed must keep {prop}"
# Reduced motion: the hint is plain text, not motion — it keeps full
# opacity (the AA pairing) while the dots calm to 0.7.
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
)
assert any(".typing span.typing-elapsed" in b and "opacity: 1" in b for b in blocks), (
"the hint must keep full contrast under reduced motion"
)
# The existing reduced-motion dot fallback is untouched (phase 06 pin).
assert any(".typing span" in b and "animation: none" in b for b in blocks)
# ---------- task 02: the per-tool-line elapsed clock ----------
def test_tool_line_elapsed_constant_is_named_and_five_seconds() -> None:
"""A5: the visible "processing" threshold is the NAMED module constant
``TOOL_LINE_ELAPSED_AFTER_MS = 5_000`` (below it a frameless gap reads
as normal latency; at/above it the latest line proves it is still
processing) — a pinned constant, not a magic number in the tick."""
js = _js()
assert re.search(r"TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000", js), (
"the 5s threshold must be the named constant TOOL_LINE_ELAPSED_AFTER_MS"
)
def test_tool_line_clock_state_is_turn_scoped_module_state() -> None:
"""One clock per turn: the three state vars live at module scope next
to the existing ``thinkingClock`` / ``turnTimeout`` state (re-armed
per `tool` frame, so each line counts its OWN silence)."""
js = _js()
for decl in (
"let toolLineTimer = 0",
"let toolLineStart = 0",
"let toolLineWrap = null",
):
assert decl in js, f"{decl} must be module-scope state"
anchor = js.find("let turnTimeoutCb = null")
assert anchor != -1, "the existing timer state must exist"
assert (
js.find("let toolLineTimer = 0") - anchor < 1500
), "the clock state sits next to the existing timer state"
def test_arm_has_exactly_one_live_call_site() -> None:
"""A6 (live-only): ``armToolLineClock(`` appears EXACTLY twice in
app.js — the definition + the single live call site in the `tool`
frame branch (right after the line's append). The restore path
(phase 14, ``renderStoredMessage``) never arms: a restored line reads
exactly as it did pre-phase (the permanent record, no stale timer)."""
js = _js()
assert js.count("armToolLineClock(") == 2, (
"the arm must be the definition + exactly one live call site"
)
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
append = branch.find("appendToolLine(wrap, name, argument)")
arm = branch.find("armToolLineClock(wrap)")
assert -1 < append < arm, (
"the arm follows the line's append — the baseline resets per line"
)
restore_fn = js.find("function renderStoredMessage")
restore_end = js.find("function restoreConversation")
assert -1 < restore_fn < restore_end
assert "armToolLineClock" not in js[restore_fn:restore_end], (
"the restore path must never arm the clock (A6)"
)
def test_settle_covers_the_three_live_frame_branches() -> None:
"""Settle = REMOVE the suffix: the `thinking`, `retry`, and `delta`
branches each call ``settleToolLine()`` at the TOP of the branch
(a frame arrived — the line is no longer "processing"), and the
settle clears the interval + removes every ``.tool-elapsed`` from the
wrap. Definition + three branches → at least 4 occurrences."""
js = _js()
assert js.count("settleToolLine(") >= 4, (
"the definition + the three frame branches must settle"
)
for branch_open, branch_close, first_work in (
('ev.type === "thinking"', 'ev.type === "tool"', "thinkingAcc += ev.text"),
('ev.type === "retry"', 'ev.type === "delta"', "const attempt"),
('ev.type === "delta"', 'ev.type === "done"', "acc += ev.text"),
):
start = js.find(branch_open)
end = js.find(branch_close, start)
assert -1 < start < end, f"the {branch_open!r} branch must exist"
seg = js[start:end]
assert "settleToolLine();" in seg, f"the {branch_open!r} branch must settle"
assert seg.index("settleToolLine();") < seg.index(first_work), (
f"the settle sits at the TOP of the {branch_open!r} branch, before its "
"content work (the line is no longer 'processing' the instant the "
"frame arrives)"
)
fn = js.find("function settleToolLine")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert "clearInterval(toolLineTimer)" in body, "the settle drops the interval"
assert 'querySelectorAll?.(".tool-elapsed")' in body and "el.remove()" in body, (
"the settle REMOVES every suffix (a frozen timestamp is noise)"
)
def test_stop_lives_in_setui_state_next_to_the_other_stops() -> None:
"""House invariant — "a stuck button is impossible" applied to a
stuck timer: ``stopToolLineClock()`` is called inside
``setUiState`` so EVERY transition stops/clears the clock. Documented
pin: the call sits in the function body AFTER the
``stopThinkingClock();`` line and BEFORE the first button write
(``sendBtn.disabled``) — stable under comment churn, and it forces
the call into the timer-clear block rather than a later branch.
``stopToolLineClock`` itself settles + forgets the wrap (no residue
across turns)."""
js = _js()
fn = js.find("export function setUiState")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert "stopToolLineClock();" in body, "setUiState must stop the clock"
assert (
body.index("stopThinkingClock();")
< body.index("stopToolLineClock();")
< body.index("sendBtn.disabled")
), (
"the stop sits in setUiState's clear block (after stopThinkingClock, "
"before the button writes)"
)
sf = js.find("function stopToolLineClock")
assert sf != -1
sbody = js[sf : js.find("\n}\n", sf)]
assert "settleToolLine();" in sbody, "stop settles (clear interval + remove suffixes)"
assert "toolLineWrap = null" in sbody, "stop forgets the wrap — no residue"
def test_suffix_is_textcontent_only_on_the_latest_line() -> None:
"""The suffix is a parenthesized "(Ns)" status suffix (e.g. "📄
Reading src/app.py (12s)" — the typing hint stays bare "Ns") written
with ``textContent`` on a ``createElement`` span — never innerHTML —
appended as a SIBLING after the line's existing children (the pinned
template text + the <code> argument). It targets the LATEST line only
(`.tool-call:last-child` — older lines keep their permanent record),
and a missing container (New-Chat click mid-gap) makes the tick a
no-op (the null-safe chain is the guard)."""
js = _js()
fn = js.find("function armToolLineClock")
assert fn != -1, "armToolLineClock must exist"
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector?.(".tool-calls .tool-call:last-child")' in body, (
"the suffix targets the LATEST line only"
)
assert 'line.querySelector(".tool-elapsed")' in body, "find-or-create the suffix span"
assert 'el.className = "tool-elapsed"' in body
assert 'document.createElement("span")' in body, "the span is createElement'd"
assert "line.appendChild(el)" in body, (
"the suffix is a SIBLING appended AFTER the line's existing children"
)
assert "`(${secs}s)`" in body, ("the parenthesized '(Ns)' suffix, textContent-built")
assert "innerHTML" not in body, "no HTML write in the clock — textContent only"
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body, "the tick gates on the named constant"
assert "toolLineWrap?.querySelector?" in body, (
"null-safe: a wrap reset mid-gap (New Chat) makes the tick a no-op"
)
def test_tool_line_template_literals_stay_byte_identical() -> None:
"""The exact ``line.textContent = "…"`` template literals survive —
mirrors test_frontend_tool_states.py so this module is
self-documenting: the suffix is a separate element the clock appends,
and a rewrite of the line text would break the emoji-guard strip set
(it strips precisely those) plus the phase-37 pins. The helper stays
clock-free — that is also what makes A6 fall out for free on restore."""
js = _js()
fn = js.find("function appendToolLine")
assert fn != -1, "appendToolLine must exist"
body = js[fn : js.find("\n}\n", fn)]
for lit in (
'line.textContent = "📄 Reading "',
'line.textContent = "🔎 Searching for "',
'line.textContent = "🔎 Listing documents in "',
'line.textContent = "🔎 Listing documents"',
):
assert lit in body, f"the pinned tool-line literal {lit!r} must stay byte-identical"
assert "tool-elapsed" not in body, "appendToolLine stays clock-free (A6)"
assert "armToolLineClock" not in body
def test_tool_elapsed_css_rule_is_the_aa_pairing() -> None:
"""The ``.tool-elapsed`` rule (next to the .tool-call rules): small
mono in ink-soft — the same AA pairing as the task-01 hint — with
``white-space: nowrap`` so "(12s)" never wraps in the row."""
css = _css()
block = re.search(r"\.tool-elapsed \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .tool-elapsed"
body = block.group(1)
for prop in (
"font-family: var(--mono)",
"font-size: 0.75rem",
"color: var(--ink-soft)",
"margin-left: 0.5rem",
"white-space: nowrap",
):
assert prop in body, f".tool-elapsed must keep {prop}"