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('