From 3a81793565c7ae78a78d127e3d174f86f82d8096 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 14 Sep 2026 01:27:16 -0400 Subject: [PATCH] phase: 109_turn_progress_loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All gates green. Final report: **Phase 109 — final verification pass (all 3 tasks were already complete; no re-implementation needed)** - Verified D15 (thinking-handler re-open `block.open = true` after `ensureThinkingBlock`; delta keeps closing; "never reopens" narrative removed; restore path still collapses) and D16 (single static `#turn-loader` in composer row, `aria-hidden`, sole `turnLoader.hidden = !inFlight` write in `setUiState`; CSS reuses `typing` keyframes + reduced-motion override after the main rule + provenance; global `[hidden]` guard intact; mock `TURN_PROGRESS_TRIGGER` repro marker; `app/` untouched — pure UI phase). - Unit pins: `tests/unit/test_frontend_turn_loader.py` (9 tests) — green via full run. - `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` → **3 passed** (isolation, DB up): loader visible send→tool-gap→final-thinking, hidden after done; re-opened scratchpad carries `marker-thought-42`/`marker-final-thought-42`; `#send-status` carries state text, back to empty idle; reduced-motion context stills dots (computed `animation-name: none`) with loader still visible. - Regressions in isolation: `test_thinking_display` 5 passed, `test_stop_generation` 3 passed, `test_big_read_progress` 4 passed, `test_loading_feedback` 5 passed. - `uv run pytest` → **2314 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings. - All terminal paths (done→idle L2554, stream error→error L2543, timeout→error L2247) funnel through `setUiState` — never-stale by construction. - No defects found; no code changes made in this pass. Commit + phase-dir move left to the harness per pipeline rules (working tree carries all changes). - Next pending phase: `108_history_wire_check` (still in `todo/`; out of scope here). --- .../109_turn_progress_loader/00_phase.md | 0 .../01_thinking_block_reexpand.md | 0 .../02_turn_active_loader.md | 0 .../03_e2e_and_gate.md | 0 .../109_turn_progress_loader__00_phase.a1.err | 0 .../109_turn_progress_loader__00_phase.a1.md | 12 + ...turn_progress_loader__00_phase.a1.validate | 101 ++++++ ..._loader__01_thinking_block_reexpand.a1.err | 0 ...s_loader__01_thinking_block_reexpand.a1.md | 18 + ...er__01_thinking_block_reexpand.a1.validate | 101 ++++++ ...gress_loader__02_turn_active_loader.a1.err | 0 ...ogress_loader__02_turn_active_loader.a1.md | 18 + ..._loader__02_turn_active_loader.a1.validate | 101 ++++++ ...rn_progress_loader__03_e2e_and_gate.a1.err | 0 ...urn_progress_loader__03_e2e_and_gate.a1.md | 10 + ...ogress_loader__03_e2e_and_gate.a1.validate | 101 ++++++ frontend/assets/app.js | 45 ++- frontend/assets/styles.css | 51 +++ frontend/index.html | 12 + tests/e2e/mock_llm.py | 256 ++++++++++++++ tests/e2e/test_turn_progress_loader.py | 321 ++++++++++++++++++ tests/unit/test_frontend_turn_loader.py | 319 +++++++++++++++++ 22 files changed, 1459 insertions(+), 7 deletions(-) rename .agents/phases/{todo => complete}/109_turn_progress_loader/00_phase.md (100%) rename .agents/phases/{todo => complete}/109_turn_progress_loader/01_thinking_block_reexpand.md (100%) rename .agents/phases/{todo => complete}/109_turn_progress_loader/02_turn_active_loader.md (100%) rename .agents/phases/{todo => complete}/109_turn_progress_loader/03_e2e_and_gate.md (100%) create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.err create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.md create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.validate create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.err create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.md create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.validate create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.err create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.md create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.validate create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.err create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.md create mode 100644 .agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.validate create mode 100644 tests/e2e/test_turn_progress_loader.py create mode 100644 tests/unit/test_frontend_turn_loader.py diff --git a/.agents/phases/todo/109_turn_progress_loader/00_phase.md b/.agents/phases/complete/109_turn_progress_loader/00_phase.md similarity index 100% rename from .agents/phases/todo/109_turn_progress_loader/00_phase.md rename to .agents/phases/complete/109_turn_progress_loader/00_phase.md diff --git a/.agents/phases/todo/109_turn_progress_loader/01_thinking_block_reexpand.md b/.agents/phases/complete/109_turn_progress_loader/01_thinking_block_reexpand.md similarity index 100% rename from .agents/phases/todo/109_turn_progress_loader/01_thinking_block_reexpand.md rename to .agents/phases/complete/109_turn_progress_loader/01_thinking_block_reexpand.md diff --git a/.agents/phases/todo/109_turn_progress_loader/02_turn_active_loader.md b/.agents/phases/complete/109_turn_progress_loader/02_turn_active_loader.md similarity index 100% rename from .agents/phases/todo/109_turn_progress_loader/02_turn_active_loader.md rename to .agents/phases/complete/109_turn_progress_loader/02_turn_active_loader.md diff --git a/.agents/phases/todo/109_turn_progress_loader/03_e2e_and_gate.md b/.agents/phases/complete/109_turn_progress_loader/03_e2e_and_gate.md similarity index 100% rename from .agents/phases/todo/109_turn_progress_loader/03_e2e_and_gate.md rename to .agents/phases/complete/109_turn_progress_loader/03_e2e_and_gate.md diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.err b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.md b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.md new file mode 100644 index 0000000..f95ca68 --- /dev/null +++ b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.md @@ -0,0 +1,12 @@ +All gates green. Final report: + +**Phase 109 — final verification pass (all 3 tasks were already complete; no re-implementation needed)** + +- Verified D15 (thinking-handler re-open `block.open = true` after `ensureThinkingBlock`; delta keeps closing; "never reopens" narrative removed; restore path still collapses) and D16 (single static `#turn-loader` in composer row, `aria-hidden`, sole `turnLoader.hidden = !inFlight` write in `setUiState`; CSS reuses `typing` keyframes + reduced-motion override after the main rule + provenance; global `[hidden]` guard intact; mock `TURN_PROGRESS_TRIGGER` repro marker; `app/` untouched — pure UI phase). +- Unit pins: `tests/unit/test_frontend_turn_loader.py` (9 tests) — green via full run. +- `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` → **3 passed** (isolation, DB up): loader visible send→tool-gap→final-thinking, hidden after done; re-opened scratchpad carries `marker-thought-42`/`marker-final-thought-42`; `#send-status` carries state text, back to empty idle; reduced-motion context stills dots (computed `animation-name: none`) with loader still visible. +- Regressions in isolation: `test_thinking_display` 5 passed, `test_stop_generation` 3 passed, `test_big_read_progress` 4 passed, `test_loading_feedback` 5 passed. +- `uv run pytest` → **2314 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings. +- All terminal paths (done→idle L2554, stream error→error L2543, timeout→error L2247) funnel through `setUiState` — never-stale by construction. +- No defects found; no code changes made in this pass. Commit + phase-dir move left to the harness per pipeline rules (working tree carries all changes). +- Next pending phase: `108_history_wire_check` (still in `todo/`; out of scope here). diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.validate b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.validate new file mode 100644 index 0000000..aee2d10 --- /dev/null +++ b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__00_phase.a1.validate @@ -0,0 +1,101 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 154 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 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 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 128 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 215 3 99% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 172 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 327 0 100% +-------------------------------------------------- +TOTAL 4018 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.err b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.md b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.md new file mode 100644 index 0000000..ac6e8f1 --- /dev/null +++ b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.md @@ -0,0 +1,18 @@ +Task 01 is complete. All gates green. + +**Report — phase 109, task 01 (thinking block re-expand)** + +- `frontend/assets/app.js` — `thinking` SSE handler gains `block.open = true;` immediately after `ensureThinkingBlock(wrap)` (idempotent: no-op pre-delta, re-opens after a `delta` closed it — the D15 toggle fix for the TODO.md L3 freeze); handler comment updated to state the toggle contract. +- `closeThinkingBlock` body kept byte-identical; its section comment + the delta-handler inline comment updated — "never reopens" narrative removed, toggle contract documented (thinking opens / delta closes). +- Untouched, as mandated: `renderStoredMessage` restore path (still `block.open = false`), the follow-the-tail pin logic (`THINKING_NEAR_BOTTOM_PX` / `isThinkingNearBottom` / pre-render `block.open &&` capture), `delta` handler call site. +- New `tests/unit/test_frontend_turn_loader.py` — 5 unit pins (house read-the-assets pattern): re-open after `ensureThinkingBlock` (order asserted), delta still closes, docstring says toggle not one-way (no "never reopens" anywhere in app.js), restore path still collapses, pin logic untouched. + +**Results** +- `uv run pytest tests/unit/test_frontend_turn_loader.py -v` → 5 passed +- `uv run pytest tests/unit/test_frontend_tool_states.py tests/unit/test_frontend_feedback.py tests/unit/test_frontend_scroll.py tests/unit/test_big_read_progress.py` (+ thinking_scroll, chat_persistence, shared_page) → 92 passed +- `uv run pytest` → exit 0, ~2300 tests, 100% +- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90% gate) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings + +- No deviations; no `.agents` planning files touched; no git add/commit (left for the harness). +- Next pending task: `109_turn_progress_loader/02_turn_active_loader.md` diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.validate b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.validate new file mode 100644 index 0000000..a46fc02 --- /dev/null +++ b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__01_thinking_block_reexpand.a1.validate @@ -0,0 +1,101 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 81%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 154 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 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 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 128 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 215 3 99% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 172 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 327 0 100% +-------------------------------------------------- +TOTAL 4018 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.err b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.md b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.md new file mode 100644 index 0000000..fc02638 --- /dev/null +++ b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.md @@ -0,0 +1,18 @@ +All green. Final report: + +**Phase 109, Task 02 — persistent in-turn loader (D16): complete** + +- `frontend/index.html`: one static `` in the composer's status row (after `#send-btn`, companion of the `#send-status` line); never JS-built, hidden by default +- `frontend/assets/app.js`: `const turnLoader` lookup with the other module-top lookups; `turnLoader.hidden = !inFlight;` inside `setUiState` next to the `is-stop` toggle — the SOLE writer (verified: exactly 1 occurrence); typing bubble, `SEND_STATUS`, `#send-status` untouched +- `frontend/assets/styles.css`: `.turn-loader` rule next to the typing-dots rules — compact 4px three-dot indicator reusing the existing `typing` keyframes (element = middle dot, `::before`/`::after` = outer), phase-109/`TODO.md` L3 provenance comment, reduced-motion variant in the existing typing `@media` block (static dots, no pulse); the global `[hidden]{display:none!important}` rule guarantees the `hidden` attribute beats the flex display +- `tests/unit/test_frontend_turn_loader.py`: extended with 4 task-02 pins (exactly-one static loader, `setUiState` sole-owner single-write-site check, CSS reused-animation + reduced-motion + provenance, aria-hidden + byte-unchanged `#send-status`) + +**Results:** +- `uv run pytest tests/unit/test_frontend_turn_loader.py tests/unit/test_frontend_tool_states.py tests/unit/test_theme_frontend.py -v` → 26 passed +- `uv run pytest` (full) → 2314 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90%) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings +- Regression E2E in isolation (`test_thinking_display` / `test_stop_generation` / `test_loading_feedback` / `test_big_read_progress`) → 17 passed + +**Decisions:** loader placed inside the composer form after the button (a `
` can't be valid phrasing content inside the button that holds `#send-status`); no per-handler cleanup anywhere — visibility is owned solely by `setUiState` (§7.4 by construction). + +**Next pending task:** `.agents/phases/todo/109_turn_progress_loader/03_e2e_and_gate.md` diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.validate b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.validate new file mode 100644 index 0000000..aee2d10 --- /dev/null +++ b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__02_turn_active_loader.a1.validate @@ -0,0 +1,101 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 154 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 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 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 128 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 215 3 99% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 172 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 327 0 100% +-------------------------------------------------- +TOTAL 4018 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.err b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.md b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.md new file mode 100644 index 0000000..b240682 --- /dev/null +++ b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.md @@ -0,0 +1,10 @@ +All green. Task 03 was the final task of phase 109. Per harness rules, I'm leaving the commit and the phase-file move to the harness (all changes are in the working tree). + +**Report — Phase 109, Task 03 (E2E + gate)** + +- **Mock marker** (`tests/e2e/mock_llm.py`): new `TURN_PROGRESS_TRIGGER` ("answer first, then list, then think") + `_turn_progress_flow`/`_turn_progress_stream` — deterministic reported repro with baked delays: call 1 (2 s pre-delay → 3 content chunks → no-arg `ls`), call 2 (7 s tool gap past the phase-87 counter's 5 s gate → 10×0.3 s thinking → 3 content chunks ending in `marker-progress-42` → 3×0.3 s FINAL thinking). Documented in the module docstring; checked before the plain `TOOLS_TRIGGER` flow; needs `` (deflected turns never hit it); verified no existing question/fixture collides. +- **E2E** (`tests/e2e/test_turn_progress_loader.py`, new): 3 tests — loader visible from send through the tool gap (start-state window, phase-87 `(Ns)` counter on the tool line, non-empty `#send-status`); post-delta thinking re-open with visible loader + clean terminal (loader hidden, both sentinels in bubble/scratchpad, button "Send", status idle); a11y split (`aria-hidden` loader, `#send-status` back to empty idle shape) + reduced-motion context (loader visible mid-turn, computed `animation-name: none`). +- **Defect fixed** (task-02 work): the reduced-motion `.turn-loader` override sat *before* the main rule in `styles.css`, so `animation: typing` won the cascade — dots kept pulsing under reduced motion. Moved the override into a media block after the main rule (comment documents the cascade rule). Caught by the new E2E. +- **Gate results**: `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` → 3 passed (×3 consecutive runs, DB up); `uv run pytest` → 2314 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90%); regressions in isolation: `test_thinking_display` 5, `test_stop_generation` 3, `test_big_read_progress` 4, `test_loading_feedback` 5 — all passed; `uv run ruff check . && uv run pyright` → clean; `git diff --stat` scoped to `frontend/` + `tests/` + `.agents/phases/` (no `app/` changes). +- **Deviations**: no `git commit` / no phase-dir move — left to the harness per its rules (overrides the task's commit step); 7 s tool gap (vs the spec's "≥1 s windows") chosen so the phase-87 counter window is race-free. +- **Next pending task**: none in phase 109 — task 03 was its last (`.agents/phases/todo/` also still lists `108_history_wire_check`, outside this task's scope). diff --git a/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.validate b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.validate new file mode 100644 index 0000000..aee2d10 --- /dev/null +++ b/.agents/reports/109_turn_progress_loader/109_turn_progress_loader__03_e2e_and_gate.a1.validate @@ -0,0 +1,101 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 9%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 156 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 154 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 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 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 128 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 215 3 99% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 172 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 327 0 100% +-------------------------------------------------- +TOTAL 4018 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/frontend/assets/app.js b/frontend/assets/app.js index cbb8dd4..220a2f4 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -305,6 +305,7 @@ const input = document.querySelector("#message-input"); const sendBtn = document.querySelector("#send-btn"); const sendLabel = document.querySelector("#send-label"); const sendStatus = document.querySelector("#send-status"); +const turnLoader = document.querySelector("#turn-loader"); // phase 109 (D16): the persistent in-turn loader — ships hidden; setUiState is its sole visibility owner const banner = document.querySelector("#kb-banner"); const bannerText = document.querySelector("#kb-banner-text"); const versionEl = document.querySelector("#app-version"); @@ -877,14 +878,24 @@ function removeTyping() { document.querySelector("#typing-indicator")?.remove(); } -/* ---------- thinking block (phase 17) ---------- +/* ---------- thinking block (phase 17; phase 109 makes it a toggle) ---------- * The model's reasoning streams into a collapsible
block ABOVE - * the answer bubble: created OPEN on the first `thinking` event, - * auto-collapsed when the first answer token lands, and user-toggleable - * afterwards (native
/ — a real focusable control). + * the answer bubble: created OPEN on the first `thinking` event, then + * user-toggleable (native
/ — a real focusable + * control). + * + * Phase 109 (TODO.md L3, D15) — the block is a TOGGLE, not a one-way + * door: open-while-thinking, closed-while-answering. The `thinking` SSE + * handler re-opens it (`block.open = true`, idempotent) — a `thinking` + * frame after the answer has started (the next agent round: the model + * answered, called a tool, then thinks again) re-opens the collapsed + * scratchpad — and the `delta` handler closes it (closeThinkingBlock, + * idempotent). The block reflects the model's current activity in every + * agent round — the "frozen chat" repro from TODO.md L3 is gone. The + * phase-14 restore path (renderStoredMessage) still renders stored + * blocks COLLAPSED — untouched. * ensureThinkingBlock is idempotent (returns the existing block if any); - * closeThinkingBlock never reopens a block once the answer has started, - * so a late/interleaved `thinking` event only appends to the closed text. */ + * closeThinkingBlock is a no-op without a block. */ function ensureThinkingBlock(wrap) { let block = wrap.querySelector(".thinking"); if (!block) { @@ -1249,6 +1260,14 @@ export function setUiState(state, errorDetail = "") { const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming; sendBtn.disabled = false; // enabled in every state — Stop is a control sendBtn.classList.toggle("is-stop", inFlight); + // Phase 109 (TODO.md L3, D16): the persistent in-turn loader — this + // line is its SOLE visibility owner (unit-pinned single-owner check): + // shown iff a turn is in flight (thinking|streaming). Every terminal + // path funnels through setUiState (done → idle, error → error, + // stop/timeout → their error/idle landings), so it hides in every + // terminal state BY CONSTRUCTION — the §7.4 never-stale guarantee is + // structural, not per-handler cleanup (that is the point). + turnLoader.hidden = !inFlight; sendBtn.querySelector(".spinner").hidden = true; // the Stop label carries it sendLabel.textContent = inFlight ? "Stop" : "Send"; sendStatus.textContent = SEND_STATUS[state]?.() ?? ""; @@ -2275,12 +2294,24 @@ async function runTurn(text, { reask = false } = {}) { // "thinking" (the button stays the enabled "Stop" control — // phase 48 — #send-status unchanged) — the live block simply // replaces the typing dots as the visible feedback. + // Phase 109 (TODO.md L3, D15): the block is a TOGGLE — + // open-while-thinking, closed-while-answering. The re-open + // assignment below (right after ensureThinkingBlock) re-opens a + // block a previous `delta` closed — the next agent round's + // thinking (the model answered, called a tool, then thinks + // again) no longer lands in an invisible collapsed scratchpad; + // while the block is already open (the pre-delta live flow) it + // is a no-op — that flow is unchanged. thinkingAcc += ev.text || ""; sawThinking = true; clearTurnTimeout(); // the stream is alive — as the first delta says if (!wrap) wrap = addMessage("brain", ""); removeTyping(); // the live block replaces the dots as feedback const block = ensureThinkingBlock(wrap); + // Phase 109 (D15): open-while-thinking — idempotent: a no-op + // while already open (the pre-delta live flow), a re-open after + // a `delta` closed the block (the next agent round). + block.open = true; const textEl = block.querySelector(".thinking-text"); // Pin state is measured BEFORE the re-render: a chunk taller // than the 32px band — any paragraph break ("\n\n") or a few @@ -2373,7 +2404,7 @@ async function runTurn(text, { reask = false } = {}) { acc += ev.text || ""; if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming); if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in - closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens + closeThinkingBlock(wrap); // auto-collapse; idempotent — the next `thinking` frame re-opens it (phase 109, D15) wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc); // No page scroll (phase 42): the answer never follows the viewport. } else if (ev.type === "done") { diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index a9b0eea..a4c70a7 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -1197,6 +1197,57 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } details.thinking summary::before { transition: none; } } +/* Phase 109 (TODO.md L3, D16): the persistent in-turn loader — the + constant progress cue in the composer's status row, visible for the + ENTIRE active turn (send → terminal frame). Its hidden attribute is + owned solely by app.js's setUiState (shown iff inFlight — hidden by + construction in every terminal state, PLAN §7.4). DECORATIVE + (aria-hidden in index.html): #send-status carries the state's + meaning, so contrast is N/A here (§7.2 "text + color, never color + alone" — the state TEXT stays in #send-status). Compact horizontal + three-dot indicator reusing the EXISTING typing-dot animation (the + `typing` keyframes above — no new animation family), sized down for + the status row (4px dots, 3px gap): the element itself is the + middle dot (0.15s delay, like .typing span:nth-child(2)), + ::before/::after the outer two. */ +.turn-loader { + display: inline-flex; + align-items: center; + gap: 3px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ink-soft); + opacity: 0.5; + align-self: center; /* the composer row is flex-end aligned — center the cue */ + animation: typing 1.2s infinite ease-in-out; + animation-delay: 0.15s; +} +.turn-loader::before, +.turn-loader::after { + content: ""; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ink-soft); + opacity: 0.5; + animation: typing 1.2s infinite ease-in-out; +} +.turn-loader::before { animation-delay: 0s; } +.turn-loader::after { animation-delay: 0.3s; } +/* Phase 109 (TODO.md L3, D16): reduced motion — the loader mirrors the + typing dots' treatment (static dots, no pulse — §7.2 house law). + This override MUST sit AFTER the main .turn-loader rule above: + equal specificity, and the later rule wins the cascade — a media + block placed before the main rule would lose to the `animation: + typing …` declaration and the dots would keep pulsing under + reduced motion (the phase-109 task-03 E2E caught it). */ +@media (prefers-reduced-motion: reduce) { + .turn-loader, + .turn-loader::before, + .turn-loader::after { animation: none; opacity: 0.7; } +} + /* ---------- Empty state & suggestions ---------- */ .empty-state { background: var(--surface); diff --git a/frontend/index.html b/frontend/index.html index 6ff6706..3e44ae6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -295,6 +295,18 @@ Send + +
diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 699f20e..feb397e 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -357,6 +357,44 @@ Implements just enough of the aipi surface: client never sends tool calls/results) — so every existing marker flow (which classifies statelessly from TOOL results and the LAST user message) is unaffected by the now-always-present history. + - user message containing ``answer first, then list, then think`` + (``TURN_PROGRESS_TRIGGER``, phase 109 task 03 — the never-frozen- + turn story's dedicated suite + ``tests/e2e/test_turn_progress_loader.py``) **and** the system + prompt carries the ```` section -> the deterministic + REPORTED-REPRO turn (TODO.md L3: "the model responds, calls a + tool, then continues thinking without re-expanding the thinking + block"): the owner's exact sequence, deterministic, with baked-in + delays (mid-turn windows of ≥1 s each — the ``slow_llm.py`` + pacing precedent) so the story E2E's window assertions are + race-free. Discriminated statelessly from the messages (streaming + only): + * request 1 (``tools`` offered, no tool results yet): ~2 s pre- + delay (model latency — the loader's start-state window), a + short ``delta.content`` stream (3 chunks, NO reasoning — the + answer starts FIRST), then the ``ls`` tool call (synthetic id + ``call_0``, no arguments — the phase-37 pattern), + ``finish_reason: "tool_calls"``; + * request 2 (a ``tool``-role result in the messages — the server + ran the ``ls``): a ~7 s frameless gap (long enough that the + phase-87 tool-line ``(Ns)`` counter — 5 s+ after the line's + own arm — appears and ticks BEFORE the first ``thinking`` + frame settles the line), then the ``reasoning_content`` stream + (10 × 0.3 s), a ``delta.content`` stream (3 chunks ending in + the distinctive final sentence carrying + ``marker-progress-42``), and the FINAL ``reasoning_content`` + chunks (3 × 0.3 s — thinking AFTER the answer, the reported + repro), ``finish_reason: "stop"``. + The server is position-independent over the wire (each + ``reasoning_content`` chunk → a ``thinking`` SSE frame, each + ``content`` chunk → a ``delta`` frame — ``app/rag/llm.py``; the + tool call materializes after its request's stream — the + content-before-tools convention), so the turn's SSE is exactly + ``delta → tool → thinking → delta → thinking → done``. + Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger + phrases — the phase-71/72/94 ordering convention); verified + 2026-09-16: no existing E2E question or fixture file contains the + phrase, so every other suite is unaffected. Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic dead-endpoint behavior for the retry E2E suite (``tests/e2e/ @@ -1597,6 +1635,207 @@ def _folder_summary_incident(body: dict[str, Any]) -> bool: return False +# --------------------------------------------------------------------------- +# Phase 109 (task 03, the never-frozen-turn story suite): the +# deterministic REPORTED-REPRO turn (delta → tool → +# thinking-after-delta) — see the module docstring +# --------------------------------------------------------------------------- + +#: A user message containing this substring (case-insensitive) — +#: combined with the ```` section in the system prompt — drives +#: the deterministic REPORTED-REPRO turn (TODO.md L3): the scripted +#: first answer (no reasoning), the no-arg ``ls``, then the post-tool +#: thinking → answer → FINAL thinking round — the owner's exact +#: "the model responds, calls a tool, then continues thinking" sequence +#: with baked-in delays (mid-turn windows of ≥1 s each — the +#: ``slow_llm.py`` pacing precedent) so the story E2E's window +#: assertions are race-free. Checked BEFORE the plain ``TOOLS_TRIGGER`` +#: flow (disjoint trigger phrases — the phase-71/72/94 ordering +#: convention); verified 2026-09-16: no existing E2E question or +#: fixture file contains the phrase, so every other suite is +#: unaffected. +TURN_PROGRESS_TRIGGER = "answer first, then list, then think" + +#: Request 1's pre-delay (s) — model latency: the loader's start-state +#: window (the turn is in flight, NO frame has arrived yet). +TURN_PROGRESS_PREDELAY_S = 2.0 + +#: Request 2's frameless gap (s) — the silence after the ``tool`` frame +#: before the first ``thinking`` frame. Deliberately past the phase-87 +#: tool-line counter's 5 s gate (``TOOL_LINE_ELAPSED_AFTER_MS`` in +#: app.js) with ≥2 s of margin: the line's ``(Ns)`` suffix appears +#: (5 s tick) and ticks (6 s) BEFORE the first thinking frame settles +#: the line, so the story E2E can pin the counter in the tool gap +#: without a race. +TURN_PROGRESS_TOOL_GAP_S = 7.0 + +#: Per-chunk delays (s) for the two streams of request 2. +TURN_PROGRESS_THINK_DELAY_S = 0.3 # reasoning chunks (~10 × 0.3 s, ~3 × 0.3 s) +TURN_PROGRESS_CONTENT_DELAY_S = 0.05 # the short content deltas (3 + 3 chunks) + +#: Chunk sizes (chars): the content deltas stay SHORT (2-3 chunks each), +#: the reasoning streams run 10 × 30 and 3 × 30. +TURN_PROGRESS_CONTENT_CHUNK = 40 +TURN_PROGRESS_THINK_CHUNK = 30 + +#: Request 1's answer (byte-stable): 3 × 40-char content chunks, NO +#: reasoning — the answer starts FIRST (the repro's "the model +#: responds"). ``marker-progress-41`` is the sentinel the story E2E +#: matches to prove call 1's content landed in the bubble. +TURN_PROGRESS_FIRST_ANSWER = ( + "Checking the listing first — opening answer: the kubernetes setup, " + "short and sweet. marker-progress-41." +) + +#: Request 2's scratchpad (byte-stable): 10 × 30-char reasoning chunks — +#: thinking AFTER the answer, the reported repro. ``marker-thought-42`` +#: is the sentinel the story E2E matches in the re-opened block. +TURN_PROGRESS_THINKING = ( + "The listing just landed — now I can see which documents exist, so the " + "answer can anchor to the kubernetes file first and the deployments note " + "second, citing each fact by the exact path it came from. Hosts, " + "versions, and ports stay exactly as the notes write them. " + "marker-thought-42." +) + +#: Request 2's answer (byte-stable): 3 × 40-char content chunks ending in +#: the DISTINCTIVE final sentence the story E2E matches on to pin the +#: post-delta state (``marker-progress-42`` — the repro's second answer). +TURN_PROGRESS_FINAL_ANSWER = ( + "Here is the plan after the listing: step one, step two, step three — " + "that is the whole of it. marker-progress-42." +) + +#: Request 2's FINAL reasoning (byte-stable): 3 × 30-char chunks — the +#: thinking that arrives AFTER the answer's last delta. The story E2E +#: waits for the re-opened block after the final sentence landed and +#: matches ``marker-final-thought-42`` to prove the last round's +#: thinking rendered in the scratchpad. +TURN_PROGRESS_FINAL_THINKING = ( + "Final check — hosts and ports are verbatim. marker-final-thought-42." +) + + +def _turn_progress_flow(body: dict[str, Any]) -> str | None: + """Classify a phase-109 reported-repro request (see the module + docstring). Stateless over the messages, like the other marker + flows: + + * ``"first"`` — ``tools`` are offered and no ``tool``-role result + is in the messages yet: request 1 — the ~2 s pre-delay (model + latency — the loader's start-state window), the short + ``delta.content`` stream (3 chunks, NO reasoning — the answer + starts first), then the ``ls`` tool call (synthetic id + ``call_0``, no arguments), ``finish_reason: "tool_calls"``. + * ``"second"`` — a ``tool``-role result is in the messages (the + server ran the ``ls``): request 2 — the ~7 s frameless gap + (the phase-87 window), the ``reasoning_content`` stream (10 × + 0.3 s), the ``delta.content`` stream (3 chunks ending in the + distinctive final sentence), and the FINAL + ``reasoning_content`` chunks (3 × 0.3 s) — thinking AFTER the + answer — then ``finish_reason: "stop"``. + * ``None`` — not the flow: the trigger is absent, the ```` + section is missing (deflected turns never carry it), or + ``tools`` are not offered and no tool result is in the messages + yet (e.g. ``agent_max_rounds=0``). + """ + if TURN_PROGRESS_TRIGGER not in _user(body).lower(): + return None + if "" not in _system(body): + return None + if _tool_results(body): + return "second" + if not body.get("tools"): + return None + return "first" + + +def _turn_progress_stream(step: str) -> Any: + """SSE frames for one phase-109 reported-repro request. + + ``"first"`` (model call 1): the ~2 s pre-delay, the short + ``delta.content`` stream (3 chunks — the answer starts first, NO + reasoning), the ``ls`` tool-call partial (synthetic id ``call_0``, + no arguments — the phase-37 ``_tool_call_stream`` first-partial + shape), and the ``finish_reason: "tool_calls"`` frame. The app is + position-independent over the wire (``app/rag/llm.py``): the + content chunks stream as ``delta`` SSE frames as they arrive and + the tool call materializes AFTER the stream (the content-before- + tools convention), so the request's SSE is exactly ``delta → + tool``. + + ``"second"`` (model call 2 — after the server ran the ``ls``): the + ~7 s frameless gap (the phase-87 window — the tool line's + ``(Ns)`` counter appears and ticks before the first frame settles + it), the ``reasoning_content`` stream (10 × 30 chars, 0.3 s each), + the ``delta.content`` stream (3 chunks ending in the distinctive + final sentence), the FINAL ``reasoning_content`` chunks (3 × 30 + chars, 0.3 s each — thinking AFTER the answer, the reported + repro), and the ``finish_reason: "stop"`` frame. + """ + model = "turbo" + chunk_id = f"chatcmpl-{uuid.uuid4()}" + + def frame(delta: dict[str, Any], finish: str | None = None) -> str: + return ( + "data: " + + json_dumps( + { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + ) + + "\n\n" + ) + + if step == "first": + time.sleep(TURN_PROGRESS_PREDELAY_S) + for piece in re.findall( + rf".{{1,{TURN_PROGRESS_CONTENT_CHUNK}}}", TURN_PROGRESS_FIRST_ANSWER, re.S + ): + yield frame({"content": piece}) + time.sleep(TURN_PROGRESS_CONTENT_DELAY_S) + yield frame( + { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_0", + "type": "function", + "function": {"name": "ls", "arguments": "{}"}, + } + ], + } + ) + time.sleep(0.1) + yield frame({}, "tool_calls") + yield "data: [DONE]\n\n" + return + + time.sleep(TURN_PROGRESS_TOOL_GAP_S) + for piece in re.findall( + rf".{{1,{TURN_PROGRESS_THINK_CHUNK}}}", TURN_PROGRESS_THINKING, re.S + ): + yield frame({"reasoning_content": piece}) + time.sleep(TURN_PROGRESS_THINK_DELAY_S) + for piece in re.findall( + rf".{{1,{TURN_PROGRESS_CONTENT_CHUNK}}}", TURN_PROGRESS_FINAL_ANSWER, re.S + ): + yield frame({"content": piece}) + time.sleep(TURN_PROGRESS_CONTENT_DELAY_S) + for piece in re.findall( + rf".{{1,{TURN_PROGRESS_THINK_CHUNK}}}", TURN_PROGRESS_FINAL_THINKING, re.S + ): + yield frame({"reasoning_content": piece}) + time.sleep(TURN_PROGRESS_THINK_DELAY_S) + yield frame({}, "stop") + yield "data: [DONE]\n\n" + + def compose_answer(body: dict[str, Any]) -> str: system = _system(body) user = _user(body) @@ -2222,6 +2461,23 @@ def chat_completions(body: dict[str, Any]) -> Any: media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) + # Phase 109 (task 03, never-frozen-turn story suite): the + # deterministic REPORTED-REPRO turn (delta → tool → + # thinking-after-delta — TODO.md L3): the scripted first answer, + # the no-arg ls, then the post-tool thinking → answer → FINAL + # thinking round — the owner's exact repro with baked-in delays + # (see the module docstring). Checked BEFORE the plain + # TOOLS_TRIGGER flow (disjoint trigger phrases — the + # phase-71/72/94 ordering convention; the trigger needs the + # ```` section, so deflected turns never hit it). + turn_progress = _turn_progress_flow(body) + if turn_progress is not None: + stream = _turn_progress_stream(turn_progress) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) flow = _tool_flow(body) if flow is not None: if flow[0] == "list": diff --git a/tests/e2e/test_turn_progress_loader.py b/tests/e2e/test_turn_progress_loader.py new file mode 100644 index 0000000..e2682c6 --- /dev/null +++ b/tests/e2e/test_turn_progress_loader.py @@ -0,0 +1,321 @@ +"""Phase 109 E2E (Playwright): the never-frozen turn — the reported +repro (delta → tool → thinking-after-delta) replayed deterministically. + +Story: n/a — owner request 2026-09-16 (TODO.md L3): "Thinking can +happen after the model starts responding. This sometimes results in a +the chat appearing 'frozen' because the model responds, calls a tool, +then continues thinking without re-expanding the thinking block. There +should be a visual that the chat is still progressing regardless of +what state it's in (some kind of loader will do)." + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov + +Marker contract: the mock's ``answer first, then list, then think`` +marker (``TURN_PROGRESS_TRIGGER`` — see the ``mock_llm.py`` module +docstring) drives the deterministic reported repro. Model call 1 +streams a short content delta (3 chunks, NO reasoning) after a ~2 s +pre-delay (model latency — the loader's start-state window), then the +no-arg ``ls`` tool call; model call 2 (after the server ran the ``ls``) +streams a ~7 s frameless gap (past the phase-87 tool-line ``(Ns)`` +counter's 5 s gate), then a ``reasoning_content`` stream (10 × 0.3 s), +a content delta (3 chunks ending in the distinctive final sentence +carrying ``marker-progress-42``), and a FINAL ``reasoning_content`` +chunk group (3 × 0.3 s). The server is position-independent over the +wire (``app/rag/llm.py`` — each ``reasoning_content`` chunk → a +``thinking`` SSE frame, each ``content`` chunk → a ``delta`` frame; +the tool call materializes after its request's stream), so the turn's +SSE is exactly ``delta → tool → thinking → delta → thinking → done`` — +the owner's repro, and every window is deterministic (the mock's +baked delays, the ``slow_llm.py`` pacing precedent). + +Each test sends the marker question in its OWN fresh conversation +(one full turn per test — the ``page`` fixture is a fresh browser +context, ``_reset_db`` reseeds the KB deterministically): + +* ``test_loader_visible_from_send_through_the_tool_gap`` — the loader + is visible right after the send (the thinking state, no frame yet — + the start-state window), is STILL visible when the ``.tool-call`` + line lands (the turn is provably in flight), the tool line carries + the phase-87 elapsed counter in the tool gap, and ``#send-status`` + carries a state text (not empty) throughout. +* ``test_thinking_block_reopens_after_delta_with_visible_loader`` — + THE reported symptom's state: the thinking block re-opens after the + answer has started (open + non-empty ``.thinking-text`` while the + bubble already carries call 1's content) with the loader STILL + visible (the frozen window is gone); the terminal state is clean + (loader hidden, the scratchpad carries both thinking rounds, the + bubble carries BOTH answers, the send button reads "Send" not + "Stop", ``#send-status`` back to the idle shape). +* ``test_loader_a11y_and_reduced_motion`` — after a full turn: the + loader element is ``aria-hidden="true"`` in the DOM and + ``#send-status`` (the sole ``aria-live`` announcer) is back to the + ``SEND_STATUS`` idle shape (empty — not stuck on a mid-turn label); + then, in a context with ``reducedMotion: "reduce"`` (the Playwright + context option), a second turn shows the loader STILL visible + mid-turn — the reduced-motion variant stills the dots (computed + ``animation-name: none``), it does not HIDE the cue: visibility is + owned by the JS ``hidden`` attribute in ``setUiState`` (D16), not by + CSS. +""" +from __future__ import annotations + +import asyncio +import re +from pathlib import Path +from threading import Thread +from typing import Any + +from playwright.sync_api import Browser, Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient +from e2e.auth_helpers import login + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" + +#: The marker question — the mock's ``TURN_PROGRESS_TRIGGER`` phrase + +#: an on-topic tail (HIGH gate → the ```` section, so the +#: scripted ``ls`` round is reachable — the phase-37/70 convention). +QUESTION = "answer first, then list, then think — how is my Kubernetes cluster set up?" + +#: The mock's byte-stable sentinels (``mock_llm.py``): call 1's +#: content, call 2's final sentence, call 2's scratchpad, call 2's +#: FINAL scratchpad (the thinking after the answer's last delta). +SENTINEL_FIRST = "marker-progress-41" +SENTINEL_FINAL = "marker-progress-42" +SENTINEL_THOUGHT = "marker-thought-42" +SENTINEL_FINAL_THOUGHT = "marker-final-thought-42" + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread. + + Playwright's sync API keeps an asyncio loop running on the test + thread, so ``asyncio.run`` cannot be called directly from a test + body. + """ + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: + """Truncate the KB (+ query log + steering notes — deterministic + mock answers), then optionally re-import fixtures.""" + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) + db.commit() + if not seed: + return None + return _run_in_thread(_import_fixtures(mock_port)) + + +def _send(page: Page) -> None: + """Send the marker question (one full reported-repro turn).""" + page.fill("#message-input", QUESTION) + page.click("#send-btn") + + +def _answer_bubble(page: Page) -> Any: + """The answer bubble (the typing indicator's bubble is ``.typing`` + — scoped out).""" + return page.locator(".msg.brain .bubble:not(.typing)") + + +def test_loader_visible_from_send_through_the_tool_gap( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + summary = _reset_db(mock_llm, seed=True) + assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _send(page) + + # Right after the send — inside call 1's ~2 s pre-delay window: + # the turn is in flight (the thinking state, NO frame has arrived + # yet) and the loader is ALREADY visible — the start-state cue. + # ``setUiState(thinking)`` runs synchronously in the submit path, + # so this is race-free: the button is the Stop control, the + # loader is out, and no frame-derived surface exists yet (no + # answer bubble, no thinking block, no tool line — only the + # pre-delta typing bubble). + loader = page.locator("#turn-loader") + expect(loader).to_be_visible() + expect(page.locator("#send-label")).to_have_text("Stop") + expect(page.locator("#typing-indicator")).to_be_visible() + expect(_answer_bubble(page)).to_have_count(0) + expect(page.locator(".msg.brain .thinking")).to_have_count(0) + expect(page.locator(".tool-call")).to_have_count(0) + + # The ``.tool-call`` line lands (call 1's content + the ls): the + # turn is provably in flight — and the loader is STILL visible. + expect(page.locator(".tool-call")).to_be_visible(timeout=30_000) + expect(loader).to_be_visible() + expect(_answer_bubble(page)).to_contain_text(SENTINEL_FIRST) + + # The tool gap (call 2's ~7 s frameless delay — deliberately past + # the phase-87 counter's 5 s gate): the tool line's elapsed + # counter appears and ticks BEFORE the first thinking frame + # settles the line ... + elapsed = page.locator(".tool-call .tool-elapsed") + expect(elapsed).to_be_visible(timeout=20_000) + expect(elapsed).to_have_text(re.compile(r"\(\d+s\)")) + # ... and the loader + the #send-status state text hold through + # the whole gap (the status line carries the streaming state's + # text — it is never empty while a frame is in flight). + expect(loader).to_be_visible() + expect(page.locator("#send-status")).not_to_be_empty() + + +def test_thinking_block_reopens_after_delta_with_visible_loader( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + summary = _reset_db(mock_llm, seed=True) + assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _send(page) + + bubble = _answer_bubble(page) + block = page.locator(".msg.brain .thinking") + text_el = page.locator(".msg.brain .thinking-text") + loader = page.locator("#turn-loader") + + # THE reported symptom's state (D15): the thinking block is open + # with non-empty text WHILE the answer bubble already carries + # call 1's content — the post-delta re-open (call 2's thinking + # frames re-opened the block the first delta had closed). + page.wait_for_function( + """() => { + const b = document.querySelector('.msg.brain .thinking'); + const t = b ? b.querySelector('.thinking-text') : null; + return !!b && b.open && !!t && t.textContent.trim().length > 0; + }""", + timeout=30_000, + ) + expect(block).to_have_count(1) + expect(block).to_have_attribute("open", "") + expect(text_el).to_contain_text(SENTINEL_THOUGHT) + # The re-open is genuinely AFTER a delta: call 1's content is + # already in the bubble ... + expect(bubble).to_contain_text(SENTINEL_FIRST) + # ... and the loader is STILL visible — the frozen window is gone. + expect(loader).to_be_visible() + + # Terminal approach: call 2's distinctive final sentence landed in + # the bubble ... + expect(bubble).to_contain_text(SENTINEL_FINAL, timeout=30_000) + # ... and the FINAL thinking round (the last frames before done — + # thinking AFTER the answer's last delta) re-opened the block + # AGAIN: the scratchpad carries the last round's text and the + # block is open (the `thinking` handler opens it before it + # renders — D15), with the loader still visible (the turn is + # still in flight — no terminal frame yet). + expect(text_el).to_contain_text(SENTINEL_FINAL_THOUGHT, timeout=30_000) + expect(block).to_have_attribute("open", "") + expect(loader).to_be_visible() + + # The turn settles (done → idle through setUiState): the loader is + # HIDDEN (its sole owner — D16, no per-handler cleanup) ... + expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) + expect(loader).to_be_hidden() + # ... the scratchpad text is intact (BOTH thinking rounds) ... + expect(text_el).to_contain_text(SENTINEL_THOUGHT) + expect(text_el).to_contain_text(SENTINEL_FINAL_THOUGHT) + # ... the bubble carries BOTH answers (call 1 + call 2) ... + expect(bubble).to_contain_text(SENTINEL_FIRST) + expect(bubble).to_contain_text(SENTINEL_FINAL) + # ... the send button is back to "Send" (asserted above via the + # wait) and NOT the Stop treatment ... + expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop")) + # ... and #send-status is back to the idle shape (not stuck on a + # mid-turn label). + expect(page.locator("#send-status")).to_have_text("") + + +def test_loader_a11y_and_reduced_motion( + page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None +) -> None: + summary = _reset_db(mock_llm, seed=True) + assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _send(page) + # The full turn lands (the distinctive final sentence + done → + # the Send button back). + expect(_answer_bubble(page)).to_contain_text(SENTINEL_FINAL, timeout=60_000) + expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) + + # The a11y split: the loader element is ``aria-hidden="true"`` in + # the DOM (decorative — it never carries meaning) ... + expect(page.locator("#turn-loader")).to_have_attribute("aria-hidden", "true") + # ... and #send-status (the sole ``aria-live`` announcer) is back + # to the SEND_STATUS idle shape after done — EMPTY, not stuck on a + # mid-turn label ("… is thinking" / "… is answering"). + status = page.locator("#send-status") + expect(status).to_have_attribute("aria-live", "polite") + expect(status).to_have_text("") + # Control (default-motion context): the loader's dots run the + # typing animation — the reduced-motion check below stills it. + assert ( + page.evaluate( + "getComputedStyle(document.querySelector('#turn-loader')).animationName" + ) + == "typing" + ) + + # Reduced motion: a FRESH context with ``reducedMotion: "reduce"`` + # (the Playwright context option) — send a second turn and pin + # that the loader is STILL visible mid-turn: the reduced-motion + # variant stills the dots (the CSS rule changes — computed + # ``animation-name: none``), but visibility is owned by the JS + # ``hidden`` attribute in setUiState (D16), not by CSS. + rm_context = browser.new_context( + reduced_motion="reduce", viewport={"width": 1280, "height": 800} + ) + try: + rm_page = rm_context.new_page() + rm_page.set_default_timeout(30_000) + login(rm_page, app_url, next="/") + _send(rm_page) + # Mid-turn (the tool gap — call 2's ~7 s frameless delay): the + # loader is still the visible cue ... + expect(rm_page.locator(".tool-call")).to_be_visible(timeout=30_000) + expect(rm_page.locator("#turn-loader")).to_be_visible() + # ... and the reduced-motion CSS variant IS applied: static + # dots, no pulse (the animation is gone from the computed + # style — the element itself is unchanged). + assert ( + rm_page.evaluate( + "getComputedStyle(document.querySelector('#turn-loader')).animationName" + ) + == "none" + ) + finally: + rm_context.close() diff --git a/tests/unit/test_frontend_turn_loader.py b/tests/unit/test_frontend_turn_loader.py new file mode 100644 index 0000000..2c7ca7c --- /dev/null +++ b/tests/unit/test_frontend_turn_loader.py @@ -0,0 +1,319 @@ +"""Unit: the phase-109 never-frozen-turn frontend contract (tasks 01-02). + +No new Python app logic exists for this task — the behavior lives in +``frontend/assets/app.js`` and is E2E-gated by the story suite (task 03). +Like the other frontend-adjacent unit files (the house +read-the-assets-as-text pattern, cf. ``test_frontend_tool_states.py``), +this module pins the JS markers the phase depends on: the thinking block +becomes a TOGGLE (D15) — ``thinking`` frames open it (idempotent), +``delta`` frames close it — while the phase-14 restore path and the +phase-17 follow-the-tail pin logic stay byte-for-byte untouched. Task 02 +pins the persistent in-turn loader (D16): the static ``#turn-loader`` +markup, ``setUiState`` as its SOLE visibility owner (the structural +never-stale guarantee), and the CSS contract (reused typing-dot +animation + reduced-motion + provenance). +""" +from __future__ import annotations + +import re +from pathlib import Path + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +APP_JS = FRONTEND / "assets" / "app.js" +INDEX_HTML = FRONTEND / "index.html" +STYLES_CSS = FRONTEND / "assets" / "styles.css" + + +def _js() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def _html() -> str: + return INDEX_HTML.read_text(encoding="utf-8") + + +def _css() -> str: + return STYLES_CSS.read_text(encoding="utf-8") + + +def _thinking_branch() -> str: + """The `thinking` handler branch (between the thinking and tool + branches of the turn dispatch).""" + js = _js() + thinking_idx = js.find('ev.type === "thinking"') + tool_idx = js.find('ev.type === "tool"') + assert -1 < thinking_idx < tool_idx, ( + "the turn handler must branch on thinking frames" + ) + return js[thinking_idx:tool_idx] + + +def test_thinking_handler_reopens_the_collapsed_block() -> None: + """Phase 109 (D15): the `thinking` handler re-opens the block — + ``block.open = true`` sits in the handler, positioned AFTER the + ``ensureThinkingBlock(wrap)`` line (the block must exist before it + can be opened). Idempotent: a no-op while already open (the + pre-delta live flow), a re-open after a `delta` closed it (the next + agent round — the reported freeze, TODO.md L3).""" + branch = _thinking_branch() + ensure_idx = branch.find("ensureThinkingBlock(wrap)") + assert ensure_idx != -1, "the handler must ensure the block first" + reopen_idx = branch.find("block.open = true") + assert reopen_idx != -1, ( + "the `thinking` handler must open the block (D15: " + "open-while-thinking)" + ) + assert ensure_idx < reopen_idx, ( + "`block.open = true` must sit AFTER `ensureThinkingBlock(wrap)` " + "— the block must exist before it is opened" + ) + + +def test_delta_handler_still_closes_the_block() -> None: + """Phase 109 (D15): the close side of the toggle survives — the + `delta` handler still calls ``closeThinkingBlock(wrap)`` (closed + while answering); nothing else about the block's lifecycle changes.""" + js = _js() + delta_idx = js.find('ev.type === "delta"') + done_idx = js.find('ev.type === "done"') + assert -1 < delta_idx < done_idx, "the turn handler must branch on delta" + branch = js[delta_idx:done_idx] + assert "closeThinkingBlock(wrap)" in branch, ( + "the `delta` handler must keep closing the block (D15: " + "closed-while-answering)" + ) + + +def test_close_thinking_block_docstring_says_toggle_not_one_way() -> None: + """Phase 109 (D15): the narrative flips. The section comment + documenting ``closeThinkingBlock`` no longer claims "never reopens" + — nowhere in app.js does — and documents the toggle contract instead: + open-while-thinking / closed-while-answering, naming the `thinking` + handler's re-open and the `delta` handler's close. The function + body itself is unchanged (idempotent close, no-op without a + block).""" + js = _js() + fn = js.find("function closeThinkingBlock") + assert fn != -1, "closeThinkingBlock must exist" + body = js[fn : js.find("\n}\n", fn)] + assert "block.open = false" in body, "the close side is unchanged" + # The block comment that documents the helpers: walk back from the + # function to its section comment start. + comment_start = js.rfind("/*", 0, fn) + assert comment_start != -1 + section = js[comment_start:fn] + assert "never reopens" not in section, ( + "the one-way-door claim is gone (phase 109, D15)" + ) + assert "never reopens" not in js, ( + "the 'never reopens' narrative must not survive anywhere in app.js" + ) + # The toggle contract is documented — and it names both sides: + # the `thinking` handler's re-open and the `delta` handler's close. + assert "TOGGLE" in section, "the contract is named: a toggle" + assert "open-while-thinking" in section + assert "closed-while-answering" in section + assert "`thinking`" in section, "the contract names the thinking handler" + assert "re-opens" in section, "the contract names the thinking handler's re-open" + assert "`delta`" in section, "the contract names the delta handler's close" + + +def test_restore_path_still_collapses_stored_blocks() -> None: + """Phase-14 contract regression: ``renderStoredMessage`` restores + stored thinking blocks COLLAPSED (``block.open = false``) — the + phase-109 toggle only changes the LIVE `thinking` handler, the + restore path is untouched.""" + js = _js() + fn = js.find("function renderStoredMessage") + assert fn != -1, "renderStoredMessage must exist" + body = js[fn : js.find("\n}\n", fn)] + assert "if (m.thinking)" in body + assert "ensureThinkingBlock(wrap)" in body + assert "block.open = false" in body, "stored blocks restore collapsed" + # The live re-open must NOT have leaked into the restore path: + # only the collapse assignment may appear in the function body. + assert "block.open = true" not in body, ( + "the restore path must never open a stored block (phase-14)" + ) + + +def test_follow_the_tail_pin_logic_is_untouched() -> None: + """Cross-file regression (phase 17): the follow-the-tail pin is + unchanged — ``THINKING_NEAR_BOTTOM_PX`` (32px band) + + ``isThinkingNearBottom`` and the pre-render capture + ``const pinned = block.open && isThinkingNearBottom(textEl);`` + (measured BEFORE the re-render) still sit in the `thinking` handler. + The capture already keys off ``block.open``, so a re-opened block + resumes pinned tail-following exactly like the live pre-delta + block — no pin logic change needed for D15.""" + js = _js() + assert "export const THINKING_NEAR_BOTTOM_PX = 32;" in js, ( + "the 32px window band is unchanged" + ) + fn = re.search( + r"function isThinkingNearBottom\(textEl\) \{([\s\S]*?)\n\}", js + ) + assert fn, "isThinkingNearBottom must still exist" + assert "THINKING_NEAR_BOTTOM_PX" in fn.group(1) + branch = _thinking_branch() + capture = "const pinned = block.open && isThinkingNearBottom(textEl);" + assert capture in branch, ( + "the pre-render `block.open &&` guard line is unchanged — D15 " + "keeps the pin logic untouched (it already reads block.open " + "before the re-render)" + ) + capture_idx = branch.find(capture) + render_idx = branch.find("textEl.innerHTML = renderMarkdown(thinkingAcc)") + assert -1 < capture_idx < render_idx, ( + "pin state is still measured BEFORE the re-render (phase-17 " + "regression — the 2-newline-gap fix)" + ) + assert "textEl.scrollTop = textEl.scrollHeight" in branch, ( + "pinned tail-following is intact" + ) + + +# ---------- task 02: the persistent in-turn loader (D16) ---------- + + +def test_index_html_carries_exactly_one_turn_loader() -> None: + """Phase 109 (D16): ``index.html`` carries exactly ONE static + ``#turn-loader`` — an empty ``
`` (never constructed in JS: the + createElement/textContent house rule), ``aria-hidden="true"`` + (decorative — ``#send-status`` carries the meaning), ``hidden`` by + default (idle on load), and it sits INSIDE the composer form — the + composer's status row, the visible companion of the ``#send-status`` + line.""" + html = _html() + assert html.count('id="turn-loader"') == 1, ( + "exactly one #turn-loader — static markup, never JS-built" + ) + match = re.search(r']*>', html) + assert match, "the loader must be a static
" + tag = match.group(0) + assert 'class="turn-loader"' in tag + assert 'aria-hidden="true"' in tag, "decorative — aria-hidden" + # the hidden attribute (a standalone word in the tag, not the + # substring of some other attribute value): ships hidden (idle). + assert re.search(r"\shidden\s*/?>$", tag), ( + "the loader ships hidden (idle on load)" + ) + # empty element: the closing tag follows immediately — no children, + # no JS-built HTML anywhere near it. + assert html[match.end() : match.end() + 6] == "
", ( + "the loader element is empty (static shell)" + ) + # inside the composer form (the status row), not elsewhere in the + # shell. + composer_idx = html.find('
", composer_idx) + assert composer_idx < match.start() < form_end, ( + "the loader sits in the composer's status row" + ) + + +def test_set_ui_state_is_the_sole_owner_of_the_loader() -> None: + """Phase 109 (D16) — the never-stale guarantee, structural: in + ``app.js`` the string ``turnLoader.hidden`` appears EXACTLY ONCE, + inside ``setUiState`` (the cross-file single-owner check — any + second write site fails this test). The toggle is + ``turnLoader.hidden = !inFlight`` next to the existing ``is-stop`` + toggle: shown iff ``uiState`` is thinking|streaming. Every terminal + path funnels through ``setUiState`` (done → idle, error → error, + stop/timeout → their landings), so the loader hides in every + terminal state BY CONSTRUCTION — no per-handler cleanup.""" + js = _js() + # The element lookup joins the other module-top lookups. + assert 'const turnLoader = document.querySelector("#turn-loader");' in js + # THE invariant: exactly one write site in the whole file. + assert js.count("turnLoader.hidden") == 1, ( + "setUiState is the SOLE writer of the loader's hidden attribute — " + "a second write site breaks the §7.4 never-stale guarantee" + ) + # ...and it sits INSIDE setUiState, next to the is-stop toggle. + fn = js.find("export function setUiState") + assert fn != -1 + body_end = js.find("\n}\n", fn) + owner_idx = js.find("turnLoader.hidden") + assert fn < owner_idx < body_end, ( + "the toggle must live inside setUiState (the single entry point)" + ) + stop_idx = js.find('sendBtn.classList.toggle("is-stop", inFlight);', fn) + assert -1 < stop_idx < owner_idx, ( + "the loader toggle joins the existing is-stop toggle" + ) + assert "turnLoader.hidden = !inFlight;" in js, ( + "shown iff inFlight (thinking|streaming)" + ) + + +def test_loader_css_reuses_the_typing_animation_and_reduced_motion() -> None: + """Phase 109 (D16) — the CSS contract: the ``.turn-loader`` rule + sits NEXT TO the typing-dots rules (after the ``@keyframes typing`` + block), reuses the SAME animation name (``typing`` — no new + animation family), a ``prefers-reduced-motion`` block covers it + (static dots, no pulse — §7.2 house law), and the provenance + comment names phase 109 + ``TODO.md``.""" + css = _css() + keyframes_idx = css.find("@keyframes typing") + assert keyframes_idx != -1, "the typing-dot keyframes exist" + # The MAIN rule (".turn-loader {" — the reduced-motion block's + # selector list never starts that exact string) must sit next to + # (after) the typing-dots rules. + rule_idx = css.find(".turn-loader {") + assert rule_idx != -1, "styles.css must carry a .turn-loader rule" + assert keyframes_idx < rule_idx, ( + "the .turn-loader rule sits next to (after) the typing-dots rules" + ) + # The provenance comment: the /* ... */ block immediately preceding + # the rule names phase 109 and TODO.md. + comment_start = css.rfind("/*", 0, rule_idx) + assert comment_start != -1 + comment = css[comment_start:rule_idx] + assert "Phase 109" in comment, "the provenance comment names phase 109" + assert "TODO.md" in comment, "the provenance comment cites the source" + # Reuses the EXISTING typing-dot animation: the same animation name + # in the rule and its pseudo-element rules (no new @keyframes + # family anywhere). + rule_block = css[rule_idx : rule_idx + 1200] + assert re.search(r"animation:\s*typing\b", rule_block), ( + "the loader reuses the typing dots' animation (same name — no " + "new animation family)" + ) + assert "@keyframes turn" not in css, "no new animation family for the loader" + # The prefers-reduced-motion block covers it: static dots, no pulse. + reduced_ok = False + for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css): + if ".turn-loader" in m.group(1) and "animation: none" in m.group(1): + reduced_ok = True + break + assert reduced_ok, ( + "a prefers-reduced-motion block must cover .turn-loader (static " + "dots, no pulse — §7.2)" + ) + + +def test_loader_is_aria_hidden_and_status_untouched() -> None: + """Phase 109 (D16) — the a11y split is untouched: the loader is + ``aria-hidden`` (decoration), and ``#send-status`` keeps its exact + attributes in ``index.html`` — still the sole ``aria-live`` + announcer (the house a11y split: visual cues are aria-hidden, the + live region carries the state text).""" + html = _html() + match = re.search(r']*>', html) + assert match and 'aria-hidden="true"' in match.group(0) + # #send-status is unchanged: one element, the exact same tag — + # the visually-hidden polite live region inside the send button, + # still the chat view's state announcer. + assert html.count('id="send-status"') == 1 + status = re.search(r']*id="send-status"[^>]*>', html) + assert status, "the #send-status live region is intact" + tag = status.group(0) + assert tag == ( + '' + ), "the #send-status attributes are byte-unchanged" + # the loader added no a11y surface: it is aria-hidden and carries + # no live region of its own. + assert 'aria-live' not in match.group(0)