phase: 87_big_read_progress
All criteria verified — no defects found, nothing to fix. Final report: **Phase 87 — big read progress: final verification pass (all tasks already complete in `complete/`)** - Verified implementation vs. overview: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, single live `armToolLineClock` site, settle on thinking/retry/delta, `stopToolLineClock` in `setUiState`, visible `.typing-elapsed` (aria kept byte-identical), CSS AA pairing — all match design; `app/` byte-identical (0 changes) - **Tests/lint (exact outcomes):** - `uv run pytest --cov=app --cov-report=term` → 1732 passed, coverage **99%** (>90% ✓) - `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` → **4 passed** (ticking suffix, visible hint, settle, no-timer restore) - `test_thinking_display.py` → 5 passed · `test_agent_document_tools.py` → 4 passed · `test_smoke.py` → 3 passed (all isolated) - 3 pinned frontend suites + new unit pins → 62 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors - **Completion criteria:** E2E pins 1–4 ✓ · guard/state-machine byte-identical ✓ (diff is additive only) · diff scope limited to `app.js`, `styles.css`, 2 new test files, phase files; nothing in `app/` ✓ - **Notable:** no deviations; commit + `00_phase.md` move left to the harness per executor rules (task files already in `complete/`) - **Next pending phase:** none — `todo/` contains only this phase (87 is the last)
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
All criteria verified — no defects found, nothing to fix. Final report:
|
||||||
|
|
||||||
|
**Phase 87 — big read progress: final verification pass (all tasks already complete in `complete/`)**
|
||||||
|
- Verified implementation vs. overview: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, single live `armToolLineClock` site, settle on thinking/retry/delta, `stopToolLineClock` in `setUiState`, visible `.typing-elapsed` (aria kept byte-identical), CSS AA pairing — all match design; `app/` byte-identical (0 changes)
|
||||||
|
- **Tests/lint (exact outcomes):**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term` → 1732 passed, coverage **99%** (>90% ✓)
|
||||||
|
- `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` → **4 passed** (ticking suffix, visible hint, settle, no-timer restore)
|
||||||
|
- `test_thinking_display.py` → 5 passed · `test_agent_document_tools.py` → 4 passed · `test_smoke.py` → 3 passed (all isolated)
|
||||||
|
- 3 pinned frontend suites + new unit pins → 62 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors
|
||||||
|
- **Completion criteria:** E2E pins 1–4 ✓ · guard/state-machine byte-identical ✓ (diff is additive only) · diff scope limited to `app.js`, `styles.css`, 2 new test files, phase files; nothing in `app/` ✓
|
||||||
|
- **Notable:** no deviations; commit + `00_phase.md` move left to the harness per executor rules (task files already in `complete/`)
|
||||||
|
- **Next pending phase:** none — `todo/` contains only this phase (87 is the last)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 95%]
|
||||||
|
........................................................................ [ 99%]
|
||||||
|
.... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 178 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 98 0 100%
|
||||||
|
app/api/tokens.py 28 0 100%
|
||||||
|
app/config.py 141 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 19 0 100%
|
||||||
|
app/core/tokens.py 33 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 64 0 100%
|
||||||
|
app/models.py 94 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 217 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 232 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3237 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+10
@@ -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).
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 96%]
|
||||||
|
.................................................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 178 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 98 0 100%
|
||||||
|
app/api/tokens.py 28 0 100%
|
||||||
|
app/config.py 141 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 19 0 100%
|
||||||
|
app/core/tokens.py 33 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 64 0 100%
|
||||||
|
app/models.py 94 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 217 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 232 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3237 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
## Task 02 — per-tool-line elapsed clock: COMPLETE
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `app.js`: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000` (A5, next to `TURN_TIMEOUT_MS`); turn-scoped state (`toolLineTimer/Start/Wrap`) next to `thinkingClock`; `armToolLineClock`/`settleToolLine`/`stopToolLineClock` — suffix is a textContent-built `(${secs}s)` SIBLING on `.tool-call:last-child`, tick gates on the 5s constant, null-safe tick
|
||||||
|
- Call sites exactly per spec: ONE arm (live `tool` branch after `appendToolLine`), THREE settles (top of thinking/retry/delta), ONE stop (in `setUiState` clear block, after `stopThinkingClock`, before button writes); restore path untouched (A6); `appendToolLine` byte-identical
|
||||||
|
- `styles.css`: `.tool-elapsed` rule (mono/ink-soft AA pairing, `white-space: nowrap`) next to the `.tool-call` rules
|
||||||
|
- `tests/unit/test_big_read_progress.py`: 8 new pins (named 5s constant, arm×2 exactly, settle≥4 + top-of-branch, setUiState stop ordering, textContent-only latest-line suffix, template literals, CSS AA pairing)
|
||||||
|
|
||||||
|
**Gates (all green)**
|
||||||
|
- `uv run pytest tests/unit/test_big_read_progress.py` → 12 passed; `test_frontend_tool_states/feedback/brand` → 50 passed unchanged
|
||||||
|
- `uv run pytest` → 1732 passed; `--cov=app` → **99%** (>90%)
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean; `node --check app.js` OK
|
||||||
|
- Live wiring smoke (throwaway, conftest-style env, mock+slow-proxy 8s, real browser): line 1 gained "(5s)" at 5s post-frame, ticked to (8s), re-armed on the next tool line, ALL settled on first delta, none at turn end, no page errors; dev DB restored (docs truncated to as-found empty, smoke tokens revoked, servers stopped)
|
||||||
|
|
||||||
|
**Decisions**
|
||||||
|
- setUiState stop pinned as "after `stopThinkingClock()`, before `sendBtn.disabled`" (documented in test)
|
||||||
|
- In a multi-tool-call turn, an older line's suffix stays frozen until the next non-tool frame/turn end — exactly the spec's behavior (task pins exactly three settle calls "and nothing else"; tick writes latest line only); the owner's big-read case ticks + settles on the delta as designed
|
||||||
|
- Dev-LLM live path deflected (empty dev KB → `tool_calls=0`), so smoke used the phase-37 mock flow per the task's fallback; note the mock 500s on the degenerate round-cap request if retrieval seeds the read target (only occurs when a doc gets chunks it shouldn't — E2E fixture avoids by design)
|
||||||
|
|
||||||
|
**Next pending task:** `03_e2e_story_suite.md` — `tests/e2e/test_big_read_progress.py`
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 95%]
|
||||||
|
........................................................................ [ 99%]
|
||||||
|
.... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 178 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 98 0 100%
|
||||||
|
app/api/tokens.py 28 0 100%
|
||||||
|
app/config.py 141 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 19 0 100%
|
||||||
|
app/core/tokens.py 33 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 64 0 100%
|
||||||
|
app/models.py 94 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 217 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 232 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3237 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
All green. Task 03 complete.
|
||||||
|
|
||||||
|
**Report — Phase 87, Task 03 (E2E story suite)**
|
||||||
|
|
||||||
|
- Implemented `tests/e2e/test_big_read_progress.py`: 4 tests (ticking tool-line "(Ns)" suffix, visible typing "Ns" hint + kept aria channel, settle on answer, no-timer restore) over the mock "use your tools" flow behind the slow-LLM proxy at `SLOW_LLM_DELAY_S=6.0`; phase-64 fixture shape (own ports 8128/8903, conftest env shape incl. leak guards), phase-37 auth (admin login) + byte-identical KB seed/question.
|
||||||
|
- Deviation 1 (documented in module): house `_wait_http` (2 s per-attempt timeout) can never probe a 6 s-sleep proxy — added module-local `_wait_slow_http` (delay+5 s per attempt, rest verbatim conftest pattern).
|
||||||
|
- Deviation 2 (documented): task's assumed timeline (ls ≈6 s) didn't hold — the chat flow's question **embedding also traverses the proxy** (+6 s), so real timeline is ls ≈12.4 s / read ≈18.7 s / first delta ≈24.9 s (verified via a temporary MutationObserver probe, since removed); test 2 pins the aria channel by polling until the visible hint (≥10) and "still thinking" aria-label align (same JS task per tick — tool frames overwrite the bubble's aria with status copy).
|
||||||
|
- Added `saved_chats` to the per-test TRUNCATE (phase-55 auto-save rows; house pattern of newer suites).
|
||||||
|
- Tests: `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — **4 consecutive isolated runs, 4 passed each** (~96 s/run; gate requires 3).
|
||||||
|
- Full suite: `uv run pytest` → 1732 passed; `--cov=app` → TOTAL 99% (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors.
|
||||||
|
- Footprint: only the new E2E file; nothing touched in `app/`, `frontend/`, `conftest.py`, `mock_llm.py`, `slow_llm.py`, or other suites (no commit made — harness owns it).
|
||||||
|
- Next pending task: `04_verify_and_commit.md` (full gate + atomic commit + phase move).
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 95%]
|
||||||
|
........................................................................ [ 99%]
|
||||||
|
.... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 178 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 98 0 100%
|
||||||
|
app/api/tokens.py 28 0 100%
|
||||||
|
app/config.py 141 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 19 0 100%
|
||||||
|
app/core/tokens.py 33 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 64 0 100%
|
||||||
|
app/models.py 94 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 217 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 232 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3237 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
**Phase 87 task 04 — gate verified, live check done**
|
||||||
|
|
||||||
|
- Gates (all green): `uv run pytest` → 1732 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90% held, no `app/` change); `tests/e2e/test_big_read_progress.py` → 4 passed (95.6s, isolation); `test_thinking_display.py` → 5 passed; `test_agent_document_tools.py` → 4 passed; `test_smoke.py` → 3 passed; `ruff check . && pyright` → clean (0 errors). Untouched frontend suites re-run named: 62 passed with `test_big_read_progress.py`.
|
||||||
|
- Live check (real aipi LLM, dev server, headless session — transcript `/tmp/live_check_87_transcript.txt`, shots in `/tmp/live_check_87_shots/`): big-read gap 47s → "📄 Reading Homelab/big-incident-report.md" ticked (5s)→(46s), settled on the verbatim answer; pre-token window 42s → visible typing hint 10s→49s with aligned "still thinking" aria; Stop mid-gap (suffix on the current turn's own line — DOM-diagnosed) → reload: 0 `.tool-elapsed`, 0 `#typing-indicator`, button "Send"; no page JS errors; seeded test docs removed from dev DB afterward.
|
||||||
|
- Notable: hint and suffix can't co-occur when the real model streams `thinking` early (phase-17 `removeTyping` on thinking frames — pre-existing, byte-identical); both runs above prove each indication live. Working tree holds only `app.js`/`styles.css` + the two new test files + phase files; `app/`, `conftest.py`, `mock_llm.py`, `slow_llm.py`, `pyproject.toml`/`uv.lock` untouched.
|
||||||
|
- Deviations: per harness protocol I made no commit and moved no phase files (task 04's commit/mv steps are the harness's atomic phase commit). Pre-existing untracked `.agents/remediation_plan.md` (Sep-7 security audit) is in the tree — not phase 87; harness to decide on it.
|
||||||
|
- Next pending task: none — task 04 is the phase's last; phase complete pending the harness commit + move.
|
||||||
+86
@@ -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
|
||||||
@@ -307,6 +307,13 @@ const brand = () => window.BOR_BRAND || "Brain of Reese";
|
|||||||
* test_frontend_feedback.py). */
|
* test_frontend_feedback.py). */
|
||||||
export const TURN_TIMEOUT_MS = 120_000;
|
export const TURN_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
|
||||||
|
* threshold (A5): below 5s a frameless gap reads as normal latency;
|
||||||
|
* at/above it the latest line proves the turn is still processing.
|
||||||
|
* Pinned constant, not a magic number — the tool-line clock's tick
|
||||||
|
* gates on it. */
|
||||||
|
const TOOL_LINE_ELAPSED_AFTER_MS = 5_000;
|
||||||
|
|
||||||
const UI_STATE = Object.freeze({
|
const UI_STATE = Object.freeze({
|
||||||
idle: "idle",
|
idle: "idle",
|
||||||
thinking: "thinking",
|
thinking: "thinking",
|
||||||
@@ -1018,6 +1025,17 @@ let thinkingClock = 0; // setInterval id — elapsed-seconds hint
|
|||||||
let thinkingStart = 0; // Date.now() when "thinking" began
|
let thinkingStart = 0; // Date.now() when "thinking" began
|
||||||
let turnTimeout = 0; // setTimeout id — 120s pre-token guard
|
let turnTimeout = 0; // setTimeout id — 120s pre-token guard
|
||||||
let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73)
|
let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73)
|
||||||
|
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — one clock per
|
||||||
|
* turn, re-armed per `tool` frame: the baseline (toolLineStart) resets on
|
||||||
|
* every new line, so each line counts its OWN frameless silence, and the
|
||||||
|
* "(Ns)" suffix targets that line's LATEST row only (older rows keep their
|
||||||
|
* permanent record, no timers). toolLineWrap is the message wrap the live
|
||||||
|
* lines render into — the tick's null-safe lookups make a New-Chat click
|
||||||
|
* mid-gap a no-op (no container → no suffix). Settle (next frame) and
|
||||||
|
* stop (every setUiState transition) own the teardown. */
|
||||||
|
let toolLineTimer = 0; // setInterval id — the latest tool line's "(Ns)" suffix
|
||||||
|
let toolLineStart = 0; // Date.now() when the latest tool line armed the clock
|
||||||
|
let toolLineWrap = null; // the live wrap the clock suffixes (null when stopped)
|
||||||
/* Turn accumulators + the navigate-away flag (phase 20, owner choice
|
/* Turn accumulators + the navigate-away flag (phase 20, owner choice
|
||||||
* 2026-08-24 A1): module scope because the `pagehide` handler reads them
|
* 2026-08-24 A1): module scope because the `pagehide` handler reads them
|
||||||
* while a turn is still in flight; reset per turn at the top of
|
* while a turn is still in flight; reset per turn at the top of
|
||||||
@@ -1062,6 +1080,19 @@ function startThinkingClock() {
|
|||||||
const bubble = document.querySelector("#typing-indicator .bubble");
|
const bubble = document.querySelector("#typing-indicator .bubble");
|
||||||
if (bubble) {
|
if (bubble) {
|
||||||
bubble.setAttribute("aria-label", `${brand()} is still thinking (${secs}s)`);
|
bubble.setAttribute("aria-label", `${brand()} is still thinking (${secs}s)`);
|
||||||
|
// Phase 87 (TODO.md L5): the 10s hint was aria-label-only — invisible
|
||||||
|
// to sighted users, who saw frozen dots on a big read. Now it ALSO
|
||||||
|
// renders a visible mono "Ns" suffix as the bubble's last child (after
|
||||||
|
// the three dot spans). The aria channel is kept byte-identical —
|
||||||
|
// both users, same clock. textContent only: the bubble is
|
||||||
|
// role="status", so the change is announced.
|
||||||
|
let el = bubble.querySelector(".typing-elapsed");
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("span");
|
||||||
|
el.className = "typing-elapsed";
|
||||||
|
bubble.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = secs + "s";
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
@@ -1080,6 +1111,53 @@ function clearTurnTimeout() {
|
|||||||
turnTimeoutCb = null;
|
turnTimeoutCb = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — the latest
|
||||||
|
* tool line's visible "processing" suffix. The suffix is a SIBLING
|
||||||
|
* appended AFTER the line's existing children (the pinned template text
|
||||||
|
* + the <code> argument) — never a rewrite: appendToolLine's exact
|
||||||
|
* line.textContent literals stay byte-identical (the unit + emoji-guard
|
||||||
|
* pins), and the restore path (phase 14) re-renders lines with no clock
|
||||||
|
* at all (A6 — the indication is live-only). */
|
||||||
|
function armToolLineClock(wrap) {
|
||||||
|
toolLineWrap = wrap;
|
||||||
|
toolLineStart = Date.now();
|
||||||
|
if (!toolLineTimer) {
|
||||||
|
toolLineTimer = setInterval(() => {
|
||||||
|
const secs = Math.round((Date.now() - toolLineStart) / 1000);
|
||||||
|
if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return; // A5: below 5s the gap reads as normal latency
|
||||||
|
const line =
|
||||||
|
toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child");
|
||||||
|
if (!line) return; // the wrap was reset mid-gap (New Chat) — no-op
|
||||||
|
let el = line.querySelector(".tool-elapsed");
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("span");
|
||||||
|
el.className = "tool-elapsed";
|
||||||
|
line.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = `(${secs}s)`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A frame arrived (thinking / retry / delta) or the turn is settling:
|
||||||
|
* the latest line is no longer "processing" — drop the interval and
|
||||||
|
* REMOVE the suffix (a frozen timestamp on a finished line is noise;
|
||||||
|
* the line itself stays the permanent record). */
|
||||||
|
function settleToolLine() {
|
||||||
|
if (toolLineTimer) {
|
||||||
|
clearInterval(toolLineTimer);
|
||||||
|
toolLineTimer = 0;
|
||||||
|
}
|
||||||
|
toolLineWrap?.querySelectorAll?.(".tool-elapsed").forEach((el) => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The state-machine entry (next to stopThinkingClock / clearTurnTimeout
|
||||||
|
* in setUiState): settle + forget the wrap — no residue across turns. */
|
||||||
|
function stopToolLineClock() {
|
||||||
|
settleToolLine();
|
||||||
|
toolLineWrap = null;
|
||||||
|
}
|
||||||
|
|
||||||
/* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must
|
/* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must
|
||||||
* never stop a turn, but the 120s pre-token guard is a plain setTimeout,
|
* never stop a turn, but the 120s pre-token guard is a plain setTimeout,
|
||||||
* so hidden time counted toward it: a turn whose first frame lands while
|
* so hidden time counted toward it: a turn whose first frame lands while
|
||||||
@@ -1109,6 +1187,7 @@ export function setUiState(state, errorDetail = "") {
|
|||||||
uiState = state;
|
uiState = state;
|
||||||
stopThinkingClock();
|
stopThinkingClock();
|
||||||
clearTurnTimeout(); // the guard only owns the pre-token window
|
clearTurnTimeout(); // the guard only owns the pre-token window
|
||||||
|
stopToolLineClock(); // phase 87: a stuck timer is impossible — every transition stops/clears it
|
||||||
|
|
||||||
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
|
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
|
||||||
sendBtn.disabled = false; // enabled in every state — Stop is a control
|
sendBtn.disabled = false; // enabled in every state — Stop is a control
|
||||||
@@ -2082,6 +2161,10 @@ async function runTurn(text, { reask = false } = {}) {
|
|||||||
await readSSE(res, (ev) => {
|
await readSSE(res, (ev) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
if (ev.type === "thinking") {
|
if (ev.type === "thinking") {
|
||||||
|
// Phase 87 (TODO.md L5): a frame arrived — the latest tool line
|
||||||
|
// is no longer "processing"; settle its "(Ns)" suffix (the
|
||||||
|
// visible indication moves to the thinking block).
|
||||||
|
settleToolLine();
|
||||||
// Phase 17: model reasoning — stream it live into the collapsible
|
// Phase 17: model reasoning — stream it live into the collapsible
|
||||||
// Thinking block. No setUiState here: the UI state stays
|
// Thinking block. No setUiState here: the UI state stays
|
||||||
// "thinking" (the button stays the enabled "Stop" control —
|
// "thinking" (the button stays the enabled "Stop" control —
|
||||||
@@ -2146,8 +2229,17 @@ async function runTurn(text, { reask = false } = {}) {
|
|||||||
?.setAttribute("aria-label", toolStatus);
|
?.setAttribute("aria-label", toolStatus);
|
||||||
}
|
}
|
||||||
appendToolLine(wrap, name, argument);
|
appendToolLine(wrap, name, argument);
|
||||||
|
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
|
||||||
|
// baseline resets per line, so each line counts its OWN
|
||||||
|
// frameless silence. This live branch is the ONLY arm call site
|
||||||
|
// (A6): the phase-14 restore path re-renders lines without it.
|
||||||
|
armToolLineClock(wrap);
|
||||||
// No page scroll (phase 42): tool lines never yank the viewport.
|
// No page scroll (phase 42): tool lines never yank the viewport.
|
||||||
} else if (ev.type === "retry") {
|
} else if (ev.type === "retry") {
|
||||||
|
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
|
||||||
|
// tool line's "(Ns)" suffix (the retry status line takes over as
|
||||||
|
// the visible feedback).
|
||||||
|
settleToolLine();
|
||||||
// Phase 67 (owner-locked A4): the server restarted the LLM
|
// Phase 67 (owner-locked A4): the server restarted the LLM
|
||||||
// request before ANY frame of it reached the client (locked
|
// request before ANY frame of it reached the client (locked
|
||||||
// A2) — say exactly what is happening on the existing status
|
// A2) — say exactly what is happening on the existing status
|
||||||
@@ -2169,6 +2261,10 @@ async function runTurn(text, { reask = false } = {}) {
|
|||||||
?.setAttribute("aria-label", retryStatus);
|
?.setAttribute("aria-label", retryStatus);
|
||||||
}
|
}
|
||||||
} else if (ev.type === "delta") {
|
} else if (ev.type === "delta") {
|
||||||
|
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
|
||||||
|
// tool line's "(Ns)" suffix (the answer bubble takes over as the
|
||||||
|
// visible feedback).
|
||||||
|
settleToolLine();
|
||||||
acc += ev.text || "";
|
acc += ev.text || "";
|
||||||
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
||||||
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
||||||
|
|||||||
@@ -606,6 +606,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
|
||||||
|
suffix — ticking "(Ns)" while ≥5s of the turn's stream stay silent
|
||||||
|
after the call; removed on the next frame (settle). ink-soft on the
|
||||||
|
bubble surface (the AA pairing), small mono like every status line. */
|
||||||
|
.tool-elapsed {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.msg-meta {
|
.msg-meta {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -1113,8 +1124,29 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||||
30% { transform: translateY(-5px); opacity: 1; }
|
30% { transform: translateY(-5px); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
/* Phase 87 (TODO.md L5): the visible pre-token elapsed hint — the
|
||||||
|
10s thinking clock promoted from aria-only to sighted users.
|
||||||
|
ink-soft on the bubble surface (the AA pairing), small mono like
|
||||||
|
every status line. Plain text: no animation, no motion opt-out.
|
||||||
|
The span is a sibling of the dots inside the same .typing bubble, so
|
||||||
|
it resets the dot geometry (.typing span) it would otherwise inherit. */
|
||||||
|
.typing-elapsed {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
opacity: 1;
|
||||||
|
animation: none;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.typing span { animation: none; opacity: 0.7; }
|
.typing span { animation: none; opacity: 0.7; }
|
||||||
|
/* Phase 87 (TODO.md L5): the elapsed hint is plain text, not motion —
|
||||||
|
it keeps full opacity (the AA pairing) under reduced motion. */
|
||||||
|
.typing span.typing-elapsed { opacity: 1; }
|
||||||
/* Phase 17 thinking block: the chevron stills (no rotation motion). */
|
/* Phase 17 thinking block: the chevron stills (no rotation motion). */
|
||||||
details.thinking summary::before { transition: none; }
|
details.thinking summary::before { transition: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
"""Phase 87 E2E (Playwright, mock-only): progress indication during a
|
||||||
|
deterministic big-read gap.
|
||||||
|
|
||||||
|
Source: ``TODO.md`` L5 — "Need indication that prompt processing is
|
||||||
|
happening during a big read, it can look frozen."
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov
|
||||||
|
|
||||||
|
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported (the tests skip)
|
||||||
|
— the deterministic gaps this story pins need the mock's "use your
|
||||||
|
tools" marker flow (``tests/e2e/mock_llm.py``) behind the phase-64
|
||||||
|
delay-injecting reverse proxy (``tests/e2e/slow_llm.py``).
|
||||||
|
|
||||||
|
**Mechanism** — the mock's deterministic single-read tool flow, with
|
||||||
|
every inter-frame gap made deterministic at ≥6 s by the slow proxy
|
||||||
|
(``SLOW_LLM_DELAY_S = 6.0`` — this suite passes its OWN value: the
|
||||||
|
phase-64 default 0.15 is far below the 5 s tool-line threshold):
|
||||||
|
|
||||||
|
NOTE the prelude: the chat flow's QUESTION EMBEDDING also travels
|
||||||
|
through the proxy (one 6 s sleep) before the agent loop starts, so
|
||||||
|
the wall-clock timeline of one turn is:
|
||||||
|
|
||||||
|
1. t≈12 s — the first SSE ``tool`` frame ("🔎 Listing documents"): the
|
||||||
|
6 s embedding sleep + request 1's 6 s sleep (``tools`` offered, no
|
||||||
|
tool results yet — streams ONLY the ``ls`` ``tool_calls`` delta,
|
||||||
|
id ``call_0``) + the mock's ~0.2 s tool-call stream;
|
||||||
|
2. t≈19 s — the second ``tool`` frame ("📄 Reading
|
||||||
|
Deployments/example-record-file.json"): request 2's 6 s sleep (a
|
||||||
|
``tool``-role catalog result → streams a ``read`` ``tool_calls``
|
||||||
|
delta on the JOINED combined ``source/path`` of the FIRST catalog
|
||||||
|
line, id ``call_1``);
|
||||||
|
3. t≈25 s — the content answer ``Read <source/path>. <first 80 chars
|
||||||
|
of the read document's content>``: request 3's 6 s sleep (a
|
||||||
|
``tool``-role read result) → the first answer ``delta``.
|
||||||
|
|
||||||
|
So each post-tool-frame gap (≈6.3 s — the 6 s sleep + the short
|
||||||
|
tool-call stream) is PAST the 5 s tool-line threshold
|
||||||
|
(``TOOL_LINE_ELAPSED_AFTER_MS = 5_000``), and the 10 s pre-token
|
||||||
|
typing hint (the existing ``startThinkingClock`` gate, ticking from
|
||||||
|
t≈0) is visible throughout both post-tool gaps — everything before the
|
||||||
|
answer's first delta (the whole turn runs ≈25 s + overhead, acceptable
|
||||||
|
for an isolated story suite). The four tests pin: (1) the latest tool
|
||||||
|
line's ticking "(Ns)" suffix, (2) the typing indicator's visible "Ns"
|
||||||
|
hint (+ the kept aria channel), (3) BOTH settling the instant the
|
||||||
|
answer arrives, (4) a RELOAD restoring the tool lines with NO timer
|
||||||
|
(A6 — the restore path never arms the clock).
|
||||||
|
|
||||||
|
**Aria-channel note (test 2):** the tool-frame branch overwrites the
|
||||||
|
typing bubble's ``aria-label`` with its status copy ("… is listing
|
||||||
|
documents" at ≈12 s, "… is reading …" at ≈19 s); the 1 s thinking
|
||||||
|
clock RE-SETS it to "… is still thinking (Ns)" on every tick (the same
|
||||||
|
task that writes the visible span) — so the two channels read
|
||||||
|
"still thinking" SIMULTANEOUSLY right after each tick, and test 2
|
||||||
|
polls until they align (the deterministic same-moment pin).
|
||||||
|
|
||||||
|
**KB fixture** — byte-identical to the phase-37 suite
|
||||||
|
(``tests/e2e/test_agent_document_tools.py``): ``Homelab/aws-route53.md``
|
||||||
|
carries one chunk embedded with the mock's own bag-of-words vector —
|
||||||
|
the marker question (phase 37's exact question, which carries the
|
||||||
|
trigger phrase) cosines ≈0.69 against it, well past the E2E 0.30
|
||||||
|
threshold, so the turn is grounded and the HIGH prompt carries the
|
||||||
|
``<tools>`` section; ``Deployments/example-record-file.json`` is
|
||||||
|
indexed WITHOUT chunks, and its ``(source, path)`` sorts FIRST in the
|
||||||
|
catalog (``Deployments`` < ``Homelab``) — exactly the line the mock's
|
||||||
|
second request reads, so the answer is byte-stable.
|
||||||
|
|
||||||
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
|
1. ``test_tool_line_shows_ticking_elapsed``
|
||||||
|
2. ``test_typing_indicator_shows_visible_elapsed``
|
||||||
|
3. ``test_indicators_settle_when_the_answer_arrives``
|
||||||
|
4. ``test_restored_turn_has_no_timer``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import Settings as _Settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import Chunk, Document
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
from e2e.conftest import (
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
MOCK_PORT,
|
||||||
|
SESSION_SECRET,
|
||||||
|
USE_REAL_LLM,
|
||||||
|
_wait_http,
|
||||||
|
)
|
||||||
|
from tests.e2e.mock_llm import embed_text
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
# Phase 79 (task 04, full inventory): the conftest session app owns its
|
||||||
|
# port in a combined run — this module app binds its own port instead
|
||||||
|
# (a same-port second uvicorn dies on bind and would drive the wrong
|
||||||
|
# server). Env-overridable.
|
||||||
|
APP_PORT = int(os.environ.get("E2E_APP_PORT_BIGREAD", "8128"))
|
||||||
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||||
|
|
||||||
|
#: This suite's slow-LLM proxy port (the conftest's mock LLM stays on
|
||||||
|
#: MOCK_PORT; the phase-64 suite owns 8902, so the ports follow the
|
||||||
|
#: same allocation rule: one port pair per module app).
|
||||||
|
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_BIGREAD", "8903"))
|
||||||
|
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
|
||||||
|
|
||||||
|
#: Per-LLM-request delay on the proxy — this suite's timing fixture.
|
||||||
|
#: 6.0 s puts every inter-frame gap past the 5 s tool-line threshold
|
||||||
|
#: and keeps the whole turn pre-token until the first delta (the 10 s
|
||||||
|
#: typing hint ticks across the prelude AND both post-tool gaps); the
|
||||||
|
#: whole turn then runs ≈25 s + overhead — acceptable for an isolated
|
||||||
|
#: story suite (the phase-64 suite has the same shape, at 0.15 s).
|
||||||
|
SLOW_DELAY_S = "6.0"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# KB fixture — byte-identical to the phase-37 suite (see the module
|
||||||
|
# docstring)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SEED_SOURCE = "Homelab"
|
||||||
|
SEED_PATH = "aws-route53.md"
|
||||||
|
|
||||||
|
READ_SOURCE = "Deployments"
|
||||||
|
READ_PATH = "example-record-file.json"
|
||||||
|
READ_SP = f"{READ_SOURCE}/{READ_PATH}"
|
||||||
|
|
||||||
|
#: The retrievable document: references the JSON file "for the exact JSON
|
||||||
|
#: shape of reeselink.json" but never includes it. The repeated
|
||||||
|
#: record-file lines carry the marker question's key tokens (aws,
|
||||||
|
#: route53, hosted, zone, reeselink, json, exact, shape) — verified
|
||||||
|
#: ≈0.69 cosine against the mock's embeddings (E2E threshold 0.30) plus
|
||||||
|
#: FTS hits, so the turn is solidly grounded (HIGH prompt → <tools>).
|
||||||
|
ROUTE53_CONTENT = (
|
||||||
|
"# AWS Route 53 Notes\n\n"
|
||||||
|
"## Record file\n\n"
|
||||||
|
+ (
|
||||||
|
"The aws route53 hosted zone for reeselink keeps every record in "
|
||||||
|
"reseelink.json — the exact JSON shape of reeselink.json is "
|
||||||
|
"documented in example-record-file.json.\n"
|
||||||
|
)
|
||||||
|
* 10
|
||||||
|
+ "\n## Sync job\n\n"
|
||||||
|
"A cron job pushes reeselink.json to the aws route53 hosted zone "
|
||||||
|
"every fifteen minutes; the diff is applied through the route53 api.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The referenced document: the exact JSON shape. Its FIRST line is longer
|
||||||
|
#: than 80 chars, so the mock's first-80-chars quote is newline-free (the
|
||||||
|
#: rendered-text assertions below match it verbatim). Pinned by the
|
||||||
|
#: assert below (phase 37's pin, kept).
|
||||||
|
RECORD_FILE_CONTENT = (
|
||||||
|
'{ "version": 3, "comment": "ReeseLink hosted zone records — the exact '
|
||||||
|
'JSON shape of reeselink.json",\n'
|
||||||
|
' "hosted_zone_id": "Z0RESEELINK01",\n'
|
||||||
|
' "record_sets": [\n'
|
||||||
|
' { "name": "www.reeselink.example", "type": "A", "ttl": 300,\n'
|
||||||
|
' "resource_records": [ { "value": "10.0.0.20" } ] },\n'
|
||||||
|
' { "name": "api.reeselink.example", "type": "CNAME", "ttl": 300,\n'
|
||||||
|
' "resource_records": [ { "value": "www.reeselink.example" } ] }\n'
|
||||||
|
" ]\n"
|
||||||
|
"}\n"
|
||||||
|
)
|
||||||
|
assert "\n" not in RECORD_FILE_CONTENT[:80] # the quote must stay one line
|
||||||
|
|
||||||
|
#: Phase 37's exact marker question — carries the mock trigger phrase
|
||||||
|
#: "use your tools" (case-insensitive ``TOOLS_TRIGGER``) and is grounded
|
||||||
|
#: against the byte-identical seed above, so the deterministic single-
|
||||||
|
#: read tool flow runs.
|
||||||
|
MARKER_QUESTION = (
|
||||||
|
"Use your tools: what is the exact JSON shape of reeselink.json "
|
||||||
|
"for my aws route53 hosted zone?"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The mock's deterministic answer for the single-read flow (the
|
||||||
|
#: phase-37 shapes, kept): "Read <source/path>. <first 80 chars>".
|
||||||
|
ANSWER_PREFIX = f"Read {READ_SP}."
|
||||||
|
ANSWER_QUOTE = RECORD_FILE_CONTENT[:80]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Timeouts — every wait carries ≥2× headroom on its expected duration
|
||||||
|
# (the flakiness discipline: the timing assertions themselves are
|
||||||
|
# strictly-increasing, never exact-value)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: The first ``tool`` frame lands at ≈12.4 s (the 6 s embedding sleep
|
||||||
|
#: + request 1's 6 s proxy sleep + the mock's short tool-call stream)
|
||||||
|
#: — 1.6× headroom.
|
||||||
|
TOOL_LINE_TIMEOUT_MS = 20_000
|
||||||
|
#: The "(Ns)" suffix appears 5 s after the line's OWN arm (1 s tick
|
||||||
|
#: granularity) — the task's generous 12 s, measured from the line's
|
||||||
|
#: appearance: 2.4× the threshold.
|
||||||
|
SUFFIX_TIMEOUT_MS = 12_000
|
||||||
|
#: The visible typing hint first ticks at 10 s from submit, but this
|
||||||
|
#: suite only starts checking it AFTER the first tool line (≈12.4 s)
|
||||||
|
#: — the first tick where the visible hint and the aria channel align
|
||||||
|
#: is ≈13 s (the next tick after the ls frame's aria overwrite), and
|
||||||
|
#: the indicator is removed at the first delta (≈25 s): 20 s from the
|
||||||
|
#: check start cannot flake.
|
||||||
|
TYPING_HINT_TIMEOUT_MS = 20_000
|
||||||
|
#: The answer settles at ≈19 s from submit; every settle wait here starts
|
||||||
|
#: ≥12 s in — 30 s keeps ≥2× headroom on the remaining window.
|
||||||
|
SETTLE_TIMEOUT_MS = 30_000
|
||||||
|
#: The restore is a synchronous boot re-render — 15 s is ample.
|
||||||
|
RESTORE_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
#: The tool-line suffix format: parenthesized "(Ns)" (the task's pin).
|
||||||
|
ELAPSED_PAT = r"\((\d+)s\)"
|
||||||
|
#: The typing hint format: bare "Ns" (task 01's pin).
|
||||||
|
TYPING_ELAPSED_PAT = r"(\d+)s"
|
||||||
|
#: The two-sample spacing the task pins (≥1.5 s apart).
|
||||||
|
SAMPLE_GAP_S = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures (the phase-64 pattern — module app behind the slow proxy; the
|
||||||
|
# conftest already spawns the mock LLM)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _mock_only() -> None:
|
||||||
|
"""The deterministic gaps need the mock's marker flow — skip
|
||||||
|
(rather than fail) when pointed at the real LLM."""
|
||||||
|
if USE_REAL_LLM:
|
||||||
|
pytest.skip(
|
||||||
|
"mock-only suite: the deterministic big-read gap needs the "
|
||||||
|
"mock's 'use your tools' flow behind the slow proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_slow_http(url: str, delay_s: float, timeout: float = 60.0) -> None:
|
||||||
|
"""Readiness probe for the slow proxy — the conftest's
|
||||||
|
``_wait_http`` pattern with one adaptation forced by THIS suite's
|
||||||
|
delay: the house helper gives each probe attempt a 2.0 s client
|
||||||
|
timeout, but a request to this proxy takes ≥ ``SLOW_LLM_DELAY_S``
|
||||||
|
(6.0 s — it sleeps per request), so with the house helper NO probe
|
||||||
|
request ever completes and the 40 s deadline expires (the phase-64
|
||||||
|
suite never hit this: its 0.15 s delay is under 2 s). Each attempt
|
||||||
|
here gets ``delay + 5 s``; the deadline loop, 0.5 s cadence, and
|
||||||
|
failure behavior are the conftest pattern verbatim."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last_err = "unknown"
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
httpx.get(url, timeout=delay_s + 5.0)
|
||||||
|
return
|
||||||
|
except Exception as e: # noqa: BLE001 — retry until deadline
|
||||||
|
last_err = str(e)
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise RuntimeError(f"server at {url} did not come up: {last_err}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def slow_llm(mock_llm: int) -> Iterator[int]:
|
||||||
|
"""The delay-injecting reverse proxy in front of the mock LLM
|
||||||
|
(``tests/e2e/slow_llm.py``) — this suite's timing fixture: the
|
||||||
|
per-request ``SLOW_DELAY_S`` makes every inter-frame gap
|
||||||
|
deterministic (see the module docstring). Mirrors the phase-64
|
||||||
|
fixture (process handling unchanged; only the readiness probe
|
||||||
|
adapts — see ``_wait_slow_http``)."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
|
||||||
|
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_slow_http(f"{SLOW_URL}/v1/models", float(SLOW_DELAY_S))
|
||||||
|
yield SLOW_PORT
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_server(mock_llm: int, slow_llm: int) -> Iterator[str]:
|
||||||
|
"""The real app under test — the conftest's session-app env shape
|
||||||
|
(including the phase-61/62 leak guards) with one swap: the LLM base
|
||||||
|
URL is the SLOW PROXY in front of the mock (the timing fixture)."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["BOR_ENVIRONMENT"] = "e2e"
|
||||||
|
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||||
|
env["BOR_LLM_BASE_URL"] = (
|
||||||
|
"https://aipi.reeseapps.com/v1"
|
||||||
|
if USE_REAL_LLM
|
||||||
|
else f"{SLOW_URL}/v1"
|
||||||
|
)
|
||||||
|
# The E2E mock's token-overlap embeddings have their own score
|
||||||
|
# distribution (phase 09) — the mock-calibrated threshold so the
|
||||||
|
# marker question stays grounded (conftest pattern).
|
||||||
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
|
# Phase 67: instant retry waits (no retry fires in this suite — the
|
||||||
|
# conftest env shape, kept).
|
||||||
|
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||||
|
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
|
||||||
|
env.setdefault(
|
||||||
|
"BOR_DATABASE_URL",
|
||||||
|
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||||
|
)
|
||||||
|
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||||
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||||
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||||
|
# Phase 61/62 leak guards (the conftest pattern): force the code
|
||||||
|
# defaults so an operator's local (gitignored) ``.env`` cannot leak
|
||||||
|
# into the app under test.
|
||||||
|
env["BOR_DOCS_REPO"] = ""
|
||||||
|
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||||
|
_Settings.model_fields["suggestions"].default
|
||||||
|
)
|
||||||
|
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||||
|
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||||
|
env["BOR_THEME"] = _Settings.model_fields["theme"].default
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"{APP_URL}/api/health")
|
||||||
|
yield APP_URL
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_url(app_server: str) -> str:
|
||||||
|
return app_server
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DB seeding (phase-37's TRUNCATE-then-seed pattern)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db: Session) -> None:
|
||||||
|
"""The phase-37 two-document pair, byte-identical (see the module
|
||||||
|
docstring)."""
|
||||||
|
md = Document(
|
||||||
|
source=SEED_SOURCE,
|
||||||
|
path=SEED_PATH,
|
||||||
|
full_path=f"/tmp/{SEED_PATH}",
|
||||||
|
title="AWS Route 53 Notes",
|
||||||
|
content=ROUTE53_CONTENT,
|
||||||
|
content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(),
|
||||||
|
indexed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(md)
|
||||||
|
db.flush()
|
||||||
|
# One chunk carrying the mock's own embedding → genuine token
|
||||||
|
# overlap between the marker question and this document.
|
||||||
|
db.add(
|
||||||
|
Chunk(
|
||||||
|
document_id=md.id,
|
||||||
|
position=0,
|
||||||
|
content=ROUTE53_CONTENT,
|
||||||
|
embedding=embed_text(ROUTE53_CONTENT),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# The referenced JSON: indexed, catalogued, readable — but NO chunks,
|
||||||
|
# so retrieval never puts it in context; its (source, path) sorts
|
||||||
|
# FIRST in the catalog — the line the mock reads.
|
||||||
|
db.add(
|
||||||
|
Document(
|
||||||
|
source=READ_SOURCE,
|
||||||
|
path=READ_PATH,
|
||||||
|
full_path=f"/tmp/{READ_PATH}",
|
||||||
|
title="Example Record File",
|
||||||
|
content=RECORD_FILE_CONTENT,
|
||||||
|
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
|
||||||
|
indexed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
|
||||||
|
"""Truncate the KB (plus the prompt-shaping tables and the
|
||||||
|
phase-55 auto-save rows), then re-seed — the E2E isolation
|
||||||
|
pattern (phase 37; ``saved_chats`` added since phase 55, when
|
||||||
|
every send auto-saves — a leftover row would pollute the
|
||||||
|
saved-chats suites on the shared DB)."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"TRUNCATE chunks, documents, query_log, "
|
||||||
|
"steering_notes, kb_overview, saved_chats"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
if seed is not None:
|
||||||
|
seed(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_tools_turn(page: Page, app_url: str) -> None:
|
||||||
|
"""Type the marker trigger and submit, then wait for the FIRST
|
||||||
|
``.tool-call`` line (the "Listing documents" line — the mock flow's
|
||||||
|
first call) to appear in ``#messages``.
|
||||||
|
|
||||||
|
Auth: phase 37's agent-tools E2E logs in as admin — mirrored
|
||||||
|
exactly (the mock flow + proxy are identical either way; the
|
||||||
|
house pattern for this flow wins)."""
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
page.fill("#message-input", MARKER_QUESTION)
|
||||||
|
page.click("#send-btn")
|
||||||
|
# The user bubble lands synchronously with the submit handler.
|
||||||
|
expect(page.locator(".msg.user .bubble").last).to_contain_text(MARKER_QUESTION)
|
||||||
|
# The first tool frame lands at ≈6 s (the proxy's per-request
|
||||||
|
# sleep) — generous timeout (≥2× headroom).
|
||||||
|
expect(page.locator("#messages .tool-call").first).to_be_visible(
|
||||||
|
timeout=TOOL_LINE_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_value(raw: str | None, pattern: str) -> int:
|
||||||
|
"""Parse the integer seconds out of one indicator text sample
|
||||||
|
(fail loud when the sample does not carry the pinned shape)."""
|
||||||
|
match = re.search(pattern, raw or "")
|
||||||
|
assert match is not None, f"no value matching {pattern} in {raw!r}"
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_twice(page: Page, selector: str, pattern: str) -> tuple[int, int]:
|
||||||
|
"""Two text samples of the FIRST element matching *selector*,
|
||||||
|
≥``SAMPLE_GAP_S`` (1.5 s) apart — the "the clock ticks while the
|
||||||
|
gap holds" check. The strictly-increasing assert stays in the
|
||||||
|
test."""
|
||||||
|
first = page.locator(selector).first
|
||||||
|
v1 = _elapsed_value(first.text_content(), pattern)
|
||||||
|
page.wait_for_timeout(int(SAMPLE_GAP_S * 1000))
|
||||||
|
v2 = _elapsed_value(first.text_content(), pattern)
|
||||||
|
return v1, v2
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_visible_hint_and_aria(page: Page, min_value: int, timeout_ms: int) -> int:
|
||||||
|
"""Poll until BOTH: the typing indicator's visible hint value is
|
||||||
|
≥ *min_value* AND the bubble's ``aria-label`` carries "still
|
||||||
|
thinking" — and return that value.
|
||||||
|
|
||||||
|
Why the combined poll (the deterministic same-moment pin): the
|
||||||
|
tool-frame branch overwrites the bubble's ``aria-label`` with its
|
||||||
|
status copy ("… is listing documents" when the ls frame lands at
|
||||||
|
≈12 s, "… is reading …" at ≈19 s), while the 1 s thinking clock
|
||||||
|
re-sets it to "… is still thinking (Ns)" on EVERY tick — the same
|
||||||
|
task that writes the visible ``.typing-elapsed`` span (app.js
|
||||||
|
``startThinkingClock``: the ``setAttribute`` precedes the span
|
||||||
|
write in one JS task). The two channels therefore read "still
|
||||||
|
thinking" simultaneously right after each tick, and a check that
|
||||||
|
starts in a tool-frame's overwrite window (this suite checks only
|
||||||
|
after the first tool line) must wait for the next tick — exactly
|
||||||
|
the house polling-until pattern (cf. the phase-44 flake fix for
|
||||||
|
transient windows)."""
|
||||||
|
deadline = time.monotonic() + timeout_ms / 1000
|
||||||
|
last: tuple[int | None, str] = (None, "")
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
el = page.locator("#typing-indicator .typing-elapsed")
|
||||||
|
if el.count() >= 1:
|
||||||
|
value = _elapsed_value(el.first.text_content(), TYPING_ELAPSED_PAT)
|
||||||
|
aria = (
|
||||||
|
page.locator("#typing-indicator .bubble").get_attribute("aria-label")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
last = (value, aria)
|
||||||
|
if value >= min_value and "still thinking" in aria:
|
||||||
|
return value
|
||||||
|
time.sleep(0.25)
|
||||||
|
raise AssertionError(
|
||||||
|
f"the visible hint (≥{min_value}s) and the 'still thinking' aria "
|
||||||
|
f"never aligned (last: value={last[0]} aria={last[1]!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_settled(page: Page) -> None:
|
||||||
|
"""The turn is complete: answer text in the bubble, button
|
||||||
|
recovered (the phase-37/48 pattern — the in-flight button is the
|
||||||
|
enabled Stop control, so the label assertion carries the settle
|
||||||
|
wait with an explicit timeout)."""
|
||||||
|
expect(page.locator(".msg.brain .bubble").last).not_to_have_text(
|
||||||
|
"", timeout=SETTLE_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=SETTLE_TIMEOUT_MS)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send", timeout=SETTLE_TIMEOUT_MS)
|
||||||
|
|
||||||
|
|
||||||
|
#: The FIRST tool line's suffix, pinned to the line ("first .tool-call
|
||||||
|
#: child of the .tool-calls container" — the "Listing documents" line;
|
||||||
|
#: the read frame's second line arms its OWN clock 5 s later and is
|
||||||
|
#: never the target here).
|
||||||
|
FIRST_LINE_SUFFIX = "#messages .tool-calls .tool-call:first-child .tool-elapsed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. The latest tool line grows a ticking "(Ns)" suffix during the gap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_line_shows_ticking_elapsed(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The owner's "it can look frozen" (sighted channel): after 5 s of
|
||||||
|
frame silence the latest tool line shows a ticking "(Ns)" suffix.
|
||||||
|
The first line's ("Listing documents") suffix is sampled twice ≥1.5
|
||||||
|
s apart: the second value is STRICTLY greater — the clock ticks
|
||||||
|
while the gap holds (the next frame is ≥6 s away, past the 5 s
|
||||||
|
threshold). The turn is still in flight at both samples: the button
|
||||||
|
is the enabled "Stop" control (the phase-48 contract)."""
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_reset_db(_seed)
|
||||||
|
_submit_tools_turn(page, app_url)
|
||||||
|
|
||||||
|
# The suffix appears 5 s after the line's own arm (1 s tick
|
||||||
|
# granularity) — the generous 12 s timeout keeps this unflaky.
|
||||||
|
expect(page.locator(FIRST_LINE_SUFFIX)).to_be_visible(timeout=SUFFIX_TIMEOUT_MS)
|
||||||
|
|
||||||
|
v1, v2 = _sample_twice(page, FIRST_LINE_SUFFIX, ELAPSED_PAT)
|
||||||
|
assert v2 > v1, f"the tool-line suffix did not tick: ({v1}s) -> ({v2}s)"
|
||||||
|
|
||||||
|
# Still in flight (a cheap invariant): the enabled Stop control.
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled()
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. The typing indicator shows the visible "Ns" hint (kept aria channel)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_typing_indicator_shows_visible_elapsed(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The 10 s pre-token clock promoted from aria-only to sighted
|
||||||
|
users: during the post-tool-frame gap (no thinking/delta frame has
|
||||||
|
arrived in the mock flow) the typing indicator shows a VISIBLE
|
||||||
|
ticking "Ns" hint. Poll until the visible value (≥10 — the gate)
|
||||||
|
and the bubble's aria-label (the kept "still thinking" sentence —
|
||||||
|
the screen-reader channel the unit pins protect) read together at
|
||||||
|
the same moment; a second value sample ≥1.5 s later is strictly
|
||||||
|
greater (the clock ticks while the gap holds — the next frame is
|
||||||
|
≥6 s away)."""
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_reset_db(_seed)
|
||||||
|
_submit_tools_turn(page, app_url)
|
||||||
|
|
||||||
|
# The hint first ticks at 10 s from submit (1 s granularity), but
|
||||||
|
# the check starts after the first tool line (≈12.4 s) — the ls
|
||||||
|
# frame has just overwritten the bubble's aria-label with its
|
||||||
|
# status copy, so wait for the NEXT tick, where the visible value
|
||||||
|
# and the aria channel align (same JS task — see the helper).
|
||||||
|
v1 = _wait_visible_hint_and_aria(page, 10, TYPING_HINT_TIMEOUT_MS)
|
||||||
|
assert v1 >= 10, f"the hint appeared below the 10 s gate: {v1}s"
|
||||||
|
|
||||||
|
# The clock ticks while the gap holds: ≥1.5 s later the value is
|
||||||
|
# strictly greater (the read frame is ≥6 s away; the first delta
|
||||||
|
# — which removes the whole indicator — is ≈25 s from submit).
|
||||||
|
page.wait_for_timeout(int(SAMPLE_GAP_S * 1000))
|
||||||
|
v2 = _elapsed_value(
|
||||||
|
page.locator("#typing-indicator .typing-elapsed").first.text_content(),
|
||||||
|
TYPING_ELAPSED_PAT,
|
||||||
|
)
|
||||||
|
assert v2 > v1, f"the typing hint did not tick: {v1}s -> {v2}s"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. On the answer's arrival BOTH indications settle (no stale timers)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_indicators_settle_when_the_answer_arrives(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""Settle contract: once the answer arrives the ``#typing-indicator``
|
||||||
|
is removed (the state machine owns it) and NO ``.tool-elapsed``
|
||||||
|
element exists anywhere in ``#messages`` (the delta frame's
|
||||||
|
settle removed them — both lines' worth); 1.5 s later still none
|
||||||
|
(the turn is over — the transition stopped the clock, no
|
||||||
|
re-appearance). The tool lines themselves remain, byte-identical
|
||||||
|
text — the permanent record (phase 37's pin), and the answer bubble
|
||||||
|
is complete."""
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_reset_db(_seed)
|
||||||
|
_submit_tools_turn(page, app_url)
|
||||||
|
|
||||||
|
# First the ticking state (the test-1 wait, reused)…
|
||||||
|
expect(page.locator(FIRST_LINE_SUFFIX)).to_be_visible(timeout=SUFFIX_TIMEOUT_MS)
|
||||||
|
|
||||||
|
# …then the answer arrives (≈25 s from submit — see the module
|
||||||
|
# docstring's timeline) and settles.
|
||||||
|
_wait_settled(page)
|
||||||
|
|
||||||
|
# Both indications are gone — the typing indicator removed by the
|
||||||
|
# first delta's setUiState(streaming), the suffixes by the delta
|
||||||
|
# frame's settle (the settle contract).
|
||||||
|
expect(page.locator("#typing-indicator")).to_have_count(0)
|
||||||
|
expect(page.locator("#messages .tool-elapsed")).to_have_count(0)
|
||||||
|
|
||||||
|
# No re-appearance 1.5 s later — the turn is over, the clock is
|
||||||
|
# stopped (settle cleared the interval, the transition nulled the
|
||||||
|
# wrap — no residue).
|
||||||
|
page.wait_for_timeout(int(SAMPLE_GAP_S * 1000))
|
||||||
|
expect(page.locator("#messages .tool-elapsed")).to_have_count(0)
|
||||||
|
expect(page.locator("#typing-indicator")).to_have_count(0)
|
||||||
|
|
||||||
|
# The tool lines remain — the permanent record, both present with
|
||||||
|
# their pinned text (phase 37's pattern).
|
||||||
|
lines = page.locator("#messages .tool-call")
|
||||||
|
expect(lines).to_have_count(2)
|
||||||
|
expect(lines.nth(0)).to_contain_text("Listing documents")
|
||||||
|
expect(lines.nth(1)).to_contain_text("Reading ")
|
||||||
|
expect(lines.nth(1)).to_contain_text(READ_SP)
|
||||||
|
|
||||||
|
# The answer bubble is complete (the mock's deterministic quote).
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(bubble).to_contain_text(ANSWER_PREFIX)
|
||||||
|
expect(bubble).to_contain_text(ANSWER_QUOTE)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. A reload restores the tool lines WITHOUT any timer (A6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_restored_turn_has_no_timer(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""A6 end-to-end: after the turn completes (the auto-save persisted
|
||||||
|
it — the local ``bor.chat.v1`` record is written synchronously at
|
||||||
|
the save points, so the reload sees the full conversation), a
|
||||||
|
RELOAD in the same context (phase-14 local session) re-renders the
|
||||||
|
persisted tool lines through the SAME ``appendToolLine`` — and they
|
||||||
|
carry NO ``.tool-elapsed`` element (the restore path never arms the
|
||||||
|
clock — the live-only contract), with the page otherwise settled:
|
||||||
|
no ``#typing-indicator``, button "Send", answer intact."""
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_reset_db(_seed)
|
||||||
|
_submit_tools_turn(page, app_url)
|
||||||
|
|
||||||
|
# Let it COMPLETE (answer rendered — the phase-50/55 auto-save
|
||||||
|
# contract).
|
||||||
|
_wait_settled(page)
|
||||||
|
|
||||||
|
# Reload — the same context keeps bor.chat.v1 (the phase-14
|
||||||
|
# restore path).
|
||||||
|
page.reload()
|
||||||
|
expect(page.locator("#empty-state")).to_be_hidden(timeout=RESTORE_TIMEOUT_MS)
|
||||||
|
|
||||||
|
# The persisted record re-renders the tool lines, in saved order
|
||||||
|
# (the phase-37 reload pin, mirrored)…
|
||||||
|
restored = page.locator("#messages .tool-call")
|
||||||
|
expect(restored).to_have_count(2, timeout=RESTORE_TIMEOUT_MS)
|
||||||
|
expect(restored.nth(0)).to_contain_text("Listing documents")
|
||||||
|
expect(restored.nth(1)).to_contain_text("Reading ")
|
||||||
|
expect(restored.nth(1)).to_contain_text(READ_SP)
|
||||||
|
|
||||||
|
# …with NO timer (A6 — the restore never arms the clock) and the
|
||||||
|
# page otherwise settled.
|
||||||
|
expect(page.locator("#messages .tool-elapsed")).to_have_count(0)
|
||||||
|
expect(page.locator("#typing-indicator")).to_have_count(0)
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send")
|
||||||
|
|
||||||
|
# The answer is intact (phase-14 restore path).
|
||||||
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
|
expect(bubble).to_contain_text(ANSWER_PREFIX)
|
||||||
|
expect(bubble).to_contain_text(ANSWER_QUOTE)
|
||||||
|
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""Unit: the phase-87 big-read progress contract (source-level pins).
|
||||||
|
|
||||||
|
Phase 87 (TODO.md L5 — "Need indication that prompt processing is
|
||||||
|
happening during a big read, it can look frozen."): during any
|
||||||
|
frameless gap of an in-flight turn the UI must show, to sighted AND
|
||||||
|
screen-reader users, that processing is ongoing — a ticking
|
||||||
|
elapsed-seconds suffix on the latest tool line (after 5s of silence)
|
||||||
|
and a VISIBLE elapsed hint on the typing indicator (the existing 10s
|
||||||
|
pre-token clock promoted from aria-only to visible text). Both settle
|
||||||
|
the instant content resumes; persisted/restored turns never show
|
||||||
|
timers (A6 — the arming call lives only in the live frame branches).
|
||||||
|
|
||||||
|
Locked decisions (phase overview): frontend-only (A4 — the server, the
|
||||||
|
SSE event set, and the 120s guard are byte-identical), named
|
||||||
|
thresholds owned by the state machine (A5 — ``TOOL_LINE_ELAPSED_AFTER_MS``),
|
||||||
|
and the exact tool-line template literals stay byte-identical (the
|
||||||
|
suffix is a separate element the clock appends; ``appendToolLine``
|
||||||
|
renders exactly as before, which is also what makes A6 fall out for
|
||||||
|
free on restore).
|
||||||
|
|
||||||
|
This module reads ``frontend/assets/app.js`` + ``styles.css`` as text
|
||||||
|
(no browser — house pattern, cf. test_frontend_feedback.py); the live
|
||||||
|
behavior is E2E-gated by ``tests/e2e/test_big_read_progress.py``
|
||||||
|
(task 03). Task 01 pins the visible typing-indicator hint below;
|
||||||
|
task 02 extends the module with the tool-line clock pins.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||||
|
APP_JS = FRONTEND / "assets" / "app.js"
|
||||||
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||||
|
|
||||||
|
|
||||||
|
def _js() -> str:
|
||||||
|
return APP_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _css() -> str:
|
||||||
|
return STYLES_CSS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- task 01: the visible typing-indicator elapsed hint ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> None:
|
||||||
|
"""Phase 87 task 01: after 10s of pre-token silence the typing
|
||||||
|
bubble gains a visible ``.typing-elapsed`` "Ns" hint — ensured
|
||||||
|
(created at most once, appended as the bubble's LAST child, after
|
||||||
|
the three dot spans) and written with ``textContent`` only. No
|
||||||
|
``innerHTML`` anywhere on the typing bubble: the ``addTyping``
|
||||||
|
template writes ``wrap.innerHTML`` (the static skeleton), and the
|
||||||
|
clock must never rewrite turn data into the DOM as HTML."""
|
||||||
|
js = _js()
|
||||||
|
assert ".typing-elapsed" in js, "the hint class must exist in app.js"
|
||||||
|
# The ensure pattern: find-or-create, then append to the bubble.
|
||||||
|
assert 'bubble.querySelector(".typing-elapsed")' in js
|
||||||
|
assert 'el.className = "typing-elapsed"' in js
|
||||||
|
assert "document.createElement(\"span\")" in js
|
||||||
|
assert "bubble.appendChild(el)" in js, "the hint is the bubble's last child"
|
||||||
|
# textContent-only write (the bubble is role="status" — announced).
|
||||||
|
assert 'el.textContent = secs + "s"' in js, (
|
||||||
|
"the hint text is the plain ticking \"Ns\" (A5 — no added wording)"
|
||||||
|
)
|
||||||
|
# No innerHTML on the typing bubble — the skeleton template
|
||||||
|
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
|
||||||
|
# write, and it must not gain a second one.
|
||||||
|
assert js.count("bubble.innerHTML") == 0, (
|
||||||
|
"the clock must never assign innerHTML to the typing bubble"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_typing_elapsed_hint_lives_in_start_thinking_clock() -> None:
|
||||||
|
"""The hint belongs to the EXISTING 10s pre-token clock (A5 — the
|
||||||
|
typing hint reuses its ``secs < 10`` gate): the ensure/write logic
|
||||||
|
sits inside ``startThinkingClock``'s 1s interval, right after the
|
||||||
|
aria-label update. ``addTyping``/``removeTyping`` stay untouched —
|
||||||
|
the hint lives and dies with the indicator the state machine owns."""
|
||||||
|
js = _js()
|
||||||
|
fn = js.find("function startThinkingClock")
|
||||||
|
assert fn != -1, "startThinkingClock must exist"
|
||||||
|
body = js[fn : js.find("\n}\n", fn)]
|
||||||
|
assert ".typing-elapsed" in body, "the hint is ensured inside the clock"
|
||||||
|
assert 'el.textContent = secs + "s"' in body
|
||||||
|
# The 10s gate is kept (aria + visible share it).
|
||||||
|
assert "secs < 10" in body
|
||||||
|
# addTyping/removeTyping never touch the hint (state-machine-owned).
|
||||||
|
for name in ("function addTyping", "function removeTyping"):
|
||||||
|
f = js.find(name)
|
||||||
|
assert f != -1, f"{name} must exist"
|
||||||
|
b = js[f : js.find("\n}\n", f)]
|
||||||
|
assert ".typing-elapsed" not in b, f"{name} must stay untouched"
|
||||||
|
|
||||||
|
|
||||||
|
def test_typing_aria_label_contract_stays_byte_identical() -> None:
|
||||||
|
"""The pinned aria channel survives the promotion to visible text —
|
||||||
|
both users, same clock (mirrors the phase-39 brand pin so this
|
||||||
|
module is self-documenting): the exact template literal, built at
|
||||||
|
call time via brand(), after the 10s gate."""
|
||||||
|
js = _js()
|
||||||
|
assert "`${brand()} is still thinking (${secs}s)`" in js
|
||||||
|
assert "secs < 10" in js, "the hint must only appear after 10s of silence"
|
||||||
|
assert "role=\"status\"" in js, "the typing bubble stays role=status"
|
||||||
|
|
||||||
|
|
||||||
|
def test_typing_elapsed_css_rule_is_the_aa_pairing() -> None:
|
||||||
|
"""The ``.typing-elapsed`` rule: small mono in ink-soft on the
|
||||||
|
bubble's --surface (the documented ≥4.5:1 AA pairing), the same
|
||||||
|
language as every status line. Plain text — ``animation: none`` +
|
||||||
|
no background, and the dot-geometry reset (the span is a sibling of
|
||||||
|
the dots inside the .typing bubble, so without the reset it would
|
||||||
|
render as an 8px bouncing dot, not a hint)."""
|
||||||
|
css = _css()
|
||||||
|
block = re.search(r"\.typing-elapsed \{([\s\S]*?)\n\}", css)
|
||||||
|
assert block, "styles.css must style .typing-elapsed"
|
||||||
|
body = block.group(1)
|
||||||
|
for prop in (
|
||||||
|
"font-family: var(--mono)",
|
||||||
|
"font-size: 0.75rem",
|
||||||
|
"color: var(--ink-soft)",
|
||||||
|
"margin-left: 0.5rem",
|
||||||
|
# the dot-geometry reset (see the rule's comment):
|
||||||
|
"width: auto",
|
||||||
|
"background: none",
|
||||||
|
"opacity: 1",
|
||||||
|
"animation: none",
|
||||||
|
):
|
||||||
|
assert prop in body, f".typing-elapsed must keep {prop}"
|
||||||
|
# Reduced motion: the hint is plain text, not motion — it keeps full
|
||||||
|
# opacity (the AA pairing) while the dots calm to 0.7.
|
||||||
|
blocks = re.findall(
|
||||||
|
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
|
||||||
|
)
|
||||||
|
assert any(".typing span.typing-elapsed" in b and "opacity: 1" in b for b in blocks), (
|
||||||
|
"the hint must keep full contrast under reduced motion"
|
||||||
|
)
|
||||||
|
# The existing reduced-motion dot fallback is untouched (phase 06 pin).
|
||||||
|
assert any(".typing span" in b and "animation: none" in b for b in blocks)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- task 02: the per-tool-line elapsed clock ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_line_elapsed_constant_is_named_and_five_seconds() -> None:
|
||||||
|
"""A5: the visible "processing" threshold is the NAMED module constant
|
||||||
|
``TOOL_LINE_ELAPSED_AFTER_MS = 5_000`` (below it a frameless gap reads
|
||||||
|
as normal latency; at/above it the latest line proves it is still
|
||||||
|
processing) — a pinned constant, not a magic number in the tick."""
|
||||||
|
js = _js()
|
||||||
|
assert re.search(r"TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000", js), (
|
||||||
|
"the 5s threshold must be the named constant TOOL_LINE_ELAPSED_AFTER_MS"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_line_clock_state_is_turn_scoped_module_state() -> None:
|
||||||
|
"""One clock per turn: the three state vars live at module scope next
|
||||||
|
to the existing ``thinkingClock`` / ``turnTimeout`` state (re-armed
|
||||||
|
per `tool` frame, so each line counts its OWN silence)."""
|
||||||
|
js = _js()
|
||||||
|
for decl in (
|
||||||
|
"let toolLineTimer = 0",
|
||||||
|
"let toolLineStart = 0",
|
||||||
|
"let toolLineWrap = null",
|
||||||
|
):
|
||||||
|
assert decl in js, f"{decl} must be module-scope state"
|
||||||
|
anchor = js.find("let turnTimeoutCb = null")
|
||||||
|
assert anchor != -1, "the existing timer state must exist"
|
||||||
|
assert (
|
||||||
|
js.find("let toolLineTimer = 0") - anchor < 1500
|
||||||
|
), "the clock state sits next to the existing timer state"
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_has_exactly_one_live_call_site() -> None:
|
||||||
|
"""A6 (live-only): ``armToolLineClock(`` appears EXACTLY twice in
|
||||||
|
app.js — the definition + the single live call site in the `tool`
|
||||||
|
frame branch (right after the line's append). The restore path
|
||||||
|
(phase 14, ``renderStoredMessage``) never arms: a restored line reads
|
||||||
|
exactly as it did pre-phase (the permanent record, no stale timer)."""
|
||||||
|
js = _js()
|
||||||
|
assert js.count("armToolLineClock(") == 2, (
|
||||||
|
"the arm must be the definition + exactly one live call site"
|
||||||
|
)
|
||||||
|
tool_idx = js.find('ev.type === "tool"')
|
||||||
|
delta_idx = js.find('ev.type === "delta"')
|
||||||
|
branch = js[tool_idx:delta_idx]
|
||||||
|
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
|
||||||
|
append = branch.find("appendToolLine(wrap, name, argument)")
|
||||||
|
arm = branch.find("armToolLineClock(wrap)")
|
||||||
|
assert -1 < append < arm, (
|
||||||
|
"the arm follows the line's append — the baseline resets per line"
|
||||||
|
)
|
||||||
|
restore_fn = js.find("function renderStoredMessage")
|
||||||
|
restore_end = js.find("function restoreConversation")
|
||||||
|
assert -1 < restore_fn < restore_end
|
||||||
|
assert "armToolLineClock" not in js[restore_fn:restore_end], (
|
||||||
|
"the restore path must never arm the clock (A6)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settle_covers_the_three_live_frame_branches() -> None:
|
||||||
|
"""Settle = REMOVE the suffix: the `thinking`, `retry`, and `delta`
|
||||||
|
branches each call ``settleToolLine()`` at the TOP of the branch
|
||||||
|
(a frame arrived — the line is no longer "processing"), and the
|
||||||
|
settle clears the interval + removes every ``.tool-elapsed`` from the
|
||||||
|
wrap. Definition + three branches → at least 4 occurrences."""
|
||||||
|
js = _js()
|
||||||
|
assert js.count("settleToolLine(") >= 4, (
|
||||||
|
"the definition + the three frame branches must settle"
|
||||||
|
)
|
||||||
|
for branch_open, branch_close, first_work in (
|
||||||
|
('ev.type === "thinking"', 'ev.type === "tool"', "thinkingAcc += ev.text"),
|
||||||
|
('ev.type === "retry"', 'ev.type === "delta"', "const attempt"),
|
||||||
|
('ev.type === "delta"', 'ev.type === "done"', "acc += ev.text"),
|
||||||
|
):
|
||||||
|
start = js.find(branch_open)
|
||||||
|
end = js.find(branch_close, start)
|
||||||
|
assert -1 < start < end, f"the {branch_open!r} branch must exist"
|
||||||
|
seg = js[start:end]
|
||||||
|
assert "settleToolLine();" in seg, f"the {branch_open!r} branch must settle"
|
||||||
|
assert seg.index("settleToolLine();") < seg.index(first_work), (
|
||||||
|
f"the settle sits at the TOP of the {branch_open!r} branch, before its "
|
||||||
|
"content work (the line is no longer 'processing' the instant the "
|
||||||
|
"frame arrives)"
|
||||||
|
)
|
||||||
|
fn = js.find("function settleToolLine")
|
||||||
|
assert fn != -1
|
||||||
|
body = js[fn : js.find("\n}\n", fn)]
|
||||||
|
assert "clearInterval(toolLineTimer)" in body, "the settle drops the interval"
|
||||||
|
assert 'querySelectorAll?.(".tool-elapsed")' in body and "el.remove()" in body, (
|
||||||
|
"the settle REMOVES every suffix (a frozen timestamp is noise)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_lives_in_setui_state_next_to_the_other_stops() -> None:
|
||||||
|
"""House invariant — "a stuck button is impossible" applied to a
|
||||||
|
stuck timer: ``stopToolLineClock()`` is called inside
|
||||||
|
``setUiState`` so EVERY transition stops/clears the clock. Documented
|
||||||
|
pin: the call sits in the function body AFTER the
|
||||||
|
``stopThinkingClock();`` line and BEFORE the first button write
|
||||||
|
(``sendBtn.disabled``) — stable under comment churn, and it forces
|
||||||
|
the call into the timer-clear block rather than a later branch.
|
||||||
|
``stopToolLineClock`` itself settles + forgets the wrap (no residue
|
||||||
|
across turns)."""
|
||||||
|
js = _js()
|
||||||
|
fn = js.find("export function setUiState")
|
||||||
|
assert fn != -1
|
||||||
|
body = js[fn : js.find("\n}\n", fn)]
|
||||||
|
assert "stopToolLineClock();" in body, "setUiState must stop the clock"
|
||||||
|
assert (
|
||||||
|
body.index("stopThinkingClock();")
|
||||||
|
< body.index("stopToolLineClock();")
|
||||||
|
< body.index("sendBtn.disabled")
|
||||||
|
), (
|
||||||
|
"the stop sits in setUiState's clear block (after stopThinkingClock, "
|
||||||
|
"before the button writes)"
|
||||||
|
)
|
||||||
|
sf = js.find("function stopToolLineClock")
|
||||||
|
assert sf != -1
|
||||||
|
sbody = js[sf : js.find("\n}\n", sf)]
|
||||||
|
assert "settleToolLine();" in sbody, "stop settles (clear interval + remove suffixes)"
|
||||||
|
assert "toolLineWrap = null" in sbody, "stop forgets the wrap — no residue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suffix_is_textcontent_only_on_the_latest_line() -> None:
|
||||||
|
"""The suffix is a parenthesized "(Ns)" status suffix (e.g. "📄
|
||||||
|
Reading src/app.py (12s)" — the typing hint stays bare "Ns") written
|
||||||
|
with ``textContent`` on a ``createElement`` span — never innerHTML —
|
||||||
|
appended as a SIBLING after the line's existing children (the pinned
|
||||||
|
template text + the <code> argument). It targets the LATEST line only
|
||||||
|
(`.tool-call:last-child` — older lines keep their permanent record),
|
||||||
|
and a missing container (New-Chat click mid-gap) makes the tick a
|
||||||
|
no-op (the null-safe chain is the guard)."""
|
||||||
|
js = _js()
|
||||||
|
fn = js.find("function armToolLineClock")
|
||||||
|
assert fn != -1, "armToolLineClock must exist"
|
||||||
|
body = js[fn : js.find("\n}\n", fn)]
|
||||||
|
assert 'querySelector?.(".tool-calls .tool-call:last-child")' in body, (
|
||||||
|
"the suffix targets the LATEST line only"
|
||||||
|
)
|
||||||
|
assert 'line.querySelector(".tool-elapsed")' in body, "find-or-create the suffix span"
|
||||||
|
assert 'el.className = "tool-elapsed"' in body
|
||||||
|
assert 'document.createElement("span")' in body, "the span is createElement'd"
|
||||||
|
assert "line.appendChild(el)" in body, (
|
||||||
|
"the suffix is a SIBLING appended AFTER the line's existing children"
|
||||||
|
)
|
||||||
|
assert "`(${secs}s)`" in body, ("the parenthesized '(Ns)' suffix, textContent-built")
|
||||||
|
assert "innerHTML" not in body, "no HTML write in the clock — textContent only"
|
||||||
|
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body, "the tick gates on the named constant"
|
||||||
|
assert "toolLineWrap?.querySelector?" in body, (
|
||||||
|
"null-safe: a wrap reset mid-gap (New Chat) makes the tick a no-op"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_line_template_literals_stay_byte_identical() -> None:
|
||||||
|
"""The exact ``line.textContent = "…"`` template literals survive —
|
||||||
|
mirrors test_frontend_tool_states.py so this module is
|
||||||
|
self-documenting: the suffix is a separate element the clock appends,
|
||||||
|
and a rewrite of the line text would break the emoji-guard strip set
|
||||||
|
(it strips precisely those) plus the phase-37 pins. The helper stays
|
||||||
|
clock-free — that is also what makes A6 fall out for free on restore."""
|
||||||
|
js = _js()
|
||||||
|
fn = js.find("function appendToolLine")
|
||||||
|
assert fn != -1, "appendToolLine must exist"
|
||||||
|
body = js[fn : js.find("\n}\n", fn)]
|
||||||
|
for lit in (
|
||||||
|
'line.textContent = "📄 Reading "',
|
||||||
|
'line.textContent = "🔎 Searching for "',
|
||||||
|
'line.textContent = "🔎 Listing documents in "',
|
||||||
|
'line.textContent = "🔎 Listing documents"',
|
||||||
|
):
|
||||||
|
assert lit in body, f"the pinned tool-line literal {lit!r} must stay byte-identical"
|
||||||
|
assert "tool-elapsed" not in body, "appendToolLine stays clock-free (A6)"
|
||||||
|
assert "armToolLineClock" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_elapsed_css_rule_is_the_aa_pairing() -> None:
|
||||||
|
"""The ``.tool-elapsed`` rule (next to the .tool-call rules): small
|
||||||
|
mono in ink-soft — the same AA pairing as the task-01 hint — with
|
||||||
|
``white-space: nowrap`` so "(12s)" never wraps in the row."""
|
||||||
|
css = _css()
|
||||||
|
block = re.search(r"\.tool-elapsed \{([\s\S]*?)\n\}", css)
|
||||||
|
assert block, "styles.css must style .tool-elapsed"
|
||||||
|
body = block.group(1)
|
||||||
|
for prop in (
|
||||||
|
"font-family: var(--mono)",
|
||||||
|
"font-size: 0.75rem",
|
||||||
|
"color: var(--ink-soft)",
|
||||||
|
"margin-left: 0.5rem",
|
||||||
|
"white-space: nowrap",
|
||||||
|
):
|
||||||
|
assert prop in body, f".tool-elapsed must keep {prop}"
|
||||||
Reference in New Issue
Block a user