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
@@ -0,0 +1,12 @@
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)
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 99%]
.... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,10 @@
All gates green. Task 01 complete.
**Report**
- **Implemented:** `startThinkingClock` now also renders a visible ticking `.typing-elapsed` "Ns" hint (createElement + textContent, bubble's last child) after the 10s gate — aria-label line kept byte-identical; new `.typing-elapsed` CSS rule (mono, ink-soft AA pairing, plain text) placed next to the typing-dot rules, plus full-contrast pin under `prefers-reduced-motion`; new `tests/unit/test_big_read_progress.py` (4 source-level pins, module started for task 02 to extend).
- **Tests:** `uv run pytest tests/unit/test_big_read_progress.py tests/unit/test_frontend_feedback.py tests/unit/test_frontend_brand.py tests/unit/test_frontend_tool_states.py -v --no-cov` → 54 passed; full `uv run pytest --cov=app --cov-report=term` → 1724 passed, app/ coverage **99%** (>90%).
- **Lint:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- **Diff:** additive-only — `app.js` +13 / `styles.css` +21 / new test file; no `app/` changes; pinned line untouched.
- **Deviations (2, both in favor of correctness):** (1) task suggested pinning `js.count('bubble.innerHTML') == 1`, but the `addTyping` skeleton actually assigns `wrap.innerHTML` — real count is 0, so pinned `== 0` (same intent: no innerHTML on the bubble); (2) the hint span lives inside the `.typing` bubble, so the CSS rule also resets the `.typing span` dot geometry (width/height/radius/background/opacity/animation) it would otherwise inherit — without it the hint would render as an 8px bouncing dot; documented in the rule comment and pinned in the unit test.
- **Next pending task:** `02_tool_line_elapsed.md` (per-line clock, `(Ns)` suffix, settle on frames).
@@ -0,0 +1,85 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 96%]
.................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,22 @@
All green. Final report:
## Task 02 — per-tool-line elapsed clock: COMPLETE
**Implemented**
- `app.js`: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000` (A5, next to `TURN_TIMEOUT_MS`); turn-scoped state (`toolLineTimer/Start/Wrap`) next to `thinkingClock`; `armToolLineClock`/`settleToolLine`/`stopToolLineClock` — suffix is a textContent-built `(${secs}s)` SIBLING on `.tool-call:last-child`, tick gates on the 5s constant, null-safe tick
- Call sites exactly per spec: ONE arm (live `tool` branch after `appendToolLine`), THREE settles (top of thinking/retry/delta), ONE stop (in `setUiState` clear block, after `stopThinkingClock`, before button writes); restore path untouched (A6); `appendToolLine` byte-identical
- `styles.css`: `.tool-elapsed` rule (mono/ink-soft AA pairing, `white-space: nowrap`) next to the `.tool-call` rules
- `tests/unit/test_big_read_progress.py`: 8 new pins (named 5s constant, arm×2 exactly, settle≥4 + top-of-branch, setUiState stop ordering, textContent-only latest-line suffix, template literals, CSS AA pairing)
**Gates (all green)**
- `uv run pytest tests/unit/test_big_read_progress.py` → 12 passed; `test_frontend_tool_states/feedback/brand` → 50 passed unchanged
- `uv run pytest` → 1732 passed; `--cov=app` → **99%** (>90%)
- `uv run ruff check . && uv run pyright` → clean; `node --check app.js` OK
- Live wiring smoke (throwaway, conftest-style env, mock+slow-proxy 8s, real browser): line 1 gained "(5s)" at 5s post-frame, ticked to (8s), re-armed on the next tool line, ALL settled on first delta, none at turn end, no page errors; dev DB restored (docs truncated to as-found empty, smoke tokens revoked, servers stopped)
**Decisions**
- setUiState stop pinned as "after `stopThinkingClock()`, before `sendBtn.disabled`" (documented in test)
- In a multi-tool-call turn, an older line's suffix stays frozen until the next non-tool frame/turn end — exactly the spec's behavior (task pins exactly three settle calls "and nothing else"; tick writes latest line only); the owner's big-read case ticks + settles on the delta as designed
- Dev-LLM live path deflected (empty dev KB → `tool_calls=0`), so smoke used the phase-37 mock flow per the task's fallback; note the mock 500s on the degenerate round-cap request if retrieval seeds the read target (only occurs when a doc gets chunks it shouldn't — E2E fixture avoids by design)
**Next pending task:** `03_e2e_story_suite.md` — `tests/e2e/test_big_read_progress.py`
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 99%]
.... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,12 @@
All green. Task 03 complete.
**Report — Phase 87, Task 03 (E2E story suite)**
- Implemented `tests/e2e/test_big_read_progress.py`: 4 tests (ticking tool-line "(Ns)" suffix, visible typing "Ns" hint + kept aria channel, settle on answer, no-timer restore) over the mock "use your tools" flow behind the slow-LLM proxy at `SLOW_LLM_DELAY_S=6.0`; phase-64 fixture shape (own ports 8128/8903, conftest env shape incl. leak guards), phase-37 auth (admin login) + byte-identical KB seed/question.
- Deviation 1 (documented in module): house `_wait_http` (2 s per-attempt timeout) can never probe a 6 s-sleep proxy — added module-local `_wait_slow_http` (delay+5 s per attempt, rest verbatim conftest pattern).
- Deviation 2 (documented): task's assumed timeline (ls ≈6 s) didn't hold — the chat flow's question **embedding also traverses the proxy** (+6 s), so real timeline is ls ≈12.4 s / read ≈18.7 s / first delta ≈24.9 s (verified via a temporary MutationObserver probe, since removed); test 2 pins the aria channel by polling until the visible hint (≥10) and "still thinking" aria-label align (same JS task per tick — tool frames overwrite the bubble's aria with status copy).
- Added `saved_chats` to the per-test TRUNCATE (phase-55 auto-save rows; house pattern of newer suites).
- Tests: `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — **4 consecutive isolated runs, 4 passed each** (~96 s/run; gate requires 3).
- Full suite: `uv run pytest` → 1732 passed; `--cov=app` → TOTAL 99% (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors.
- Footprint: only the new E2E file; nothing touched in `app/`, `frontend/`, `conftest.py`, `mock_llm.py`, `slow_llm.py`, or other suites (no commit made — harness owns it).
- Next pending task: `04_verify_and_commit.md` (full gate + atomic commit + phase move).
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 99%]
.... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,9 @@
All green. Final report:
**Phase 87 task 04 — gate verified, live check done**
- Gates (all green): `uv run pytest` → 1732 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90% held, no `app/` change); `tests/e2e/test_big_read_progress.py` → 4 passed (95.6s, isolation); `test_thinking_display.py` → 5 passed; `test_agent_document_tools.py` → 4 passed; `test_smoke.py` → 3 passed; `ruff check . && pyright` → clean (0 errors). Untouched frontend suites re-run named: 62 passed with `test_big_read_progress.py`.
- Live check (real aipi LLM, dev server, headless session — transcript `/tmp/live_check_87_transcript.txt`, shots in `/tmp/live_check_87_shots/`): big-read gap 47s → "📄 Reading Homelab/big-incident-report.md" ticked (5s)→(46s), settled on the verbatim answer; pre-token window 42s → visible typing hint 10s→49s with aligned "still thinking" aria; Stop mid-gap (suffix on the current turn's own line — DOM-diagnosed) → reload: 0 `.tool-elapsed`, 0 `#typing-indicator`, button "Send"; no page JS errors; seeded test docs removed from dev DB afterward.
- Notable: hint and suffix can't co-occur when the real model streams `thinking` early (phase-17 `removeTyping` on thinking frames — pre-existing, byte-identical); both runs above prove each indication live. Working tree holds only `app.js`/`styles.css` + the two new test files + phase files; `app/`, `conftest.py`, `mock_llm.py`, `slow_llm.py`, `pyproject.toml`/`uv.lock` untouched.
- Deviations: per harness protocol I made no commit and moved no phase files (task 04's commit/mv steps are the harness's atomic phase commit). Pre-existing untracked `.agents/remediation_plan.md` (Sep-7 security audit) is in the tree — not phase 87; harness to decide on it.
- Next pending task: none — task 04 is the phase's last; phase complete pending the harness commit + move.
@@ -0,0 +1,86 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 99%]
.... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 98 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 217 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3237 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
+96
View File
@@ -307,6 +307,13 @@ const brand = () => window.BOR_BRAND || "Brain of Reese";
* test_frontend_feedback.py). */ * test_frontend_feedback.py). */
export const TURN_TIMEOUT_MS = 120_000; export const TURN_TIMEOUT_MS = 120_000;
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
* threshold (A5): below 5s a frameless gap reads as normal latency;
* at/above it the latest line proves the turn is still processing.
* Pinned constant, not a magic number — the tool-line clock's tick
* gates on it. */
const TOOL_LINE_ELAPSED_AFTER_MS = 5_000;
const UI_STATE = Object.freeze({ const UI_STATE = Object.freeze({
idle: "idle", idle: "idle",
thinking: "thinking", thinking: "thinking",
@@ -1018,6 +1025,17 @@ let thinkingClock = 0; // setInterval id — elapsed-seconds hint
let thinkingStart = 0; // Date.now() when "thinking" began let thinkingStart = 0; // Date.now() when "thinking" began
let turnTimeout = 0; // setTimeout id — 120s pre-token guard let turnTimeout = 0; // setTimeout id — 120s pre-token guard
let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73) let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73)
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — one clock per
* turn, re-armed per `tool` frame: the baseline (toolLineStart) resets on
* every new line, so each line counts its OWN frameless silence, and the
* "(Ns)" suffix targets that line's LATEST row only (older rows keep their
* permanent record, no timers). toolLineWrap is the message wrap the live
* lines render into — the tick's null-safe lookups make a New-Chat click
* mid-gap a no-op (no container → no suffix). Settle (next frame) and
* stop (every setUiState transition) own the teardown. */
let toolLineTimer = 0; // setInterval id — the latest tool line's "(Ns)" suffix
let toolLineStart = 0; // Date.now() when the latest tool line armed the clock
let toolLineWrap = null; // the live wrap the clock suffixes (null when stopped)
/* Turn accumulators + the navigate-away flag (phase 20, owner choice /* Turn accumulators + the navigate-away flag (phase 20, owner choice
* 2026-08-24 A1): module scope because the `pagehide` handler reads them * 2026-08-24 A1): module scope because the `pagehide` handler reads them
* while a turn is still in flight; reset per turn at the top of * while a turn is still in flight; reset per turn at the top of
@@ -1062,6 +1080,19 @@ function startThinkingClock() {
const bubble = document.querySelector("#typing-indicator .bubble"); const bubble = document.querySelector("#typing-indicator .bubble");
if (bubble) { if (bubble) {
bubble.setAttribute("aria-label", `${brand()} is still thinking (${secs}s)`); bubble.setAttribute("aria-label", `${brand()} is still thinking (${secs}s)`);
// Phase 87 (TODO.md L5): the 10s hint was aria-label-only — invisible
// to sighted users, who saw frozen dots on a big read. Now it ALSO
// renders a visible mono "Ns" suffix as the bubble's last child (after
// the three dot spans). The aria channel is kept byte-identical —
// both users, same clock. textContent only: the bubble is
// role="status", so the change is announced.
let el = bubble.querySelector(".typing-elapsed");
if (!el) {
el = document.createElement("span");
el.className = "typing-elapsed";
bubble.appendChild(el);
}
el.textContent = secs + "s";
} }
}, 1000); }, 1000);
} }
@@ -1080,6 +1111,53 @@ function clearTurnTimeout() {
turnTimeoutCb = null; turnTimeoutCb = null;
} }
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — the latest
* tool line's visible "processing" suffix. The suffix is a SIBLING
* appended AFTER the line's existing children (the pinned template text
* + the <code> argument) — never a rewrite: appendToolLine's exact
* line.textContent literals stay byte-identical (the unit + emoji-guard
* pins), and the restore path (phase 14) re-renders lines with no clock
* at all (A6 — the indication is live-only). */
function armToolLineClock(wrap) {
toolLineWrap = wrap;
toolLineStart = Date.now();
if (!toolLineTimer) {
toolLineTimer = setInterval(() => {
const secs = Math.round((Date.now() - toolLineStart) / 1000);
if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return; // A5: below 5s the gap reads as normal latency
const line =
toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child");
if (!line) return; // the wrap was reset mid-gap (New Chat) — no-op
let el = line.querySelector(".tool-elapsed");
if (!el) {
el = document.createElement("span");
el.className = "tool-elapsed";
line.appendChild(el);
}
el.textContent = `(${secs}s)`;
}, 1000);
}
}
/* A frame arrived (thinking / retry / delta) or the turn is settling:
* the latest line is no longer "processing" — drop the interval and
* REMOVE the suffix (a frozen timestamp on a finished line is noise;
* the line itself stays the permanent record). */
function settleToolLine() {
if (toolLineTimer) {
clearInterval(toolLineTimer);
toolLineTimer = 0;
}
toolLineWrap?.querySelectorAll?.(".tool-elapsed").forEach((el) => el.remove());
}
/* The state-machine entry (next to stopThinkingClock / clearTurnTimeout
* in setUiState): settle + forget the wrap — no residue across turns. */
function stopToolLineClock() {
settleToolLine();
toolLineWrap = null;
}
/* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must /* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must
* never stop a turn, but the 120s pre-token guard is a plain setTimeout, * never stop a turn, but the 120s pre-token guard is a plain setTimeout,
* so hidden time counted toward it: a turn whose first frame lands while * so hidden time counted toward it: a turn whose first frame lands while
@@ -1109,6 +1187,7 @@ export function setUiState(state, errorDetail = "") {
uiState = state; uiState = state;
stopThinkingClock(); stopThinkingClock();
clearTurnTimeout(); // the guard only owns the pre-token window clearTurnTimeout(); // the guard only owns the pre-token window
stopToolLineClock(); // phase 87: a stuck timer is impossible — every transition stops/clears it
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming; const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
sendBtn.disabled = false; // enabled in every state — Stop is a control sendBtn.disabled = false; // enabled in every state — Stop is a control
@@ -2082,6 +2161,10 @@ async function runTurn(text, { reask = false } = {}) {
await readSSE(res, (ev) => { await readSSE(res, (ev) => {
if (aborted) return; if (aborted) return;
if (ev.type === "thinking") { if (ev.type === "thinking") {
// Phase 87 (TODO.md L5): a frame arrived — the latest tool line
// is no longer "processing"; settle its "(Ns)" suffix (the
// visible indication moves to the thinking block).
settleToolLine();
// Phase 17: model reasoning — stream it live into the collapsible // Phase 17: model reasoning — stream it live into the collapsible
// Thinking block. No setUiState here: the UI state stays // Thinking block. No setUiState here: the UI state stays
// "thinking" (the button stays the enabled "Stop" control — // "thinking" (the button stays the enabled "Stop" control —
@@ -2146,8 +2229,17 @@ async function runTurn(text, { reask = false } = {}) {
?.setAttribute("aria-label", toolStatus); ?.setAttribute("aria-label", toolStatus);
} }
appendToolLine(wrap, name, argument); appendToolLine(wrap, name, argument);
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
// baseline resets per line, so each line counts its OWN
// frameless silence. This live branch is the ONLY arm call site
// (A6): the phase-14 restore path re-renders lines without it.
armToolLineClock(wrap);
// No page scroll (phase 42): tool lines never yank the viewport. // No page scroll (phase 42): tool lines never yank the viewport.
} else if (ev.type === "retry") { } else if (ev.type === "retry") {
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
// tool line's "(Ns)" suffix (the retry status line takes over as
// the visible feedback).
settleToolLine();
// Phase 67 (owner-locked A4): the server restarted the LLM // Phase 67 (owner-locked A4): the server restarted the LLM
// request before ANY frame of it reached the client (locked // request before ANY frame of it reached the client (locked
// A2) — say exactly what is happening on the existing status // A2) — say exactly what is happening on the existing status
@@ -2169,6 +2261,10 @@ async function runTurn(text, { reask = false } = {}) {
?.setAttribute("aria-label", retryStatus); ?.setAttribute("aria-label", retryStatus);
} }
} else if (ev.type === "delta") { } else if (ev.type === "delta") {
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
// tool line's "(Ns)" suffix (the answer bubble takes over as the
// visible feedback).
settleToolLine();
acc += ev.text || ""; acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming); if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
+32
View File
@@ -606,6 +606,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
border-radius: 5px; border-radius: 5px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
suffix — ticking "(Ns)" while ≥5s of the turn's stream stay silent
after the call; removed on the next frame (settle). ink-soft on the
bubble surface (the AA pairing), small mono like every status line. */
.tool-elapsed {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft);
margin-left: 0.5rem;
white-space: nowrap;
}
.msg-meta { .msg-meta {
font-size: 0.75rem; font-size: 0.75rem;
@@ -1113,8 +1124,29 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-5px); opacity: 1; } 30% { transform: translateY(-5px); opacity: 1; }
} }
/* Phase 87 (TODO.md L5): the visible pre-token elapsed hint — the
10s thinking clock promoted from aria-only to sighted users.
ink-soft on the bubble surface (the AA pairing), small mono like
every status line. Plain text: no animation, no motion opt-out.
The span is a sibling of the dots inside the same .typing bubble, so
it resets the dot geometry (.typing span) it would otherwise inherit. */
.typing-elapsed {
width: auto;
height: auto;
border-radius: 0;
background: none;
opacity: 1;
animation: none;
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft);
margin-left: 0.5rem;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.typing span { animation: none; opacity: 0.7; } .typing span { animation: none; opacity: 0.7; }
/* Phase 87 (TODO.md L5): the elapsed hint is plain text, not motion —
it keeps full opacity (the AA pairing) under reduced motion. */
.typing span.typing-elapsed { opacity: 1; }
/* Phase 17 thinking block: the chevron stills (no rotation motion). */ /* Phase 17 thinking block: the chevron stills (no rotation motion). */
details.thinking summary::before { transition: none; } details.thinking summary::before { transition: none; }
} }
+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}"