From 7cfe58fb2119f907514c49cfa38708b610ddb9b2 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 8 Sep 2026 05:51:23 -0400 Subject: [PATCH] phase: 87_big_read_progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../87_big_read_progress/00_phase.md | 0 .../01_typing_elapsed_hint.md | 0 .../02_tool_line_elapsed.md | 0 .../03_e2e_story_suite.md | 0 .../04_verify_and_commit.md | 0 .../87_big_read_progress__00_phase.a1.err | 0 .../87_big_read_progress__00_phase.a1.md | 12 + ...87_big_read_progress__00_phase.a1.validate | 86 +++ ...ad_progress__01_typing_elapsed_hint.a1.err | 0 ...ead_progress__01_typing_elapsed_hint.a1.md | 10 + ...ogress__01_typing_elapsed_hint.a1.validate | 85 +++ ...read_progress__02_tool_line_elapsed.a1.err | 0 ..._read_progress__02_tool_line_elapsed.a1.md | 22 + ...progress__02_tool_line_elapsed.a1.validate | 86 +++ ...g_read_progress__03_e2e_story_suite.a1.err | 0 ...ig_read_progress__03_e2e_story_suite.a1.md | 12 + ...d_progress__03_e2e_story_suite.a1.validate | 86 +++ ...read_progress__04_verify_and_commit.a1.err | 0 ..._read_progress__04_verify_and_commit.a1.md | 9 + ...progress__04_verify_and_commit.a1.validate | 86 +++ frontend/assets/app.js | 96 +++ frontend/assets/styles.css | 32 + tests/e2e/test_big_read_progress.py | 695 ++++++++++++++++++ tests/unit/test_big_read_progress.py | 333 +++++++++ 24 files changed, 1650 insertions(+) rename .agents/phases/{todo => complete}/87_big_read_progress/00_phase.md (100%) rename .agents/phases/{todo => complete}/87_big_read_progress/01_typing_elapsed_hint.md (100%) rename .agents/phases/{todo => complete}/87_big_read_progress/02_tool_line_elapsed.md (100%) rename .agents/phases/{todo => complete}/87_big_read_progress/03_e2e_story_suite.md (100%) rename .agents/phases/{todo => complete}/87_big_read_progress/04_verify_and_commit.md (100%) create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.err create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.md create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.validate create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.err create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.md create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.validate create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.err create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.md create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.validate create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.err create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.md create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.validate create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.err create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.md create mode 100644 .agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.validate create mode 100644 tests/e2e/test_big_read_progress.py create mode 100644 tests/unit/test_big_read_progress.py diff --git a/.agents/phases/todo/87_big_read_progress/00_phase.md b/.agents/phases/complete/87_big_read_progress/00_phase.md similarity index 100% rename from .agents/phases/todo/87_big_read_progress/00_phase.md rename to .agents/phases/complete/87_big_read_progress/00_phase.md diff --git a/.agents/phases/todo/87_big_read_progress/01_typing_elapsed_hint.md b/.agents/phases/complete/87_big_read_progress/01_typing_elapsed_hint.md similarity index 100% rename from .agents/phases/todo/87_big_read_progress/01_typing_elapsed_hint.md rename to .agents/phases/complete/87_big_read_progress/01_typing_elapsed_hint.md diff --git a/.agents/phases/todo/87_big_read_progress/02_tool_line_elapsed.md b/.agents/phases/complete/87_big_read_progress/02_tool_line_elapsed.md similarity index 100% rename from .agents/phases/todo/87_big_read_progress/02_tool_line_elapsed.md rename to .agents/phases/complete/87_big_read_progress/02_tool_line_elapsed.md diff --git a/.agents/phases/todo/87_big_read_progress/03_e2e_story_suite.md b/.agents/phases/complete/87_big_read_progress/03_e2e_story_suite.md similarity index 100% rename from .agents/phases/todo/87_big_read_progress/03_e2e_story_suite.md rename to .agents/phases/complete/87_big_read_progress/03_e2e_story_suite.md diff --git a/.agents/phases/todo/87_big_read_progress/04_verify_and_commit.md b/.agents/phases/complete/87_big_read_progress/04_verify_and_commit.md similarity index 100% rename from .agents/phases/todo/87_big_read_progress/04_verify_and_commit.md rename to .agents/phases/complete/87_big_read_progress/04_verify_and_commit.md diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.err b/.agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.md b/.agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.md new file mode 100644 index 0000000..5407562 --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.md @@ -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) diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.validate b/.agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.validate new file mode 100644 index 0000000..6a8ab90 --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.err b/.agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.md b/.agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.md new file mode 100644 index 0000000..4a266f8 --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.md @@ -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). diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.validate b/.agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.validate new file mode 100644 index 0000000..694b74d --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__01_typing_elapsed_hint.a1.validate @@ -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 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.err b/.agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.md b/.agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.md new file mode 100644 index 0000000..ff9fde0 --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.md @@ -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` diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.validate b/.agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.validate new file mode 100644 index 0000000..6a8ab90 --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__02_tool_line_elapsed.a1.validate @@ -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 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.err b/.agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.md b/.agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.md new file mode 100644 index 0000000..3ce4c8d --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.md @@ -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). diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.validate b/.agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.validate new file mode 100644 index 0000000..6a8ab90 --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__03_e2e_story_suite.a1.validate @@ -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 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.err b/.agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.md b/.agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.md new file mode 100644 index 0000000..8353798 --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.md @@ -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. diff --git a/.agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.validate b/.agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.validate new file mode 100644 index 0000000..6a8ab90 --- /dev/null +++ b/.agents/reports/87_big_read_progress/87_big_read_progress__04_verify_and_commit.a1.validate @@ -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 diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 487ca3e..7c33be2 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -307,6 +307,13 @@ const brand = () => window.BOR_BRAND || "Brain of Reese"; * test_frontend_feedback.py). */ 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({ idle: "idle", thinking: "thinking", @@ -1018,6 +1025,17 @@ let thinkingClock = 0; // setInterval id — elapsed-seconds hint let thinkingStart = 0; // Date.now() when "thinking" began let turnTimeout = 0; // setTimeout id — 120s pre-token guard 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 * 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 @@ -1062,6 +1080,19 @@ function startThinkingClock() { const bubble = document.querySelector("#typing-indicator .bubble"); if (bubble) { 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); } @@ -1080,6 +1111,53 @@ function clearTurnTimeout() { 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 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 * 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 @@ -1109,6 +1187,7 @@ export function setUiState(state, errorDetail = "") { uiState = state; stopThinkingClock(); 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; 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) => { if (aborted) return; 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 // Thinking block. No setUiState here: the UI state stays // "thinking" (the button stays the enabled "Stop" control — @@ -2146,8 +2229,17 @@ async function runTurn(text, { reask = false } = {}) { ?.setAttribute("aria-label", toolStatus); } 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. } 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 // request before ANY frame of it reached the client (locked // A2) — say exactly what is happening on the existing status @@ -2169,6 +2261,10 @@ async function runTurn(text, { reask = false } = {}) { ?.setAttribute("aria-label", retryStatus); } } 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 || ""; if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming); if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index ac939a4..b8a9482 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -606,6 +606,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } border-radius: 5px; 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 { 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; } 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) { .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). */ details.thinking summary::before { transition: none; } } diff --git a/tests/e2e/test_big_read_progress.py b/tests/e2e/test_big_read_progress.py new file mode 100644 index 0000000..4678d55 --- /dev/null +++ b/tests/e2e/test_big_read_progress.py @@ -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 . ``: 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 +```` 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 → ). +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 . ". +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) + diff --git a/tests/unit/test_big_read_progress.py b/tests/unit/test_big_read_progress.py new file mode 100644 index 0000000..a8f2e91 --- /dev/null +++ b/tests/unit/test_big_read_progress.py @@ -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 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}"