phase: 166_hide_empty_answer_bubble
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s

**Phase 166 — final verification pass: green.** All 3 task files already in `complete/`, so this was verification + defect/doc fixing.

- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
  - `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
  - `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
  - `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
This commit is contained in:
2026-10-08 04:27:03 -04:00
parent b0c7aed613
commit ab9391d019
36 changed files with 2348 additions and 93 deletions
@@ -0,0 +1,14 @@
**Phase 166 — final verification pass: green.** All 3 task files already in `complete/`, so this was verification + defect/doc fixing.
- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
- `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
- `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
- `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
@@ -0,0 +1,125 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
................................................................ [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 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 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 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 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 22 0 100%
app/main.py 68 0 100%
app/models.py 136 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 487 0 100%
--------------------------------------------------
TOTAL 5883 17 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
@@ -0,0 +1,18 @@
## Report — task 166/01 `hide_empty_bubble`
**Implemented**
- `frontend/assets/styles.css`: added `.msg .bubble:empty { display: none; }` in the Messages block, directly after `.bubble p`, with the full provenance comment (TODO L4, `image.png`, the `addMessage("brain","")` / `ensureReasoningWrap` anchor it preserves, why the typing indicator is never `:empty`, flex-gap collapse, scoping, no tokens/animation).
- New `tests/unit/test_no_empty_answer_bubble.py` (11 tests, house source-assertion style): rule exists exactly once + `.msg`-scoped + ordered between `.bubble p` and `.bubble pre`; `.bubble` keeps padding/border-radius/background/border/box-shadow (hides, never restyles); typing-dot rules untouched; `addTyping`'s three dot `<span>`s pinned (loading state can't vanish); all three `addMessage("brain", "")` anchors + the `insertBefore(cont, querySelector(".bubble"))` anchor survive (D2); node-executed `renderMarkdown("")`/`(" ")` → `""` (why restore/shared are covered, A3); no-CDN pin.
- `frontend/assets/app.js`: **no change** (LOCKED A1 respected).
**Tests / lint / coverage**
- `uv run pytest tests/unit/test_no_empty_answer_bubble.py -v` → 11 passed
- `uv run pytest tests/unit -k "thinking_timeline or tool_states or edit_message or frontend"` → all passed (phase-158/161/159 anchor pins untouched)
- `uv run pytest --cov=app --cov-report=term-missing` → **3865 passed**, `app/` coverage **99%** (>90% gate unchanged)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
**Docs**: no doc impact — README never described the empty box (behavior now matches the docs); task file pins the diff to styles.css + one test file only.
**Decisions**: none deviating; git diff touches exactly `frontend/assets/styles.css` + the new unit test file.
**Next pending task**: `166_hide_empty_answer_bubble/02_feedback_contract_check.md`
@@ -0,0 +1,125 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 96%]
........................................................................ [ 98%]
................................................. [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 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 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 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 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 22 0 100%
app/main.py 68 0 100%
app/models.py 136 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 487 0 100%
--------------------------------------------------
TOTAL 5883 17 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
@@ -0,0 +1,20 @@
**Task 02 complete — §7.4 feedback contract proven with the empty bubble hidden.**
**Implemented** (all in `tests/unit/test_no_empty_answer_bubble.py`, +14 tests; no `app/` or `frontend/` change):
- Module docstring `AUDIT NOTE:` — the four-state mid-turn inventory (typing dots+clock → reasoning container+status span → bubble at first token → terminal copy) as the record for the next agent.
- Feedback inventory pins: `addTyping` `role="status"`/`TYPING_LABEL()`/3 dots; `thinkingClock` `.typing-elapsed` `Ns` suffix (bubble never `:empty`); `setReasoningStatus` literals wired (1 Thinking / 1 Calling / 3 settle "N steps"); `cont.open = true`; `SEND_STATUS`/`#send-label` copy; `#messages` `aria-live="polite"` with announcement-timing reasoning.
- "Nothing measures the hidden box": `if (scroll) scrollReveal(wrap)` takes the `.msg` wrap; phase-161 pins take `.reasoning-body` (never a `.bubble`); grep-audits — no geometry prop (`getBoundingClientRect`/`offset*`/`client*`/`scroll*`) ever pairs with `bubble`, the `.bubble` selector appears only in `querySelector`/`insertBefore` lines, and the bubble variable is touched only for content (allowlist incl. `textContent`, `innerHTML`, `.querySelector(".msg-image")`, `classList`).
- `.msg-meta` append sites pinned to `.msg-body` (chips, images row, answer figure, actions row, `.failed-note`, `.stopped-note`) — none mentions `.bubble`.
- Restore (`addMessage("brain", renderMarkdown(m.text), true)`, ×1) and shared page (`shared.js` byte-identical `<div class="bubble">${html}</div>` + `msg ${who}` wrap, same `styles.css`, zero `:empty` of its own) covered by the same `.msg ` scope — no `shared.js` change.
**Gates** (all green):
- `uv run pytest tests/unit/test_no_empty_answer_bubble.py -v` → 26 passed
- `uv run pytest tests/unit -k "frontend or reasoning or thinking or tool" -v` → 572 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 3880 passed, `app/` coverage **99%** (>90%, unmoved)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
**Docs:** no doc impact — test-only task, nothing user-visible changed.
**Notes:** `ruff format` isn't a project gate (470 pre-existing files unformatted); I matched the `ruff check` gate. D2 honored: zero JS/CSS changes.
**Next pending task:** `166_hide_empty_answer_bubble/03_e2e_no_empty_box.md`.
@@ -0,0 +1,125 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
................................................................ [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 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 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 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 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 22 0 100%
app/main.py 68 0 100%
app/models.py 136 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 487 0 100%
--------------------------------------------------
TOTAL 5883 17 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
@@ -0,0 +1,22 @@
**Task complete.** All gates green.
## Task Report
**Task:** `03_e2e_no_empty_box.md` — Playwright E2E proving the empty answer box never paints (phase 166)
**Files changed:**
- `tests/e2e/test_no_empty_box.py` *(new)* — 5 E2E tests: pre-first-token window (synced on the reasoning stream: bubble `display:none`, 0px), plain-turn typing-indicator coverage (raced with an in-page 10ms paint sampler), restored chat, shared page, whitespace-only persisted answer
- `tests/e2e/mock_llm.py` — new paced flow `EMPTY_BUBBLE_TRIGGER` ("paint the pre-answer window"): thinking+tool turn with a widened pre-content gap
- `frontend/assets/styles.css` — the task-01 rule `.msg .bubble:empty { display:none }` (was uncommitted in the tree; rule untouched, verified)
- Screenshot artifact: `.agents/screenshots/166_pre_answer_window_1280.png` (gitignored, house convention)
**Pre-existing defects found & fixed** (suites left stale by `690fd98` "container is ONE disclosure"):
- `test_turn_progress_loader.py` (4 tests) and `test_share_chat.py` (3 tests) — pinned the deleted live `details.thinking` shape → updated to the container/run shape; contracts preserved
- `test_sources_midstream_bug.py` (2 tests) — same stale live selector → `div.thinking` runs
- `test_no_reply_autoscroll.py` — 3 tests: 2 stale-shape; `test_no_autoscroll_during_long_answer` failed on a **native −44px scrollY adjustment** at settle (proven via probes: at done the Edit/Retry re-target collapses turn 1's meta row 44→0px *above* the viewport; Chromium's scroll anchoring compensates scrollY so the reader's view stays pixel-still; predates 166 — reproduced with HEAD's CSS and the full pre-161 frontend). Metric modernized to visual-stability of the reading position — same contract, asserted on what the user perceives; real up-hops still fail it.
**Tests:** `test_no_empty_box.py` 5/5 in isolation; regression set (thinking_timeline, reasoning_bubble, turn_progress_loader, chat_history, share_chat, save_share_ux, failed_turn_retry, chat_answer_clusters, chat_error_honesty, markdown_tables, wide_desktop_column) all green; smoke green; full unit+integration **3880 passed**, `app/` coverage **99%** (>90% gate); ruff clean.
**Docs:** README/AGENTS.md need no changes (test-only task; the visual fix itself was task 01).
**Notes:** No git index/history commands run; changes left in the working tree for the pipeline harness to commit and to move `03_e2e_no_empty_box.md` to `.agents/phases/complete/166_hide_empty_answer_bubble/`.
@@ -0,0 +1,125 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
................................................................ [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 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 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 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 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 22 0 100%
app/main.py 68 0 100%
app/models.py 136 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 487 0 100%
--------------------------------------------------
TOTAL 5883 17 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+4 -1
View File
@@ -21,7 +21,10 @@
panels — `max-height: min(70vh, 64rem)` — with a sticky header row;
a clipped action label shows an ellipsis hint (`.cell-clip`) with
the full text kept reachable via the control's aria-label
(phase 156); the document viewer's page strip is CLASS-scoped —
(phase 156); an in-flight turn never paints an empty answer
bubble — the box is created empty as the reasoning anchor and
`.msg .bubble:empty { display: none }` hides it until the first
answer token (phase 166); the document viewer's page strip is CLASS-scoped —
the primary class shows the strip in place of the text, the
additive class never hides the text: Text/Pages toggle group
(`aria-pressed`, Text default) with the strip behind it
+28 -8
View File
@@ -92,6 +92,15 @@ question caps at 90 displayed characters with a `…` (phase 157) — the
full text stays in the hover tooltip, and clicking the chip submits
the full question.
**No empty answer box mid-turn** (phase 166): the bubble that will hold the
answer is created on the turn's first `thinking`/`tool` frame — it is the
DOM anchor the Reasoning container inserts itself above — but it stays
hidden until the **first answer token** lands in it, so an in-flight turn
shows the typing indicator and the Reasoning bubble only, never a padded
empty rectangle (nothing replaces the box: the existing loading feedback
is the turn's visible state). The same rule covers a restored or shared
answer whose persisted text is empty, which paints no box either.
The document's meta row (the chat modal **and** the standalone viewer at
`/document.html`) carries a **Copy path** button: one click copies the
document's `source/path` (the exact form the `read` tool, the chips, and
@@ -486,14 +495,15 @@ whole git-source refresh in one click:
## Thinking
The self-hosted `turbo` model reasons before it answers. That reasoning is
streamed with the turn as `thinking` SSE events and shown in **
collapsible "Thinking" blocks** — one block per
contiguous thinking run, in arrival order (phase 158) — all wrapped in
**one collapsible "Reasoning" bubble** above the answer bubble
(phase 161): the turn renders as a chronological timeline
streamed with the turn as `thinking` SSE events and shown as **"Thinking"
blocks** — one block per contiguous thinking run, in arrival order
(phase 158) — all wrapped in **one collapsible "Reasoning" bubble** above
the answer bubble (phase 161): the turn renders as a chronological timeline
(Thinking → Tool call (1) → Thinking → … → answer), condensed into a
single bounded live window (320 px, one scroll surface) that follows
the tail while you watch.
the tail while you watch. The Reasoning bubble is the ONE disclosure
(owner refinement, 2026-10-06): the per-run blocks inside it are plain and
never fold on their own.
The bubble is created on the turn's first thinking (or tool) frame and
stays **open** from creation until the turn settles — including a
@@ -1174,11 +1184,21 @@ uv run pyright
# Playwright E2E — install the browser once:
uv run playwright install chromium
# Each phase's E2E runs IN ISOLATION:
# Each phase's E2E runs IN ISOLATION — one file per run:
uv run pytest tests/e2e/test_import_documents.py -v --no-cov
uv run pytest tests/e2e/test_chat_rag.py -v --no-cov
# ...one file per phase in tests/e2e/ (the stories live in
# .agents/user_stories/, see .agents/phases/todo/)
# .agents/user_stories/, see .agents/phases/todo/).
#
# Isolation is REQUIRED, not a style preference: every suite shares one
# Postgres and one pair of fixed ports (the app on 8123, the mock aipi on
# 8901 — overridable with E2E_APP_PORT / E2E_MOCK_PORT), and each file
# truncates + reseeds the KB for its own fixture. Running the whole
# `tests/e2e/` directory in a single pytest session interleaves those
# fixtures and fails tests that pass file by file.
# To sweep every suite, loop over the files (one pytest process each):
for f in tests/e2e/test_*.py; do uv run pytest "$f" --no-cov -q || echo "FAIL $f"; done
```
**Retrieval regression battery.** `tests/fixtures/retrieval_battery.txt` is
+15
View File
@@ -507,6 +507,21 @@ body::before {
overflow-wrap: anywhere;
}
.bubble p { margin: 0.2rem 0; }
/* Phase 166 (TODO L4): the answer bubble an in-flight turn creates is
EMPTY by construction — app.js:4063/:4164 call addMessage("brain","")
so ensureReasoningWrap has an anchor to insert the per-run timeline
BEFORE. With .bubble's own padding + border + background + shadow an
empty one paints a ~40px box, which is the "empty box before the LLM
responds" the owner reported (image.png). :empty matches only a
child-less element — exactly innerHTML === "" (renderMarkdown("") is
"", so the restore and the shared page take the same path) — and the
first delta writes content, so the box appears AT the first token.
display:none also drops the flex item, so .msg-body's gap collapses.
The typing indicator is never :empty (three dot <span>s) — its
animation is untouched. Scoped to .msg .bubble: the viewer's .doc-*
blocks and the composer never match. Static suppression — no new
tokens, no animation (nothing for prefers-reduced-motion to still). */
.msg .bubble:empty { display: none; }
.bubble pre {
background: var(--surface);
color: var(--ink);
+202
View File
@@ -590,6 +590,37 @@ Implements just enough of the aipi surface:
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.
- user message containing ``paint the pre-answer window``
(``EMPTY_BUBBLE_TRIGGER``, phase 166 task 03 — the no-empty-
answer-box story's dedicated suite
``tests/e2e/test_no_empty_box.py``) **and** the system prompt
carries the ``<tools>`` section -> the deterministic PRE-ANSWER
WINDOW turn (TODO.md L4: "There's an empty box where the LLM
response will go that shows up before the LLM responds"):
thinking frames and a tool call with ZERO content deltas — the
exact shape that used to paint the empty answer bubble — with
baked-in delays (every visible window ≥1 s — the ``slow_llm.py``
/ phase-109 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): ~1.2 s
pre-delay (model latency while the typing indicator shows),
then 3 ``reasoning_content`` chunks at ~0.4 s (≈ 1.2 s of
thinking — this request NEVER emits ``delta.content``), 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 ~2.0 s frameless gap (the
post-tool pre-answer window), then 3 short ``delta.content``
chunks 0.05 s apart (the answer box appears WITH the first
chunk), ``finish_reason: "stop"``.
The turn's SSE is exactly ``thinking → tool → delta → done`` —
the mid-turn state the box must survive hidden in (typing
indicator → reasoning container → answer at the first token).
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
phrases — the phase-71/72/94 ordering convention); verified
2026-10-08: no existing E2E question or fixture file contains
the phrase, so every other suite is unaffected.
- user message containing ``repeat your folder map``
(``FOLDER_MAP_TRIGGER``, phase 119, D3 — the suggested-folder
context's story suite ``tests/e2e/test_name_signal_read_chips.py``)
@@ -3783,6 +3814,160 @@ def _turn_progress_stream(step: str) -> Any:
yield "data: [DONE]\n\n"
# ---------------------------------------------------------------------------
# Phase 166 (task 03, the no-empty-answer-box suite): the deterministic
# PRE-ANSWER WINDOW turn (thinking + a tool call, zero content deltas
# until the follow-up request) — see the module docstring
# ---------------------------------------------------------------------------
#: A user message containing this substring (case-insensitive) —
#: combined with the ``<tools>`` section in the system prompt — drives
#: the phase-166 PRE-ANSWER WINDOW turn (TODO.md L4: "There's an
#: empty box where the LLM response will go that shows up before the
#: LLM responds"): ~2.4 s of thinking + a tool call with ZERO content
#: deltas, then a ~2 s gap, then the answer — the exact shape that
#: painted the empty answer bubble (the owner's image.png) before
#: phase 166 hid it. Baked-in delays keep every visible window ≥1 s
#: (the ``slow_llm.py`` / phase-109 pacing precedent) so the story
#: E2E's window assertions are race-free. Verified 2026-10-08: no
#: existing E2E question or fixture file contains the phrase, so
#: every other suite is unaffected.
EMPTY_BUBBLE_TRIGGER = "paint the pre-answer window"
#: Request 1's pre-delay (s) — model latency: the typing indicator's
#: window (no frame of the request has arrived yet).
EMPTY_BUBBLE_PREDELAY_S = 1.2
#: Per-chunk delay (s) of request 1's three ``reasoning_content``
#: chunks — ≈ 1.2 s of visible thinking with NO ``delta.content``
#: (the window in which the empty bubble used to paint).
EMPTY_BUBBLE_THINK_DELAY_S = 0.4
#: Request 2's frameless gap (s) — the post-tool pre-answer window:
#: ≥ 1 s by construction, so the E2E can assert nothing paints an
#: answer box between the ``tool`` frame and the first token.
EMPTY_BUBBLE_TOOL_GAP_S = 2.0
#: Per-chunk delay (s) of request 2's short content deltas.
EMPTY_BUBBLE_CONTENT_DELAY_S = 0.05
#: Request 1's scratchpad (byte-stable): 3 chunks —
#: ``marker-thought-166`` is the sentinel the story E2E matches in
#: the open reasoning container during the pre-delta window.
EMPTY_BUBBLE_THINKING_CHUNKS = (
"The owner wants the exact JSON shape — ",
"listing the deployments folder first, ",
"then answering from the file itself. marker-thought-166.",
)
#: Request 2's answer (byte-stable): 3 short chunks —
#: ``marker-emptybox-1`` is the sentinel the story E2E matches on the
#: bubble to prove the box appeared WITH the first token.
EMPTY_BUBBLE_ANSWER_CHUNKS = (
"Here is the shape from the listing: ",
"version 3, two record sets, zone ",
"Z0RESEELINK01. marker-emptybox-1.",
)
#: The full answer (the three chunks joined) — what the bubble holds
#: once the turn settles.
EMPTY_BUBBLE_ANSWER = "".join(EMPTY_BUBBLE_ANSWER_CHUNKS)
def _empty_bubble_flow(body: dict[str, Any]) -> str | None:
"""Classify a phase-166 pre-answer-window 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 — thinking + the ``ls`` tool
call, ZERO content deltas.
* ``"second"`` — a ``tool``-role result is in the messages (the
server ran the ``ls``): request 2 — the ~2 s gap, then the
three content chunks.
* ``None`` — not the flow: the trigger is absent, the
``<tools>`` section is missing (deflected turns never carry
it), or tools are not offered and no tool result is in the
messages yet.
"""
if EMPTY_BUBBLE_TRIGGER not in _user(body).lower():
return None
if "<tools>" not in _system(body):
return None
if _tool_results(body):
return "second"
if not body.get("tools"):
return None
return "first"
def _empty_bubble_stream(step: str) -> Any:
"""SSE frames for one phase-166 pre-answer-window request.
``"first"`` (model call 1): the ~1.2 s pre-delay (model latency
— the typing-indicator window), the 3 ``reasoning_content``
chunks at 0.4 s each (≈ 1.2 s of thinking — this request emits
NO ``delta.content`` at all: the pre-delta state that used to
paint the empty box), 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.
``"second"`` (model call 2 — after the server ran the ``ls``):
the ~2.0 s frameless gap (the post-tool pre-answer window), the
3 short ``delta.content`` chunks 0.05 s apart (the box appears
with the first chunk and grows from there), 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(EMPTY_BUBBLE_PREDELAY_S)
for piece in EMPTY_BUBBLE_THINKING_CHUNKS:
yield frame({"reasoning_content": piece})
time.sleep(EMPTY_BUBBLE_THINK_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(EMPTY_BUBBLE_TOOL_GAP_S)
for piece in EMPTY_BUBBLE_ANSWER_CHUNKS:
yield frame({"content": piece})
time.sleep(EMPTY_BUBBLE_CONTENT_DELAY_S)
yield frame({}, "stop")
yield "data: [DONE]\n\n"
def compose_answer(body: dict[str, Any]) -> str:
system = _system(body)
user = _user(body)
@@ -5053,6 +5238,23 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 166 (task 03, the no-empty-answer-box suite): the
# deterministic PRE-ANSWER WINDOW turn — request 1 streams
# thinking + the ``ls`` tool call with ZERO content deltas,
# request 2 answers after a ~2 s gap (TODO.md L4 — the empty
# answer box that painted before the first token; 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 ``<tools>``
# section, so deflected turns never hit it).
empty_bubble = _empty_bubble_flow(body)
if empty_bubble is not None:
stream = _empty_bubble_stream(empty_bubble)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 161 (task 04 — the reasoning-bubble story): the
# deterministic MID-ROUND flow — a thinking run, then a
# MID-ROUND ``delta`` (the React-style partial answer — the
+2 -2
View File
@@ -521,7 +521,7 @@ def _expect_desc_cell(
escape hatch, ``makeDescCell`` — set whenever the description is
non-empty, whether or not the display ellipsizes)."""
span = page.locator(f"#folders-tbody tr:nth-child({row_index + 1}) "
"td:nth-child(4) span")
"td:nth-child(4) span.kb-desc-text")
expect(span).to_have_text(full_text)
expect(span).to_have_attribute("title", full_text)
@@ -633,7 +633,7 @@ def test_rag_table_renders_the_stored_voice(
expect(page.locator("#kb-level-summary")).to_have_text(EXPECTED[""])
# The rendered voice is the new one: no Description cell in the
# level shows a forbidden opener (the shape pin, view-side).
for cell in page.locator("#folders-tbody td:nth-child(4) span").all():
for cell in page.locator("#folders-tbody td:nth-child(4) span.kb-desc-text").all():
rendered = cell.text_content() or ""
for prefix in FORBIDDEN_PREFIXES:
assert not rendered.startswith(prefix), rendered
+40 -7
View File
@@ -91,6 +91,33 @@ def _box_height(page: Page, selector: str) -> float:
return box["height"]
def _meta_line_count(page: Page, selector: str) -> int:
"""How many VISUAL LINES the row's children occupy: cluster the
children by their vertical center (a 4px tolerance absorbs the
mixed badge/text/native-control heights on one line) and return the
number of clusters. ONE cluster = one line; a wrap produces two (the
second row of children sits a full line lower). Shape-first on
purpose: the row's own height is content-sized by its tallest child
— since phase 153 that is the ≥44px touch-target floor on
`.doc-copy-path` at ≤640px, so a pixel bound on the row would pin
the control's minimum, not the layout."""
centers = page.evaluate(
"""(sel) => [...document.querySelectorAll(sel + ' > *')]
.map(el => { const b = el.getBoundingClientRect();
return b.height > 0 ? b.top + b.height / 2 : null; })
.filter(v => v !== null)""",
selector,
)
assert centers, f"{selector} rendered no visible children"
clusters: list[list[float]] = []
for c in sorted(centers):
if clusters and c - clusters[-1][-1] <= 4:
clusters[-1].append(c)
else:
clusters.append([c])
return len(clusters)
def _header_heights(page: Page, app_url: str) -> dict[str, float]:
"""Measured heights of the sticky top bar on the three pages."""
heights: dict[str, float] = {}
@@ -201,13 +228,19 @@ def test_viewer_header_content_still_fits(
assert _box_height(page, "#doc-title") < 30 # one line at either size
# Meta row: badges + path present and visible, single line.
# The bound catches a WRAP (a second line would be ≥ ~44px),
# not a pixel-exact line height: the row is content-sized by
# its tallest child — the native type=date input (phase 106
# D8), whose metrics are Chromium/font-dependent and render
# the single line at ~26px on this host (the pre-phase 26px
# pin red-lined on the 26.125px measurement, 2026-09-16).
assert _box_height(page, ".doc-meta") < 34
# SHAPE, not pixels: the pre-phase absolute bound (<34px) went
# stale the moment the row gained a control carrying the ≥44px
# touch-target floor (phase 153: .doc-copy-path min-height 44px
# at ≤640px — PLAN §7.1), which makes a single, correctly-wrapped
# mobile line measure 44px. What the contract actually forbids is
# the row STACKING (a wrap grows the header), so the pin reads the
# geometry directly: every child centered on the row's own center
# = ONE line, whatever the tallest control's height (the same
# shape-first reading the 2026-09-16 note above already moved to).
assert _meta_line_count(page, ".doc-meta") == 1, (
"the meta row must stay on ONE line (badges, path, dates and "
"controls centered on the row's own center)"
)
expect(page.locator(".doc-source-badge", has_text="docs")).to_be_visible()
expect(page.locator(".format-badge", has_text="md")).to_be_visible()
expect(page.locator(".doc-path", has_text="homelab/kubernetes.md")).to_be_visible()
+9 -9
View File
@@ -545,10 +545,10 @@ def test_top_level_lists_sources_with_descriptions(
rows = page.locator("#folders-tbody tr")
expect(rows.nth(0).locator("a.folder-link")).to_have_text(ALPHA)
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text(str(ALPHA_COUNT))
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(ALPHA_ROOT_SUM)
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ALPHA_ROOT_SUM)
expect(rows.nth(1).locator("a.folder-link")).to_have_text(BETA)
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text(str(BETA_COUNT))
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(BETA_ROOT_SUM)
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(BETA_ROOT_SUM)
# The edit affordance is ALWAYS present (a description can be
# CREATED where none is stored) — the row surface, one per row.
expect(page.locator("#folders-tbody .kb-summary-edit")).to_have_count(2)
@@ -607,10 +607,10 @@ def test_drill_into_source(
expect(rows).to_have_count(2)
expect(rows.nth(0).locator("a.folder-link")).to_have_text("one")
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text("2")
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(ONE_SUM)
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ONE_SUM)
expect(rows.nth(1).locator("a.folder-link")).to_have_text("two")
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text("2")
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(TWO_SUM)
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(TWO_SUM)
# The level's direct files: root-note.md — the 6-column contract
# (makeRow): Source | Path | Title | Chunks | Created | Indexed
@@ -718,7 +718,7 @@ def test_edit_folder_description(
# (the textContent re-render — the canned text is gone); the
# always-present Edit button is back.
expect(status).to_have_text("Description updated.")
expect(row.locator("td:nth-child(4) span")).to_have_text(NEW_ALPHA_ONE)
expect(row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(NEW_ALPHA_ONE)
expect(row.locator(".kb-summary-edit")).to_be_visible()
# The server row: the new text AND the manually_edited flag (the
@@ -761,7 +761,7 @@ def test_clear_folder_description(
# The text is gone: the cell empties (the always-present Edit
# button stays — a description can be re-created from the empty
# cell).
expect(row.locator("td:nth-child(4) span")).to_have_text("")
expect(row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text("")
expect(row.locator(".kb-summary-edit")).to_be_visible()
# The SQL assert: no row for the folder (the next KB-changing
@@ -794,7 +794,7 @@ def test_manual_description_survives_a_changed_sync(
rows = page.locator("#folders-tbody tr")
expect(rows).to_have_count(1)
expect(rows.nth(0).locator("a.folder-link")).to_have_text("gamma")
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(GAMMA_SUM)
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(GAMMA_SUM)
# The LEVEL-BLOCK Edit (the level surface — test 4 pins the row
# surface): the static #kb-level-edit button, the source root
@@ -836,7 +836,7 @@ def test_manual_description_survives_a_changed_sync(
expect(
page.locator("#folders-tbody tr")
.nth(0)
.locator("td:nth-child(4) span")
.locator("td:nth-child(4) span.kb-desc-text")
).to_have_text(GAMMA_SUM)
# The new document landed in the tree: back to the top (the
@@ -847,7 +847,7 @@ def test_manual_description_survives_a_changed_sync(
one_row = page.locator("#folders-tbody tr").nth(0)
expect(one_row.locator("a.folder-link")).to_have_text("one")
expect(one_row.locator("td:nth-child(2)")).to_have_text("3")
expect(one_row.locator("td:nth-child(4) span")).to_have_text(ONE_SUM)
expect(one_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ONE_SUM)
# The SQL asserts: the manual row survived (text + flag), the
# untouched rows were regenerated (the canned text, the flag back
+713
View File
@@ -0,0 +1,713 @@
"""Phase 166 E2E (Playwright, mock-only): NO empty answer box paints
before the first token (owner TODO.md L4 — "There's an empty box where
the LLM response will go that shows up before the LLM responds. It
looks ugly." — the attached image.png: Thinking ▸ / Tool calls (2) ▸ /
an expanded Thinking run, then a bordered, padded, EMPTY rounded box).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_no_empty_box.py -v --no-cov
MOCK-ONLY suite: the gate is the deterministic paced marker flow in
``tests/e2e/mock_llm.py`` (user message contains ``paint the pre-answer
window`` (``EMPTY_BUBBLE_TRIGGER``) **and** the system prompt carries
the ``<tools>`` section of the HIGH prompt): request 1 streams ~1.2 s
of ``reasoning_content`` and one ``ls`` tool call with ZERO content
deltas (the exact mid-turn state of the owner's screenshot), request 2
(id a ``tool``-result) waits ~2.0 s, then answers in three short
chunks. Every visible window is ≥ 1 s by construction (the phase-109
pacing precedent), so the window assertions are race-free — the
pre-delta window from the first thinking frame to the first delta is
~3.2 s.
The fix is the ONE CSS rule ``.msg .bubble:empty { display: none; }``
(LOCKED A1 — hide in CSS, the JS keeps creating the empty bubble as
the phase-158/161 reasoning-container anchor); nothing replaces the
box (A2 — the typing indicator + the reasoning container ARE the
mid-turn feedback, PLAN §7.4), and the same rule covers the restore
and the shared page (A3). The unit pins live in
``tests/unit/test_no_empty_answer_bubble.py``; this file proves the
browser behavior.
KB fixture — the deterministic two-document pair mirrored from
``test_reasoning_bubble.py``: the marker question cosines well past
the mock-calibrated 0.30 E2E threshold plus FTS hits, so the turn is
solidly grounded (HIGH prompt → ``<tools>``).
Test → phase mapping (Playwright Mapping Rule):
1. ``test_no_box_paints_before_the_first_token`` — the defect window
(D1/D3): once ``details.reasoning`` exists (the sync point — the
first thinking frame, which also removes the typing indicator),
``.msg.brain .bubble`` is NOT visible and its box is zero-height
while the thinking + tool frames stream, while the reasoning
container IS open with a live status literal (``Thinking…`` /
``Calling ``) and the composer reads the in-flight state (``Stop``).
The mid-turn screenshot is the owner's before/after record. THE
SETTLE: after ``done`` the SAME locator is visible with ``height >
0`` holding the answer, the container folds, and the phase-130 rows
(chips, actions) sit BELOW the bubble (hiding the bubble never
hides them).
2. ``test_plain_turn_typing_indicator_carries_the_window`` — the
no-thinking control: a plain answer question shows the typing
indicator (dots ⇒ never ``:empty`` ⇒ painted with height > 0)
while in flight, and NO empty bubble ever paints a box. The plain
turn is FAST (the mock streams unpaced), so the proof is an
IN-PAGE sampler on a 10 ms interval installed BEFORE the click:
typing painted ≥ 1 sample, painted empty boxes 0 samples across
every sample of the turn (the answer bubble is created WITH its
first delta in one JS task — nothing can paint between them, and
the sampler is the browser-side evidence of exactly that; A2).
3. ``test_restored_answered_chat_has_no_empty_box`` — the restore
corner (A3): the auto-saved chat re-loaded through ``/?chat=<id>``
renders its answer bubble normally and carries ZERO
``.bubble:empty`` elements.
4. ``test_shared_page_has_no_empty_box`` — the shared-page corner
(A3): the phase-51 save + share flow, a fresh anonymous context on
``/shared/<token>`` — the answer renders, zero ``.bubble:empty``.
5. ``test_empty_persisted_answer_paints_no_box`` — the empty-TEXT
record corner (A3, the shared-page defect half): a saved chat
whose persisted brain text is whitespace (passes the client's
``text.length > 0`` filter, renders through ``renderMarkdown(" ")
=== ""``) paints NO box on ``/?chat=<id>`` AND on its shared
page — the bubble exists in the DOM (the anchor contract) but
zero-height and hidden.
"""
from __future__ import annotations
import hashlib
import re
import time
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
from playwright.sync_api import Browser, BrowserContext, Page, expect
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, GitSource, SavedChat
from e2e.auth_helpers import login
from tests.e2e.mock_llm import EMPTY_BUBBLE_ANSWER, EMPTY_BUBBLE_TRIGGER, embed_text
REPO = Path(__file__).resolve().parents[2]
SCREENSHOTS = REPO / ".agents" / "screenshots" # house convention for visual records
# --------------------------------------------------------------------------
# Fixture documents (deterministic, token-controlled — mirrored from
# test_reasoning_bubble.py so the grounding gate is byte-stable)
# --------------------------------------------------------------------------
SEED_SOURCE = "Homelab"
SEED_PATH = "aws-route53.md"
READ_SOURCE = "Deployments"
READ_PATH = "example-record-file.json"
ROUTE53_CONTENT = (
"# AWS Route 53 Notes\n\n"
"## Record file\n\n"
+ (
"The aws route53 hosted zone for reeselink keeps every record in "
"reseelink.json — the exact JSON shape of reeselink.json is "
"documented in example-record-file.json.\n"
)
* 10
+ "\n## Sync job\n\n"
"A cron job pushes reeselink.json to the aws route53 hosted zone "
"every fifteen minutes; the diff is applied through the route53 api.\n"
)
RECORD_FILE_CONTENT = (
'{ "version": 3, "comment": "ReeseLink hosted zone records — the exact '
"JSON shape of reeselink.json\",\n"
' "hosted_zone_id": "Z0RESEELINK01",\n'
' "record_sets": [\n'
' { "name": "www.reeselink.example", "type": "A", "ttl": 300,\n'
' "resource_records": [ { "value": "10.0.0.20" } ] },\n'
' { "name": "api.reeselink.example", "type": "CNAME", "ttl": 300,\n'
' "resource_records": [ { "value": "www.reeselink.example" } ] }\n'
" ]\n"
"}\n"
)
#: The phase-166 marker question: ``EMPTY_BUBBLE_TRIGGER`` + the KB
#: topic — the mock-calibrated cosine keeps the turn grounded (HIGH
#: prompt → ``<tools>`` → the deterministic pre-answer-window flow).
EMPTY_WINDOW_QUESTION = (
f"{EMPTY_BUBBLE_TRIGGER.capitalize()}: what is the exact JSON shape "
"of reeselink.json for my aws route53 hosted zone?"
)
#: The no-thinking control (NO trigger — the generic ``compose_answer``
#: path: no thinking frames, no tool frames; the typing indicator is
#: the whole pre-answer state).
PLAIN_ANSWER_QUESTION = (
"What is the exact JSON shape of reeselink.json for my aws "
"route53 hosted zone?"
)
#: The live status literals of the reasoning container (phase 161 D6)
#: — the thinking/tool state's visible text while the box stays hidden.
LIVE_STATUS_RE = re.compile(r"^(Thinking…|Calling )")
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, mirrored from test_reasoning_bubble.py)
# --------------------------------------------------------------------------
def _seed(db: Session) -> None:
"""The two-document pair (see the module docstring), plus the
phase-94 registry rows: both sources registered, ``Deployments``
FIRST (the operator's ``BOR_GIT_SOURCES`` is ignored — a non-empty
table wins). COMMIT between the inserts (not flush): ``added_at``
is ``server_default now()`` and the tie-break is the random uuid
``id`` — one-transaction rows order nondeterministically."""
db.add(GitSource(url=READ_SOURCE, kind="local"))
db.commit()
db.add(GitSource(url=SEED_SOURCE, kind="local"))
md = Document(
source=SEED_SOURCE,
path=SEED_PATH,
full_path=f"/tmp/{SEED_PATH}",
title="AWS Route 53 Notes",
content=ROUTE53_CONTENT,
content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2024, 6, 15, tzinfo=UTC),
)
db.add(md)
db.flush()
# One chunk carrying the mock's own embedding → genuine token
# overlap between the marker question and this document.
db.add(
Chunk(
document_id=md.id,
position=0,
content=ROUTE53_CONTENT,
embedding=embed_text(ROUTE53_CONTENT),
)
)
db.add(
Document(
source=READ_SOURCE,
path=READ_PATH,
full_path=f"/tmp/{READ_PATH}",
title="Example Record File",
content=RECORD_FILE_CONTENT,
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2024, 6, 15, tzinfo=UTC),
)
)
def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
"""Truncate the KB (plus the prompt-shaping tables and the
phase-55 auto-save rows), then re-seed — the E2E isolation
pattern. ``steering_notes`` / ``kb_overview`` are truncated too,
so the HIGH prompt is exactly ``<relevance>`` + ``<documents>`` +
``<tools>`` regardless of leftovers — byte-stable prompts,
byte-stable answers."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, "
"steering_notes, kb_overview, saved_chats, git_sources"
)
)
db.commit()
if seed is not None:
seed(db)
db.commit()
# --------------------------------------------------------------------------
# Page helpers
# --------------------------------------------------------------------------
#: The defect's visible signature: a ``.msg .bubble`` element that is
#: EMPTY (child-less — exactly what ``addMessage("brain","")``
#: produces) yet still PAINTS A BOX (height > 0). The owner's
#: image.png is this number being ≥ 1; phase 166 makes it 0 at every
#: sample, live, restored, and shared (the same stylesheet on the
#: shared page — LOCKED A3).
PAINTED_EMPTY_BOXES_JS = """
() => [...document.querySelectorAll('#messages .msg .bubble')]
.filter((el) => el.matches(':empty') && el.getBoundingClientRect().height > 0)
.length
"""
#: The IN-PAGE sampler (the no-thinking control's race-free proof):
#: a 10 ms interval installed BEFORE the submit counts (a) samples
#: where ``#typing-indicator .bubble`` painted a box and (b) samples
#: where any EMPTY ``.msg .bubble`` painted a box. The plain turn can
#: settle in ~100 ms — too fast for out-of-band polling to bracket
#: reliably; the sampler sees every 10 ms slice of the page from the
#: click to the settle (and a few past it) from INSIDE the page.
SAMPLER_ON_JS = """
() => {
window.__p166 = { samples: 0, typingPainted: 0, paintedEmpty: 0 };
window.__p166.timer = setInterval(() => {
const s = window.__p166;
s.samples += 1;
const t = document.querySelector('#typing-indicator .bubble');
if (t && t.getBoundingClientRect().height > 0) s.typingPainted += 1;
const empties = [...document.querySelectorAll('#messages .msg .bubble')]
.filter((el) => el.matches(':empty')
&& el.getBoundingClientRect().height > 0);
if (empties.length > 0) s.paintedEmpty += 1;
}, 10);
}
"""
SAMPLER_OFF_JS = """
() => {
clearInterval(window.__p166.timer);
const { samples, typingPainted, paintedEmpty } = window.__p166;
return { samples, typingPainted, paintedEmpty };
}
"""
def _bubble_height(locator: Any) -> float:
"""The painted box height of one bubble — ``display: none`` (the
phase-166 rule) yields exactly 0."""
height: float = locator.evaluate("el => el.getBoundingClientRect().height")
return height
def _submit(page: Page, question: str) -> None:
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button
recovered (phase 48 — the in-flight button is the enabled Stop
control, so the label assertion carries the settle wait)."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# --------------------------------------------------------------------------
# Phase-51 save + share flow (the test_reasoning_bubble.py pattern — the
# auto-saved row is shared from the chat page; the row is deleted in a
# finally)
# --------------------------------------------------------------------------
def _admin_cookies(page: Page) -> dict[str, str]:
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55 — auto-saves are SILENT,
so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so poll the admin
list until the row with the conversation's auto-title appears
with the expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the admin context
(headless Chromium requires the grant before ``writeText``
resolves — without it the inline-link fallback fires)."""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _click_share_and_assert_status(page: Page, app_url: str) -> None:
"""Press the chat page's Share pill and pin the outcome on the
live region (the assertion branches on the API's availability —
the test_share_chat.py convention)."""
page.locator("#share-chat-btn").click()
if page.evaluate("() => !!navigator.clipboard"):
expect(page.locator("#send-status")).to_have_text(
"Share link copied.", timeout=15_000
)
else:
expect(page.locator("#send-status")).to_have_text(
"Share link ready — copy it from the field.", timeout=15_000
)
field = page.locator(".share-link-fallback")
expect(field).to_be_visible()
href = field.get_attribute("href")
assert href is not None
assert href.startswith(app_url)
assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url))
# --------------------------------------------------------------------------
# 1. The defect window: thinking + tool frames stream and NO answer
# box paints — the box appears WITH the first delta (D1/D3)
# --------------------------------------------------------------------------
def test_no_box_paints_before_the_first_token(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
_submit(page, EMPTY_WINDOW_QUESTION)
# THE SYNC POINT: details.reasoning exists only after the FIRST
# thinking frame (ensureReasoningWrap runs in the same handler
# tick as addMessage("brain", "") and removeTyping()) — so from
# here on the ONLY .msg.brain .bubble is the empty answer bubble,
# and the mock's pacing keeps the window open ≥ 1 s of thinking +
# the tool round + the ~2 s gap (≈ 3.2 s total before the first
# delta — race-free by construction).
expect(page.locator("#messages .msg.brain details.reasoning")).to_be_visible()
# THE DEFECT WINDOW: the answer bubble exists in the DOM (the
# phase-158/161 reasoning anchor — LOCKED A1 keeps it) but paints
# NO BOX: not visible AND zero-height (the owner's image.png box
# is gone). The typing indicator is gone too (the live block
# replaced the dots), so the single strict match doubles as the
# no-stray-bubble pin.
bubble = page.locator("#messages .msg.brain .bubble")
expect(bubble).not_to_be_visible()
assert _bubble_height(bubble) == 0, (
"the pre-delta answer bubble must paint zero box (the phase-166 defect)"
)
assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0, (
"no empty .bubble may paint a box mid-turn (live window)"
)
# §7.4 — the mid-turn feedback DOES paint: the reasoning container
# is OPEN with a thinking run and its live status literal
# (Thinking… / Calling <name>…), and the composer carries the
# in-flight state (the phase-48 Stop label).
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_be_visible()
expect(
page.locator("#messages .msg.brain details.reasoning .thinking-text")
).to_be_visible()
expect(
page.locator("#messages .msg.brain details.reasoning .reasoning-status")
).to_have_text(LIVE_STATUS_RE)
expect(page.locator("#send-label")).to_have_text("Stop")
# The owner's before/after record (image.png's twin): the open
# reasoning run with NO empty box under it.
SCREENSHOTS.mkdir(parents=True, exist_ok=True)
shot = SCREENSHOTS / "166_pre_answer_window_1280.png"
page.screenshot(path=str(shot))
assert shot.exists() and shot.stat().st_size > 0
# THE SETTLE: the box appears at (and after) the first token. The
# SAME locator is now visible with a real box and holds the
# byte-stable answer (the three chunks of the mock's request 2).
_wait_settled(page)
expect(bubble).to_be_visible()
assert _bubble_height(bubble) > 0, "the answer box must paint with the first token"
expect(bubble).to_contain_text(EMPTY_BUBBLE_ANSWER)
# The container folded at done (phase 161 D2 — the settle fold).
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
# Phase 130 ordering survives the hidden box: the chips row and
# the actions row are SIBLING rows of .msg-body BELOW the bubble
# (hiding an empty bubble never hides a chip or an action).
order = page.evaluate(
"""
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps[wraps.length - 1];
const body = wrap ? wrap.querySelector('.msg-body') : null;
const kids = body ? [...body.children] : [];
return {
bubbleIdx: kids.findIndex((k) => k.classList.contains('bubble')),
metaIdxs: kids
.map((k, i) => (k.classList.contains('msg-meta') ? i : -1))
.filter((i) => i >= 0),
};
}
"""
)
assert order["bubbleIdx"] >= 0, "the settled turn keeps its answer bubble"
assert order["metaIdxs"], "the settled turn keeps its .msg-meta rows"
assert all(idx > order["bubbleIdx"] for idx in order["metaIdxs"]), (
f"every .msg-meta row sits BELOW the bubble: {order}"
)
expect(
page.locator("#messages .msg.brain .msg-meta.msg-actions")
).to_have_count(1)
# --------------------------------------------------------------------------
# 2. The no-thinking control: the typing indicator (dots ⇒ never
# :empty) carries the pre-answer window; no empty box at any point
# --------------------------------------------------------------------------
def test_plain_turn_typing_indicator_carries_the_window(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
# The plain turn is FAST (the mock streams unpaced — the whole
# turn can settle in ~100 ms), so the proof runs IN-PAGE: the
# sampler counts 10 ms slices from BEFORE the click to a few
# hundred ms past the settle. The typing indicator lands
# synchronously in the click handler (setUiState(thinking) →
# addTyping(): three dot <span>s ⇒ never :empty ⇒ painted) and
# the answer bubble is created WITH its first delta in one JS
# task — every slice of the turn is accounted for (locked A2:
# this affordance IS the pre-answer window).
page.evaluate(SAMPLER_ON_JS)
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
# A few extra samples past the settle (the painted answer bubble
# is non-empty — the sampler must still read zero painted boxes).
time.sleep(0.3)
stats = page.evaluate(SAMPLER_OFF_JS)
assert stats["samples"] >= 5, f"the sampler never ran: {stats}"
assert stats["typingPainted"] >= 1, (
"the typing indicator was never observed painting its dots box "
f"while the turn was in flight: {stats}"
)
assert stats["paintedEmpty"] == 0, (
f"an empty answer box painted during the plain turn: {stats}"
)
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("")
assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
# --------------------------------------------------------------------------
# 3. The restore corner: an auto-saved chat re-loaded through
# /?chat=<id> renders its bubble normally, zero .bubble:empty
# --------------------------------------------------------------------------
def test_restored_answered_chat_has_no_empty_box(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(PLAIN_ANSWER_QUESTION))
try:
# The phase-50 restore: /?chat=<id> boots into the saved
# conversation through renderStoredMessage — the answered turn
# renders its bubble normally (non-empty, painted).
page.goto(app_url + f"/?chat={row['id']}")
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_be_visible()
expect(bubble).not_to_have_text("")
assert _bubble_height(bubble) > 0
# ZERO child-less bubbles exist in the restored DOM — the
# restore creates no empty anchor (the anchor is a LIVE-turn
# construct), so nothing even needs hiding.
assert page.locator("#messages .msg .bubble:empty").count() == 0
assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
finally:
_delete_chat(app_url, cookies, row["id"])
# --------------------------------------------------------------------------
# 4. The shared-page corner (LOCKED A3): the phase-51 share flow, a
# fresh anonymous context — zero .bubble:empty, the answer painted
# --------------------------------------------------------------------------
def test_shared_page_has_no_empty_box(
page: Page,
browser: Browser,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
_reset_db(_seed)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
# The phase-51 flow: the conversation is AUTO-SAVED by the time
# the answer settles (phase 55) — the Share click shares the
# linked row.
cookies = _admin_cookies(page)
_grant_clipboard(page, app_url)
_wait_saved_row(app_url, cookies, _auto_title(PLAIN_ANSWER_QUESTION))
share = page.locator("#share-chat-btn")
expect(share).to_be_visible()
_click_share_and_assert_status(page, app_url)
row = _find_row(_chats(app_url, cookies), _auto_title(PLAIN_ANSWER_QUESTION))
assert row is not None and row.get("share_url"), (
"the shared row must carry a share_url"
)
share_url: str = row["share_url"]
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
created: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
# A FRESH context: a separate session with no cookies — the
# guest's only credential is the token in the URL.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
# The shared answer bubble renders normally (non-empty,
# painted) — and shared.js builds the identical
# .msg/.msg-body/.bubble structure on the same stylesheet, so
# ZERO child-less bubbles exist here either (locked A3).
bubble = anon.locator(".msg.brain .bubble").first
expect(bubble).to_be_visible()
expect(bubble).not_to_have_text("")
assert _bubble_height(bubble) > 0
assert anon.locator("#messages .msg .bubble:empty").count() == 0
assert anon.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
finally:
if anon_ctx is not None:
anon_ctx.close()
if created:
_delete_chat(app_url, cookies, created)
# --------------------------------------------------------------------------
# 5. The empty-TEXT record corner (locked A3): a persisted brain answer
# of " " passes the client's length>0 filter and renders through
# renderMarkdown(" ") === "" — the bubble lands CHILD-LESS in the
# DOM and the SAME rule hides it on /?chat=<id> AND the shared page
# --------------------------------------------------------------------------
def test_empty_persisted_answer_paints_no_box(
page: Page,
browser: Browser,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
# The crafted record: the whitespace brain text passes the
# restore's `text.length > 0` filter (a real empty string would be
# filtered out before rendering), and renderMarkdown(" ") is ""
# (unit-pinned) — so the bubble renders CHILD-LESS: exactly the
# :empty shape the phase-166 rule suppresses. share_token rides
# the same row so the shared page takes the identical corner.
token = uuid.uuid4()
with SessionLocal() as db:
row = SavedChat(
title="Phase 166 empty-answer corner",
messages=[
{"who": "user", "text": "What is the exact JSON shape?"},
{"who": "brain", "text": " "},
],
share_token=token,
)
db.add(row)
db.commit()
chat_id = str(row.id)
anon_ctx: BrowserContext | None = None
try:
# The restore: the user record renders; the empty brain
# bubble EXISTS in the DOM (the same structure, no restore
# branch was added) but paints NO BOX.
page.goto(app_url + f"/?chat={chat_id}")
expect(page.locator(".msg.user .bubble").first).to_contain_text(
"What is the exact JSON shape?"
)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_have_count(1)
expect(bubble).not_to_be_visible()
assert _bubble_height(bubble) == 0, (
"the empty persisted answer must paint zero box (restore — A3)"
)
assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
# The shared page: the same record, a fresh anonymous context
# — same zero-box outcome (shared.js renders the identical
# structure on the same stylesheet).
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + f"/shared/{token}")
expect(anon.locator(".msg.user .bubble").first).to_contain_text(
"What is the exact JSON shape?"
)
anon_bubble = anon.locator(".msg.brain .bubble").last
expect(anon_bubble).to_have_count(1)
expect(anon_bubble).not_to_be_visible()
assert (
anon_bubble.evaluate("el => el.getBoundingClientRect().height") == 0
), "the empty persisted answer must paint zero box (shared page — A3)"
assert anon.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
finally:
if anon_ctx is not None:
anon_ctx.close()
with SessionLocal() as db:
db.execute(
text("DELETE FROM saved_chats WHERE id = :id"), {"id": chat_id}
)
db.commit()
+39 -13
View File
@@ -270,12 +270,28 @@ def test_no_autoscroll_during_long_answer(
# Phase 48: in-flight detection rides the Stop label (the button is
# the enabled Stop control while a turn runs — it is no longer
# disabled, so the disabled-state proxy is gone).
#
# WHAT IS MEASURED (2026-10-08): the VISUAL position of the text the
# user is reading — the first answer bubble's top relative to the
# viewport — not the raw scrollY. Chromium's native scroll anchoring
# legitimately compensates scrollY when layout above the viewport
# changes (at settle the Edit/Retry buttons re-target to the newest
# turn, so the FIRST turn's meta row collapses 44px→0): the reader
# sees nothing move, but a bare scrollY-constancy check would flag
# the compensating adjustment itself. A real up-hop (the regression
# this suite pins — the old block:"end" submit jump, programmatic
# follow-scrolls) moves the marker just the same, so the contract is
# asserted strictly, and correctly, on what the user perceives.
marker_top = """() => {
const el = document.querySelector('.msg.brain .bubble');
return el ? el.getBoundingClientRect().top : null;
}"""
samples: list[float] = []
mid_stream = 0
deadline = time.monotonic() + 40
while time.monotonic() < deadline:
time.sleep(0.25)
samples.append(scroll_state(page)["y"])
samples.append(page.evaluate(marker_top))
if page.locator("#send-label").inner_text() == "Stop":
mid_stream += 1
if len(samples) >= 10 and page.locator("#send-label").inner_text() == "Send":
@@ -283,7 +299,7 @@ def test_no_autoscroll_during_long_answer(
# queued behind the stream may move the page either).
for _ in range(3):
time.sleep(0.3)
samples.append(scroll_state(page)["y"])
samples.append(page.evaluate(marker_top))
break
else:
raise AssertionError("the long turn did not settle within 40s")
@@ -291,8 +307,8 @@ def test_no_autoscroll_during_long_answer(
spread = max(samples) - min(samples)
assert spread <= STABLE_PX, (
f"the viewport moved {spread:.1f}px while the user was scrolled up "
"(no-reply-autoscroll contract)"
f"the page moved {spread:.1f}px under the user's reading position "
"while they were scrolled up (no-reply-autoscroll contract)"
)
# The whole answer still landed (off-screen below — by design).
@@ -318,13 +334,17 @@ def test_no_autoscroll_during_thinking(
# The thinking turn: the submit reveals the user's message (the kept
# app scroll) ...
submit(page, THINKING_QUESTION)
details = page.locator(".msg.brain").last.locator("details.thinking")
# 690fd98 refinement: ONE details.reasoning container per turn (the
# live per-run blocks are plain divs that never fold); the container
# is created OPEN and auto-folds on settle — same view-stability
# contract phase 17 pinned, one level up.
details = page.locator(".msg.brain").last.locator("details.reasoning")
details.wait_for(state="attached", timeout=10_000)
expect(details).to_have_attribute("open", "") # created open (phase 17)
# ... and, once the reasoning stream is clearly running ...
page.wait_for_function(
"() => { const el = document.querySelector('details.thinking .thinking-text');"
" return !!el && el.innerText.length > 300; }",
"() => { const els = document.querySelectorAll('details.reasoning .thinking-text');"
" let n = 0; els.forEach((e) => { n += e.innerText.length; }); return n > 300; }",
timeout=30_000,
)
# ... let the submit's smooth reveal settle, then the user goes up.
@@ -339,7 +359,7 @@ def test_no_autoscroll_during_thinking(
time.sleep(0.3)
state = page.evaluate(
"""() => {
const block = document.querySelector('details.thinking');
const block = document.querySelector('details.reasoning');
const wrap = block ? block.closest('.msg.brain') : null;
const el = wrap ? wrap.querySelector('.bubble') : null;
return { y: window.scrollY,
@@ -366,7 +386,9 @@ def test_no_autoscroll_during_thinking(
# the point of the story).
wait_settled(page)
assert abs(scroll_state(page)["y"]) <= STABLE_PX
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
# Settled: the container auto-folded; its last run holds the tail.
assert not details.get_attribute("open")
expect(details.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
@@ -484,9 +506,12 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
# (phase 17: open while streaming, closed from the first delta on).
submit(page, THINKING_QUESTION)
wait_settled(page)
details = page.locator(".msg.brain").last.locator("details.thinking")
# 690fd98 refinement: settled turns carry ONE details.reasoning
# container (auto-collapsed by settle, phase-17 contract) holding the
# plain per-run blocks — the per-run details.thinking is gone live.
details = page.locator(".msg.brain").last.locator("details.reasoning")
expect(details).not_to_have_attribute("open")
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
expect(details.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
# Persistence: four messages, the thinking text stored raw — and
@@ -508,9 +533,10 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
expect(page.locator(".msg.brain .bubble")).to_have_count(2)
expect(page.locator(".msg.brain .bubble").first).to_contain_text(LONG_ANSWER_END)
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
restored = page.locator(".msg.brain").last.locator("details.thinking")
# Restore writes the SAME container shape (segments-based restore).
restored = page.locator(".msg.brain").last.locator("details.reasoning")
expect(restored).not_to_have_attribute("open")
expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
expect(restored.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
wait_settled(page)
+13 -6
View File
@@ -651,12 +651,19 @@ def test_scoped_scope_survives_save_restore(
page.goto(app_url + f"/?chat={row['id']}")
_assert_scoped_line(_tool_line(page))
# Phase 117 (D3): a restored turn renders FOLDED — one compact
# "Tool call(s) (N)" line (the disclosure shape is unchanged by
# this phase: the `open` attribute is ABSENT).
disclosure = page.locator(".msg.brain .tool-calls-disclosure").last
expect(disclosure).not_to_have_attribute("open", timeout=30_000)
expect(disclosure.locator("summary")).to_have_text(
# Phase 117 (D3), re-pinned for the phase-161 D5 refinement (the
# owner's 2026-10-06 shape, shipped in 690fd98): a restored turn
# renders FOLDED inside the ONE details.reasoning container — the
# container is the ONLY disclosure (zero nested <details>), the
# run itself is a plain div whose label is the
# .tool-calls-summary SPAN. The compact "Tool call (1)" line the
# phase-117 pin named is unchanged; only the element carrying it
# moved (this phase's scope — the scoped line — is asserted above).
container = page.locator(".msg.brain details.reasoning").last
expect(container).not_to_have_attribute("open", timeout=30_000)
expect(container.locator("details")).to_have_count(0)
run = page.locator(".msg.brain .tool-calls-disclosure").last
expect(run.locator(".tool-calls-summary")).to_have_text(
"Tool call (1)", timeout=30_000
)
+17 -6
View File
@@ -340,12 +340,23 @@ def test_anonymous_shared_view(
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
assert bubble.inner_text() == answer, "the shared answer must match the admin's"
# The thinking block exists and is RESTORED COLLAPSED (the
# phase-17 restore convention — a closed <details> has no
# `open` attribute).
think = anon.locator("details.thinking")
expect(think).to_have_count(1)
expect(think.first).not_to_have_attribute("open")
# The reasoning timeline exists and renders COLLAPSED (the
# phase-161 D5 convention, updated by the 2026-10-06 owner
# refinement shipped in 690fd98 — the saved turn carries
# `segments`, so shared.js's addSegments wraps the per-run
# timeline in the ONE details.reasoning container; a closed
# <details> has no `open` attribute, and the inner runs are
# plain divs — NEVER a nested <details>).
cont = anon.locator("details.reasoning")
expect(cont).to_have_count(1)
expect(cont.first).not_to_have_attribute("open")
expect(cont.locator("details")).to_have_count(0)
# The thinking RUN itself is present inside the collapsed
# container (the plain-div shape — the guest sees the same
# timeline the admin saw, just folded at rest).
runs = cont.locator("div.thinking")
expect(runs).to_have_count(1)
expect(runs.first.locator(".thinking-text")).not_to_be_empty()
# Source chips: ZERO — the shared turn read nothing, so
# (phase 119, LOCKED A1) its done sources are empty and the
+8 -4
View File
@@ -366,9 +366,11 @@ def test_nav_switch_before_first_token_completes(
# running — NO content frame has landed yet. (The .msg.brain bubble
# element exists from turn start with its thinking block — the
# pre-token state is "no content text", not "no bubble element".)
thinking = page.locator(".msg.brain").last.locator("details.thinking")
# 690fd98 refinement: the live thinking block is a plain div.thinking
# RUN (never folding mid-turn) inside the details.reasoning container.
thinking = page.locator(".msg.brain").last.locator("div.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(
expect(thinking.locator(".thinking-text").last).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48
@@ -444,9 +446,11 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
# which outruns Playwright's 5s assertion auto-wait on a loaded host
# (the wait started at the FIRST thinking frame — the pre-existing
# race, phase 55 task 02 fix).
thinking = page.locator(".msg.brain").last.locator("details.thinking")
# 690fd98 refinement: the live thinking block is a plain div.thinking
# RUN (never folding mid-turn) inside the details.reasoning container.
thinking = page.locator(".msg.brain").last.locator("div.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(
expect(thinking.locator(".thinking-text").last).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48 —
+9 -9
View File
@@ -862,7 +862,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
src_row = page.locator("#folders-tbody tr")
expect(src_row.locator("a.folder-link")).to_have_text(SOURCE)
expect(src_row.locator("td:nth-child(2)")).to_have_text(str(N_FILES))
expect(src_row.locator("td:nth-child(4) span")).to_have_text(SUM_ROOT)
expect(src_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ROOT)
expect(page.locator(".kb-summary-pending")).to_have_count(0)
# The gap: delete the bravo row AND the source-root row directly
@@ -877,7 +877,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
page.click("#nav-sources")
_wait_top_level(page)
src_row = page.locator("#folders-tbody tr")
s_span = src_row.locator("td:nth-child(4) span")
s_span = src_row.locator("td:nth-child(4) span.kb-desc-text")
expect(s_span).to_have_text(PENDING_COPY)
# Phase 99 (task 01): the marker toggles onto the cell's base
# .kb-desc-text span (the one-line clamp) — the class pair, exact.
@@ -900,13 +900,13 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
bravo_row = rows.nth(1)
expect(alpha_row.locator("a.folder-link")).to_have_text(FOLDER_A)
# The intact folder: the stored line, never the marker.
expect(alpha_row.locator("td:nth-child(4) span")).to_have_text(SUM_ALPHA)
expect(alpha_row.locator("td:nth-child(4) span")).not_to_have_class(
expect(alpha_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ALPHA)
expect(alpha_row.locator("td:nth-child(4) span.kb-desc-text")).not_to_have_class(
"kb-summary-pending"
)
# The affected folder: the marker (copy + class + title) — the
# class pair with the phase-99 .kb-desc-text base span, exact.
b_span = bravo_row.locator("td:nth-child(4) span")
b_span = bravo_row.locator("td:nth-child(4) span.kb-desc-text")
expect(b_span).to_have_text(PENDING_COPY)
expect(b_span).to_have_class("kb-desc-text kb-summary-pending")
expect(b_span).to_have_attribute("title", PENDING_TITLE)
@@ -929,7 +929,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
expect(bravo_row.locator(".kb-summary-status")).to_have_text(
"Description updated."
)
b_span = bravo_row.locator("td:nth-child(4) span")
b_span = bravo_row.locator("td:nth-child(4) span.kb-desc-text")
expect(b_span).to_have_text(MANUAL_BRavo)
expect(b_span).not_to_have_class("kb-summary-pending")
@@ -962,14 +962,14 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
expect(page.locator("#kb-level-title")).to_have_text(SOURCE)
expect(page.locator("#kb-level-summary")).to_have_text(SUM_ROOT)
rows = page.locator("#folders-tbody tr")
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(SUM_ALPHA)
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(MANUAL_BRavo)
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ALPHA)
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(MANUAL_BRavo)
expect(page.locator(".kb-summary-pending")).to_have_count(0)
# And at the top level: the source row's cell carries the
# regenerated root line, no marker.
page.locator("#kb-crumb a.kb-crumb-link").nth(0).click()
src_row = page.locator("#folders-tbody tr")
s_span = src_row.locator("td:nth-child(4) span")
s_span = src_row.locator("td:nth-child(4) span.kb-desc-text")
expect(s_span).to_have_text(SUM_ROOT)
expect(s_span).not_to_have_class("kb-summary-pending")
expect(page.locator(".kb-summary-pending")).to_have_count(0)
+43 -28
View File
@@ -36,8 +36,13 @@ thinking frame) is REPLACED by the per-run timeline (D1/D5): each
contiguous thinking run streams into its OWN block, and the first frame
of a new kind closes the previous run. The SSE stream is unchanged, so
this turn renders ONE ``.tool-calls-disclosure`` + TWO ``.thinking``
blocks (arrival order, all ABOVE the answer bubble — D2), open in their
live windows and folded at rest. The loader pins
blocks (arrival order) — wrapped since phase 161 in the ONE
``details.reasoning`` container ABOVE the answer bubble (D1), the
container open from creation until the settle fold (D2). The
2026-10-06 D5 refinement (owner, shipped in ``690fd98``): the per-run
blocks are PLAIN DIVS — always visible inside the container, never
themselves collapsible (the container is the ONE disclosure). The
loader pins
(``test_loader_visible_from_send_through_the_tool_gap``,
``test_loader_a11y_and_reduced_motion``) stay unchanged — the
never-stale loader contract (D16) is intact.
@@ -53,15 +58,17 @@ context, ``_reset_db`` reseeds the KB deterministically):
the phase-87 elapsed counter in the tool gap, and ``#send-status``
carries a state text (not empty) throughout.
* ``test_thinking_runs_open_in_live_window_with_visible_loader`` —
THE reported symptom's state, re-pinned for phase 158 (D5 replaces
phase-109 D15 — a NEW block instead of a re-open): the post-delta
thinking frames open a per-run ``.thinking`` block (exactly one
block, open + non-empty ``.thinking-text`` while the bubble already
carries call 1's content — the run starts AFTER the first delta) with
the loader STILL visible (the frozen window is gone — the user sees
the new thinking in its own live block); the terminal state is clean
(loader hidden, the scratchpad holds TWO ``.thinking`` blocks —
EACH carrying its own round's text — BOTH folded at rest, the bubble
THE reported symptom's state, re-pinned for phase 158 + the phase-161
D5 refinement (a NEW plain-div run instead of a re-open; the runs
never fold, the ONE container does): while the
``details.reasoning`` container is OPEN, exactly one ``.thinking``
run is VISIBLE with non-empty ``.thinking-text`` while the bubble
already carries call 1's content — the run starts AFTER the first
delta — with the loader STILL visible (the frozen window is gone —
the user sees the new thinking in its own live run); the terminal
state is clean (loader hidden, the container folded at the settle —
zero nested ``<details>`` inside it — the scratchpad holds TWO
``.thinking`` runs EACH carrying its own round's text, 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
@@ -223,25 +230,32 @@ def test_thinking_runs_open_in_live_window_with_visible_loader(
loader = page.locator("#turn-loader")
# THE reported symptom's state (phase 158 D5 replaces phase-109
# D15 — the post-delta thinking frames open a NEW per-run block
# instead of re-opening the first delta's block): exactly ONE
# ``.thinking`` block exists, it is OPEN with non-empty text WHILE
# the answer bubble already carries call 1's content — the first
# thinking run starts AFTER the first delta (the owner's "thinking
# after responding" repro), and the loader is STILL visible (the
# frozen window is gone — the user sees the new thinking in its
# own live block).
# D15 — the post-delta thinking frames open a NEW per-run block,
# and the phase-161 D5 refinement (owner 2026-10-06, 690fd98) made
# the runs PLAIN DIVS inside the ONE details.reasoning container —
# always visible, the CONTAINER is the disclosure): while the
# container is OPEN, exactly ONE ``.thinking`` run exists and
# paints non-empty text WHILE the answer bubble already carries
# call 1's content — the first thinking run starts AFTER the first
# delta (the owner's "thinking after responding" repro), and the
# loader is STILL visible (the frozen window is gone — the user
# sees the new thinking in its own live run).
page.wait_for_function(
"""() => {
const b = document.querySelectorAll('.msg.brain .thinking');
const cont = document.querySelector(
'.msg.brain details.reasoning[open]');
if (!cont) return false;
const b = cont.querySelectorAll('.thinking');
const t = b.length === 1 ? b[0].querySelector('.thinking-text') : null;
return b.length === 1 && !!b[0].open && !!t
&& t.textContent.trim().length > 0;
return b.length === 1 && !!t && t.textContent.trim().length > 0;
}""",
timeout=30_000,
)
expect(blocks).to_have_count(1)
expect(blocks).to_have_attribute("open", "")
expect(blocks.first).to_be_visible()
expect(
page.locator(".msg.brain details.reasoning").first
).to_have_attribute("open", "")
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
# The run is genuinely AFTER a delta: call 1's content is already
# in the bubble ...
@@ -265,11 +279,12 @@ def test_thinking_runs_open_in_live_window_with_visible_loader(
# reasoning streams — separate blocks, not one lump) ...
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
expect(blocks.nth(1).locator(".thinking-text")).to_contain_text(SENTINEL_FINAL_THOUGHT)
# ... and BOTH runs are FOLDED at rest (D5 — the per-run
# fold-at-rest; the phase-109 symptom — the frozen chat — stays
# fixed: a run is open in its live window, never re-opened after
# done) ...
expect(page.locator(".msg.brain .thinking[open]")).to_have_count(0)
# ... and the ONE container FOLDED at the settle (phase 161 D2 —
# the container is the ONLY disclosure; the inner runs are plain
# divs, never collapsible: zero nested <details>; the phase-109
# symptom — the frozen chat — stays fixed) ...
expect(page.locator(".msg.brain details.reasoning[open]")).to_have_count(0)
expect(page.locator(".msg.brain details.reasoning details")).to_have_count(0)
# ... the bubble carries BOTH answers (call 1 + call 2) ...
expect(bubble).to_contain_text(SENTINEL_FIRST)
expect(bubble).to_contain_text(SENTINEL_FINAL)
+628
View File
@@ -0,0 +1,628 @@
"""Unit: the empty answer bubble never paints (phase 166, task 01,
``TODO.md`` L4 — "There's an empty box where the LLM response will go
that shows up before the LLM responds. It looks ugly." (owner's
``image.png``: Thinking / Tool calls (2) / an expanded Thinking run,
then a bordered, padded, EMPTY rounded box)).
Why the box exists: the first ``thinking``/``tool`` frame of a turn
calls ``addMessage("brain", "")`` (app.js ~4063 / ~4164 / ~4269) — the
EMPTY bubble is created ON PURPOSE as the DOM anchor for
``ensureReasoningWrap`` (``body.insertBefore(cont,
body.querySelector(".bubble"))`` — the phase-158/161 per-run timeline
sits ABOVE the answer bubble). ``.bubble`` carries its own padding +
border + background + box-shadow, so an empty one paints the reported
~40px box (and ``.msg-body``'s flex gap adds the separation).
The fix is CSS-only (LOCKED A1 — owner 2026-07-10: "hide until the
first token", NOT "restructure the anchor"):
``.msg .bubble:empty { display: none; }`` in the Messages block.
``:empty`` matches only a child-less element — exactly
``innerHTML === ""`` — and the first delta writes content, so the box
appears AT the first token; ``display: none`` also drops the flex item
so the gap collapses. Because ``renderMarkdown("")`` returns ``""``
(node-executed below), the restore path and the shared page (same
``.msg/.msg-body/.bubble`` structure, same stylesheet — locked A3) are
covered by the same rule. The typing indicator is never ``:empty``
(three dot ``<span>``s), so the loading state never disappears (locked
A2 — nothing replaces the box).
Browser behavior is E2E-covered by ``tests/e2e/test_no_empty_box.py``
(task 03); here we pin the source-level contract in the house
"read-the-source" style (test_thinking_scroll.py /
test_markdown_tables.py): the exact declaration exists ONCE in the
Messages block, ``.bubble`` keeps its paint (the rule HIDES, never
restyles), the typing-dot rules are untouched, the JS anchors survive
unchanged (D2 — this is the pin that stops a future "cleanup" from
moving the fix into JS and breaking the timeline), and the empty-input
renderer behavior is executed under node.
AUDIT NOTE (phase 166 task 02 — the §7.4 "never stale" inventory; the
record the next agent reads instead of re-deriving it). With the empty
bubble suppressed, EVERY mid-turn state still has a visible
affordance, in order:
• idle / pre-thinking → ``#typing-indicator``: the ``role="status"``
bubble labelled ``TYPING_LABEL()`` with its three dot ``<span>``s
(never ``:empty``, so the rule can never hide it);
• long tool call / frameless silence → the ``thinkingClock`` still
paints the ``Ns`` ``.typing-elapsed`` suffix INTO that same bubble
(a child ⇒ the bubble is never ``:empty`` ⇒ the clock keeps
painting);
• thinking / tool → the ``details.reasoning`` container
(``ensureReasoningWrap`` sets ``cont.open = true`` — visible while
live) with its live status span: "Thinking…" / "Calling <name>…"
/ the settle "N steps";
• first token → the answer bubble APPEARS: the first ``delta``
writes ``innerHTML``, so ``:empty`` stops matching on the SAME
frame the box returns — and ``#messages`` (the ONE conversation
``aria-live="polite"`` region) announces as content lands, at
which point the bubble is visible again anyway;
• terminal → the settle copy: answer / deflection (.maybe-try chips)
/ stopped (.stopped-note) / failed (.failed-note) + the
``#send-status`` / ``#send-label`` composer signals.
Nothing measures the hidden box (``display: none`` yields zero
rects), so no scroll or pin can shift: ``scrollReveal`` receives the
``.msg`` WRAP (never the bubble), the phase-161 window pins take
``.reasoning-body``, and ``.bubble`` is only ever read for CONTENT
(``textContent`` / ``innerHTML`` / ``querySelector`` / ``classList`` /
``prepend`` / ``setAttribute``) or used as an ``insertBefore`` anchor.
The ``.msg-meta`` rows (chips, figures, actions, notes) append to
``.msg-body`` as SIBLINGS of the bubble (phase 130), so hiding an
empty one never hides them. The restore
(``addMessage("brain", renderMarkdown(m.text), true)``) and the shared
page (``shared.js`` builds the identical ``.msg/.msg-body/.bubble``
structure on the same stylesheet) land in the same ``.msg `` scope —
no ``shared.js`` change needed (locked A3).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
APP_JS = ASSETS / "app.js"
MARKDOWN_JS = ASSETS / "markdown.js"
SHARED_JS = ASSETS / "shared.js"
STYLES_CSS = ASSETS / "styles.css"
INDEX_HTML = FRONTEND / "index.html"
RULE = ".msg .bubble:empty { display: none; }"
HAVE_NODE = shutil.which("node") is not None
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _css() -> str:
return _text(STYLES_CSS)
def _js() -> str:
return _text(APP_JS)
def _bubble_rule(css: str) -> str:
"""Body of the bare `.bubble { ... }` rule (the answer bubble)."""
rule = re.search(r"\n\.bubble \{([\s\S]*?)\n\}", css)
assert rule, "styles.css must style .bubble"
return rule.group(1)
def _function_body(js: str, signature: str) -> str:
"""Source of one top-level function, from its signature to the
first column-0 `}` (the house indentation makes that the function
end). Used by the task-02 audit pins."""
start = js.find(signature)
assert start != -1, f"missing function: {signature}"
end = js.find("\n}", start)
assert end != -1, f"unterminated function: {signature}"
return js[start:end]
def _strip_comments(src: str) -> str:
"""Crude-but-sufficient comment stripper for the audit greps: block
comments then line comments (the house files carry no `//` inside
string literals — no-CDN pins keep URLs out)."""
src = re.sub(r"/\*[\s\S]*?\*/", "", src)
return re.sub(r"//[^\n]*", "", src)
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
# ---------- styles.css: the one rule ----------
def test_suppression_rule_exists_exactly_once() -> None:
"""The exact declaration ``.msg .bubble:empty { display: none; }``
exists in styles.css EXACTLY once — one rule, no duplicates, no
near-miss variant (a `.bubble:empty` without the `.msg` scope would
leak past the Messages section)."""
css = _css()
assert css.count(RULE) == 1, "the suppression rule must exist exactly once"
assert css.count(".bubble:empty") == 1, "no unscoped or extra :empty variant"
def test_rule_sits_in_the_messages_block() -> None:
"""Provenance ordering (the test_date_editor.py css.find idiom):
the rule lives in the Messages block, directly after the
``.bubble p`` content rule and before ``.bubble pre`` — i.e. after
``.bubble {`` — so the fix reads next to the bubble it hides."""
css = _css()
assert css.find("---------- Messages ----------") < css.find(RULE), (
"the rule must sit in the Messages block"
)
assert css.find(".bubble {") < css.find(".bubble p {") < css.find(RULE), (
"the rule must follow the .bubble rules it suppresses"
)
assert css.find(RULE) < css.find(".bubble pre {"), (
"the rule must stay between .bubble p and .bubble pre"
)
def test_rule_is_scoped_to_msg_bubbles() -> None:
"""The selector is `.msg .bubble:empty` — the chat/shared-page
message bubbles ONLY. The document viewer's .doc-* blocks, the
composer, and every other :empty-carrying element never match."""
css = _css()
assert RULE in css
# Every `.bubble:empty` occurrence carries the `.msg ` prefix — no
# bare variant escaping the Messages scope — and there is no
# broader `.msg:empty` (which would hide the whole message wrap).
for m in re.finditer(r"\.bubble:empty", css):
assert css[: m.start()].endswith(".msg "), (
"the :empty rule must stay scoped to .msg .bubble"
)
assert ".msg:empty" not in css
def test_bubble_keeps_its_paint_the_rule_hides_never_restyles() -> None:
"""The bare `.bubble` rule still declares padding, border-radius,
background, border and box-shadow (LOCKED A1: the fix HIDES the
empty box, it never restyles a non-empty bubble) — and no
`display: none` crept into the base rule itself."""
body = _bubble_rule(_css())
for decl in ("padding", "border-radius", "background", "border", "box-shadow"):
assert decl in body, f".bubble must keep its {decl} (hide, not restyle)"
assert "display: none" not in body, "the base .bubble rule must stay visible"
def test_suppression_carries_phase_166_provenance() -> None:
"""The provenance comment directly above the rule names the defect
(TODO L4 + the owner's image.png), the JS anchors it preserves
(addMessage("brain","") / ensureReasoningWrap), and why the typing
indicator is unaffected."""
css = _css()
idx = css.find(RULE)
comment = css[:idx]
start = comment.rindex("/*")
note = css[start:idx]
assert "Phase 166" in note
assert "TODO L4" in note, "the comment must name the TODO line"
assert "image.png" in note, "the comment must name the owner's screenshot"
assert 'addMessage("brain","")' in note or 'addMessage("brain", "")' in note, (
"the comment must name the empty-bubble creation it hides"
)
assert "ensureReasoningWrap" in note, "the comment must name the preserved anchor"
assert "typing" in note, "the comment must explain why the indicator is safe"
def test_typing_indicator_rules_are_untouched() -> None:
"""The loading affordance stays painted: the `.typing` bubble rule
(inline-flex + gap) and the three-dot animation rules
(`.typing span`, the two animation-delays, @keyframes typing) are
all still present — the indicator bubble carries the dot children,
so the new :empty rule can never hide it (locked A2)."""
css = _css()
assert ".typing { display: inline-flex" in css
assert "animation: typing 1.2s infinite ease-in-out;" in css
assert ".typing span:nth-child(2) { animation-delay: 0.15s; }" in css
assert ".typing span:nth-child(3) { animation-delay: 0.3s; }" in css
assert "@keyframes typing {" in css
# ---------- app.js: the CSS-only contract (D2) ----------
def test_addtyping_bubble_is_never_empty() -> None:
"""`addTyping()` still builds the bubble with its three dot
`<span>` children — `:empty` matches only a CHILD-LESS element, so
the typing indicator (role=status) is provably never hidden by the
new rule: the loading state cannot disappear."""
js = _js()
start = js.find("function addTyping() {")
assert start != -1, "app.js must keep addTyping()"
body = js[start : js.find("\n}", start)]
assert '<div class="bubble typing"' in body
assert "<span></span><span></span><span></span>" in body, (
"the three dot spans are the non-:empty guarantee"
)
def test_the_three_empty_bubble_anchors_survive() -> None:
"""D2 pin: app.js is NOT modified by this phase — all three
`addMessage("brain", "")` call sites (thinking ~4063 / tool ~4164 /
first delta ~4269) still create the empty wrap. A future "cleanup"
that moves the fix into JS would re-own the phase-158/161 anchors
for zero user-visible gain over the CSS rule — this pin stops it."""
js = _js()
assert js.count('addMessage("brain", "")') == 3, (
"the thinking/tool/first-delta empty-bubble anchors must all stay"
)
def test_ensure_reasoning_wrap_still_anchors_before_the_bubble() -> None:
"""`ensureReasoningWrap` still does `body.insertBefore(cont,
body.querySelector(".bubble"))` — the hidden bubble remains the
DOM anchor the per-run timeline inserts BEFORE (the CSS rule hides
the box; it never removes the element)."""
js = _js()
start = js.find("function ensureReasoningWrap(wrap) {")
assert start != -1, "app.js must keep ensureReasoningWrap"
body = js[start : js.find("\n}", start)]
assert 'body.insertBefore(cont, body.querySelector(".bubble"))' in body
# ---------- markdown.js: WHY one rule covers restore + shared (A3) ----------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_rendermarkdown_empty_inputs_yield_empty_string() -> None:
"""Executed under node (the phase-44 driver idiom):
`renderMarkdown("")` and `renderMarkdown(" ")` BOTH return `""`
— so a persisted answer with empty/whitespace text lands as a truly
child-less `<div class="bubble"></div>` in the restore AND the
shared page, and the same `:empty` rule covers them (locked A3)."""
js = _text(MARKDOWN_JS)
driver = (
js
+ "\nconst out = { empty: renderMarkdown(\"\"), blank: renderMarkdown(\" \") };\n"
+ "console.log(JSON.stringify(out));"
)
results = json.loads(_run_node(driver))
assert results["empty"] == "", f'renderMarkdown("") must be "", got {results["empty"]!r}'
assert results["blank"] == "", f'renderMarkdown(" ") must be "", got {results["blank"]!r}'
# ---------- no-CDN (AGENTS.md rule 6) ----------
def test_no_cdn_introduced() -> None:
"""The touched assets stay CDN-free (AGENTS.md rule 6): no remote
url()/@import in styles.css, no remote <script src> in app.js."""
css = _css()
assert "url(http" not in css and "@import url(" not in css, (
"no CDN (AGENTS.md rule 6)"
)
js = _js()
assert "src=\"http" not in js and "src='http" not in js
# ---------- §7.4 feedback inventory (task 02) ----------
#
# Each test names the mid-turn state whose affordance it guarantees.
# If one of these pins fails, a turn has become feedback-less — the
# exact regression phase 166 must not ship (PLAN §7.4, AGENTS.md 10).
def test_pre_thinking_state_typing_indicator_is_a_labeled_status() -> None:
"""STATE: idle → pre-thinking. `addTyping()` still builds the
#typing-indicator bubble as a `role="status"` live element with the
accessible label `aria-label="${TYPING_LABEL()}"` and the three
`<span></span>` dots — the dots are both the visible pulse and the
non-`:empty` guarantee (task 01's pin), so this affordance survives
the suppression rule untouched (locked A2)."""
body = _function_body(_js(), "function addTyping() {")
assert 'wrap.id = "typing-indicator"' in body
assert 'role="status"' in body
assert 'aria-label="${TYPING_LABEL()}"' in body
assert body.count("<span></span>") == 3, (
"the three dot spans are the visible pre-thinking affordance"
)
def test_pre_thinking_state_setuistate_arms_typing_and_clock() -> None:
"""STATE: idle → thinking. `setUiState` still arms BOTH composer-
side and chat-side feedback on the thinking transition —
`addTyping()` + `startThinkingClock()` — and the persistent turn
loader stays owned by the single `turnLoader.hidden = !inFlight`
line (phase 109 D16). The suppression rule lives nowhere near
this state machine."""
body = _function_body(_js(), 'export function setUiState(state, errorDetail = "", opts = {}) {')
assert "addTyping();" in body
assert "startThinkingClock();" in body
assert "turnLoader.hidden = !inFlight;" in body
assert 'sendBtn.classList.toggle("is-stop", inFlight);' in body
def test_long_tool_call_state_clock_paints_the_elapsed_suffix() -> None:
"""STATE: long tool call (frameless silence ≥ 10s). `thinkingClock`
still writes the `Ns` suffix into `#typing-indicator .bubble` as a
`.typing-elapsed` child (`el.textContent = secs + "s"`) and keeps
the aria channel byte-identical (`is still thinking (${secs}s)`).
NOTE: the suffix APPENDS a child to the typing bubble — that bubble
is therefore NEVER `:empty` (dots from creation, the suffix on top)
— so the clock keeps painting for the whole turn; the suppression
rule cannot starve this affordance."""
body = _function_body(_js(), "function startThinkingClock() {")
assert 'document.querySelector("#typing-indicator .bubble")' in body
assert 'el.className = "typing-elapsed"' in body
assert 'el.textContent = secs + "s"' in body
assert "is still thinking (${secs}s)" in body
assert "bubble.appendChild(el)" in body
def test_thinking_tool_state_reasoning_status_literals_stay_wired() -> None:
"""STATE: thinking / tool / the settle. The D6 status literals are
still wired at their call sites: `setReasoningStatus("Thinking…")`
on every thinking frame (1 site), `setReasoningStatus(
`Calling ${name}…`)` on every tool frame (1 site), and the settle
`${segRuns.length} steps` at its THREE settle sites (done, the
user stop, the failed-turn funnel in finalizeFailedTurn). The live
status span is the text signal of the thinking/tool state while
the answer box is still hidden."""
js = _js()
assert js.count('setReasoningStatus("Thinking…")') == 1
assert js.count("setReasoningStatus(`Calling ${name}…`)") == 1
assert js.count("setReasoningStatus(`${segRuns.length} steps`)") == 3, (
"done + stop + failed-settle must all keep the N-steps literal"
)
def test_thinking_tool_state_reasoning_container_is_visible_while_live() -> None:
"""STATE: thinking / tool. `ensureReasoningWrap` still sets
`cont.open = true` — the per-run timeline container is OPEN (i.e.
actually VISIBLE) from the first thinking/tool frame until
settleReasoning folds it (phase 161 D1/D2). With the answer box
hidden, this container is the turn's main brain-side surface; a
silently-closed container would break §7.4 even though the box
fix itself is CSS-only."""
body = _function_body(_js(), "function ensureReasoningWrap(wrap) {")
assert "cont.open = true;" in body
def test_composer_side_state_copy_is_untouched() -> None:
"""STATE: every non-idle state, composer side. The `#send-label`
("Send" ⇄ "Stop") and the `#send-status` live region
(`SEND_STATUS[state]` — "… is thinking" / "… is answering" /
"The last question failed — try again") are still written by
`setUiState` exactly as before — the phase-166 change lives in one
CSS rule and never touches the composer-side signal (locked A2:
nothing replaces the box because the existing states already
carry the feedback)."""
js = _js()
start = js.find("const SEND_STATUS = Object.freeze({")
assert start != -1
block = js[start : js.find("});", start)]
assert "is thinking" in block and "is answering" in block
assert "The last question failed — try again" in block
body = _function_body(js, 'export function setUiState(state, errorDetail = "", opts = {}) {')
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send";' in body
assert 'sendStatus.textContent = SEND_STATUS[state]?.() ?? "";' in body
def test_messages_stays_the_one_conversation_live_region() -> None:
"""STATE: first token → the announcement channel. `#messages`
keeps `aria-live="polite"` (frontend/index.html — the ONE
conversation live region; the composer-side #send-status and the
per-feature announcers are separate surfaces). An aria-live
announcement fires when the answer CONTENT lands in the region —
and by construction that is the first delta, the same frame the
bubble gains children and becomes visible again: the suppression
rule can therefore never hide content from the a11y channel."""
html = _text(INDEX_HTML)
m = re.search(r'<section class="messages"[^>]*>', html)
assert m, "index.html must keep the #messages section"
tag = m.group(0)
assert 'id="messages"' in tag
assert 'aria-live="polite"' in tag, (
"the conversation region stays the live region (PLAN §7.4)"
)
# ---------- nothing measures the hidden box (task 02) ----------
MEASURE_PROPS = (
"getBoundingClientRect",
"offsetHeight",
"offsetWidth",
"clientHeight",
"clientWidth",
"scrollHeight",
"scrollTop",
"scrollLeft",
"offsetTop",
"offsetLeft",
)
def test_scroll_reveal_receives_the_msg_wrap_not_the_bubble() -> None:
"""FINDING: `addMessage` calls `if (scroll) scrollReveal(wrap);`
with the `.msg` WRAP — never the `.bubble` — and `scrollReveal`
measures NOTHING of its argument (`void wrap;` then a document-
bottom `window.scrollTo`). `display: none` on the bubble therefore
cannot shift a scroll: the only page scroll paths are the two
user-intent call sites, and none of them touches the hidden box."""
add = _function_body(_js(), "function addMessage(who, html, scroll = false, image = null) {")
assert "if (scroll) scrollReveal(wrap);" in add
assert 'wrap.className = `msg ${who}`' in add, "wrap is the .msg element"
assert 'querySelector(".bubble")' not in add.split("scrollReveal")[-1], (
"the scroll call must not hand the bubble to scrollReveal"
)
reveal = _function_body(_js(), "function scrollReveal(wrap, behavior = SCROLL) {")
assert "void wrap;" in reveal
assert "window.scrollTo({ top: document.documentElement.scrollHeight, behavior });" in reveal
assert ".bubble" not in reveal
def test_reasoning_window_pins_never_take_a_bubble() -> None:
"""FINDING: the phase-161 window pins operate on `.reasoning-body`
handles (`reasoningWrapEl?.querySelector(".reasoning-body")`, both
frame sites) — `isReasoningPinned` / `scrollReasoningToBottom`
take the container window `el`, NEVER a `.bubble`. The pinned-tail
follow is therefore blind to the bubble's box: hiding it cannot
move a pin."""
js = _js()
pinned = _function_body(js, "function isReasoningPinned(el) {")
scroll = _function_body(js, "function scrollReasoningToBottom(el) {")
assert ".bubble" not in pinned and ".bubble" not in scroll
assert js.count('reasoningWrapEl?.querySelector(".reasoning-body")') == 2, (
"the thinking and tool frame sites capture the CONTAINER window"
)
assert "isReasoningPinned(rbody)" in js and "isReasoningPinned(tbody)" in js
assert "scrollReasoningToBottom(rbody)" in js and "scrollReasoningToBottom(tbody)" in js
def test_no_line_measures_a_bubble_box() -> None:
"""FINDING (grep audit): NO line of app.js or shared.js pairs a
geometry property (getBoundingClientRect / offsetHeight /
clientHeight / scroll* / offset*) with `bubble`. The only geometry
reads that exist today are pinned to their OWN surfaces: the
thinking-text window (`textEl`), the container window (`el`, the
two pins above), the composer textarea autosize (`input`), and the
document-bottom scroll (documentElement) — so `display: none` on
an empty `.bubble` cannot shift a scroll or a pin ANYWHERE."""
for path in (APP_JS, SHARED_JS):
for line in _text(path).splitlines():
if any(prop in line for prop in MEASURE_PROPS):
assert "bubble" not in line.lower(), (
f"{path.name} measures a box on a bubble line: {line.strip()}"
)
def test_bubble_selector_is_only_lookups_and_anchors() -> None:
"""FINDING: every code line (comments stripped) that references the
`.bubble` SELECTOR is a `querySelector` lookup or an
`insertBefore` anchor — the two operations `display: none` does
not disturb (the element stays in the DOM; locked A1/D2). No
measurement ever hangs off the selector itself."""
for path in (APP_JS, SHARED_JS):
code = _strip_comments(_text(path))
selector_lines = [
ln.strip()
for ln in code.splitlines()
if re.search(r"\.bubble(?=[\"'])", ln)
]
assert selector_lines, f"{path.name} must still reference .bubble"
for ln in selector_lines:
assert "querySelector" in ln, (
f"the .bubble selector may only appear in lookups/anchors: {ln}"
)
def test_bubble_variable_is_only_touched_for_content() -> None:
"""FINDING: the `bubble` variable (whatever a lookup bound) is
only ever touched for CONTENT — textContent, innerHTML,
classList, querySelector, prepend, appendChild, setAttribute — in
BOTH app.js and shared.js. A geometry property on the handle
would read ZERO once the box is hidden, so the allowlist failing
here means some future code silently depends on the painted box —
exactly what this audit forbids."""
content_props = {
"textContent",
"innerHTML",
"classList",
"querySelector",
"prepend",
"appendChild",
"setAttribute",
}
for path in (APP_JS, SHARED_JS):
code = _strip_comments(_text(path))
touched = set(re.findall(r"(?<![.\w])bubble\??\.\s*([A-Za-z_$][\w$]*)", code))
assert touched <= content_props, (
f"{path.name} touches non-content props on a bubble: "
f"{sorted(touched - content_props)}"
)
# The documented content reads exist (a vacuous allowlist is not
# the contract — these are the sites the audit enumerated):
js = _strip_comments(_js())
assert 'bubble.textContent' in js
assert 'bubble.querySelector(".msg-image")' in js
assert 'bubble.classList.remove("is-editing")' in js
assert 'wrap.querySelector(".bubble").innerHTML = renderMarkdown' in _js()
# ---------- .msg-meta rows stay OUTSIDE the bubble (task 02) ----------
def test_meta_rows_append_to_msg_body_never_into_the_bubble() -> None:
"""Phase-130 pin: the chips row, the images row, the answer figure
and the actions row all append to `.msg-body` — SIBLINGS of
`.bubble` — and none of the appender bodies even mentions
`.bubble`. Hiding an empty bubble therefore never hides a chip,
an action, or a figure; a future refactor that moves them INTO the
bubble would fail this pin (and the owner's screenshot defect
would return multiplied)."""
js = _js()
actions = _function_body(js, "function getActionsRow(wrap) {")
assert 'row.className = "msg-meta msg-actions";' in actions
assert "body.appendChild(row);" in actions
assert ".bubble" not in actions
sources = _function_body(js, "function appendSources(wrap, sources) {")
assert 'const body = wrap.querySelector(".msg-body");' in sources
assert 'meta.className = "msg-meta";' in sources
assert "body.appendChild(meta);" in sources, "chips row → .msg-body"
assert "body.appendChild(images);" in sources, "images row → .msg-body"
assert ".bubble" not in sources
figure = _function_body(js, "function appendAnswerImage(wrap, ref) {")
assert 'const body = wrap.querySelector(".msg-body");' in figure
assert "body.appendChild(fig);" in figure, "answer figure → .msg-body"
assert ".bubble" not in figure
for name, cls in (("appendStoppedNote(wrap)", "stopped-note"),
("appendFailedNote(wrap, detail)", "failed-note")):
note = _function_body(js, f"function {name} {{")
assert "const meta = getActionsRow(wrap);" in note, (
f".{cls} joins the actions row (itself a .msg-body child)"
)
assert f'note.className = "{cls}";' in note
assert "meta.appendChild(note);" in note
assert ".bubble" not in note
# ---------- the restore and shared corners (task 02) ----------
def test_restore_renders_brain_text_through_the_pinned_call_shape() -> None:
"""CORNER: the restore. The phase-14 restore renders every brain
record through `addMessage("brain", renderMarkdown(m.text), true)`
— with `m.text === ""` the node-pinned `renderMarkdown("") === ""
` (pinned above) makes the bubble CHILD-LESS ⇒ `:empty` ⇒ hidden:
an empty persisted answer paints no box, through the SAME rule
(locked A3) — no restore-path branch was added."""
js = _js()
assert js.count('addMessage("brain", renderMarkdown(m.text), true)') == 1
def test_shared_page_builds_the_identical_msg_structure_no_change_needed() -> None:
"""CORNER: the shared page. `addSharedMessage` builds the same
`wrap.className = `msg ${who}`` wrap and the identical
`<div class="bubble">${html}</div>` structure (shared.js ~113) as
`addMessage` — so the rule's `.msg ` scope prefix matches BOTH
pages (shared.html loads the same styles.css). shared.js carries
NO `:empty` rule of its own: one stylesheet, one rule, no
shared.js change needed (locked A3)."""
shared = _text(SHARED_JS)
body = _function_body(shared, "function addSharedMessage(who, html) {")
assert "wrap.className = `msg ${who}`" in body
assert '<div class="bubble">${html}</div>' in body
# The chat page builds the byte-identical bubble fragment:
assert '<div class="bubble">${html}</div>' in _js()
# The rule's scope prefix is `.msg ` — the class both pages put
# on the wrap — and shared.js adds no second suppression rule.
assert RULE.startswith(".msg .bubble:empty")
assert ":empty" not in shared
# shared.html is on the same stylesheet (one rule covers it).
shared_html = _text(FRONTEND / "shared.html")
assert 'href="/assets/styles.css"' in shared_html