From ab9391d019bcf7ee41216016b9d4b4fb5368a1e3 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Thu, 8 Oct 2026 04:27:03 -0400 Subject: [PATCH] phase: 166_hide_empty_answer_bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 ``/`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`. --- .../166_hide_empty_answer_bubble/00_phase.md | 0 .../01_hide_empty_bubble.md | 0 .../02_feedback_contract_check.md | 0 .../03_e2e_no_empty_box.md | 0 ..._hide_empty_answer_bubble__00_phase.a1.err | 0 ...6_hide_empty_answer_bubble__00_phase.a1.md | 14 + ...empty_answer_bubble__00_phase.a1.md.status | 1 + ..._empty_answer_bubble__00_phase.a1.validate | 125 +++ ...answer_bubble__01_hide_empty_bubble.a1.err | 0 ..._answer_bubble__01_hide_empty_bubble.a1.md | 18 + ..._bubble__01_hide_empty_bubble.a1.md.status | 1 + ...r_bubble__01_hide_empty_bubble.a1.validate | 125 +++ ..._bubble__02_feedback_contract_check.a1.err | 0 ...r_bubble__02_feedback_contract_check.a1.md | 20 + ...e__02_feedback_contract_check.a1.md.status | 1 + ...le__02_feedback_contract_check.a1.validate | 125 +++ ..._answer_bubble__03_e2e_no_empty_box.a1.err | 0 ...y_answer_bubble__03_e2e_no_empty_box.a1.md | 22 + ...r_bubble__03_e2e_no_empty_box.a1.md.status | 1 + ...er_bubble__03_e2e_no_empty_box.a1.validate | 125 +++ .../166_pre_answer_window_1280.png | Bin 0 -> 70822 bytes AGENTS.md | 5 +- README.md | 36 +- frontend/assets/styles.css | 15 + tests/e2e/mock_llm.py | 202 +++++ tests/e2e/test_folder_summary_voice.py | 4 +- tests/e2e/test_header_consistency.py | 47 +- tests/e2e/test_kb_tree.py | 18 +- tests/e2e/test_no_empty_box.py | 713 ++++++++++++++++++ tests/e2e/test_no_reply_autoscroll.py | 52 +- tests/e2e/test_search_tool_scope.py | 19 +- tests/e2e/test_share_chat.py | 23 +- tests/e2e/test_sources_midstream_bug.py | 12 +- tests/e2e/test_sync_summary_visibility.py | 18 +- tests/e2e/test_turn_progress_loader.py | 71 +- tests/unit/test_no_empty_answer_bubble.py | 628 +++++++++++++++ 36 files changed, 2348 insertions(+), 93 deletions(-) rename .agents/phases/{todo => complete}/166_hide_empty_answer_bubble/00_phase.md (100%) rename .agents/phases/{todo => complete}/166_hide_empty_answer_bubble/01_hide_empty_bubble.md (100%) rename .agents/phases/{todo => complete}/166_hide_empty_answer_bubble/02_feedback_contract_check.md (100%) rename .agents/phases/{todo => complete}/166_hide_empty_answer_bubble/03_e2e_no_empty_box.md (100%) create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.err create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.md create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.md.status create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.validate create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.err create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.md create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.md.status create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.validate create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.err create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.md create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.md.status create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.validate create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.err create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.md create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.md.status create mode 100644 .agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.validate create mode 100644 .agents/screenshots/166_pre_answer_window_1280.png create mode 100644 tests/e2e/test_no_empty_box.py create mode 100644 tests/unit/test_no_empty_answer_bubble.py diff --git a/.agents/phases/todo/166_hide_empty_answer_bubble/00_phase.md b/.agents/phases/complete/166_hide_empty_answer_bubble/00_phase.md similarity index 100% rename from .agents/phases/todo/166_hide_empty_answer_bubble/00_phase.md rename to .agents/phases/complete/166_hide_empty_answer_bubble/00_phase.md diff --git a/.agents/phases/todo/166_hide_empty_answer_bubble/01_hide_empty_bubble.md b/.agents/phases/complete/166_hide_empty_answer_bubble/01_hide_empty_bubble.md similarity index 100% rename from .agents/phases/todo/166_hide_empty_answer_bubble/01_hide_empty_bubble.md rename to .agents/phases/complete/166_hide_empty_answer_bubble/01_hide_empty_bubble.md diff --git a/.agents/phases/todo/166_hide_empty_answer_bubble/02_feedback_contract_check.md b/.agents/phases/complete/166_hide_empty_answer_bubble/02_feedback_contract_check.md similarity index 100% rename from .agents/phases/todo/166_hide_empty_answer_bubble/02_feedback_contract_check.md rename to .agents/phases/complete/166_hide_empty_answer_bubble/02_feedback_contract_check.md diff --git a/.agents/phases/todo/166_hide_empty_answer_bubble/03_e2e_no_empty_box.md b/.agents/phases/complete/166_hide_empty_answer_bubble/03_e2e_no_empty_box.md similarity index 100% rename from .agents/phases/todo/166_hide_empty_answer_bubble/03_e2e_no_empty_box.md rename to .agents/phases/complete/166_hide_empty_answer_bubble/03_e2e_no_empty_box.md diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.err b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.md b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.md new file mode 100644 index 0000000..523c489 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.md @@ -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 ``/`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`. diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.md.status b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.validate b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.validate new file mode 100644 index 0000000..b623730 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.err b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.md b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.md new file mode 100644 index 0000000..391e2f4 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.md @@ -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 ``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` diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.md.status b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.validate b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.validate new file mode 100644 index 0000000..d7bc0a9 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__01_hide_empty_bubble.a1.validate @@ -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 diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.err b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.md b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.md new file mode 100644 index 0000000..ff6e0af --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.md @@ -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 `
${html}
` + `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`. diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.md.status b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.validate b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.validate new file mode 100644 index 0000000..b623730 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__02_feedback_contract_check.a1.validate @@ -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 diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.err b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.md b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.md new file mode 100644 index 0000000..b3e649d --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.md @@ -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/`. diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.md.status b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.validate b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.validate new file mode 100644 index 0000000..b623730 --- /dev/null +++ b/.agents/reports/166_hide_empty_answer_bubble/166_hide_empty_answer_bubble__03_e2e_no_empty_box.a1.validate @@ -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 diff --git a/.agents/screenshots/166_pre_answer_window_1280.png b/.agents/screenshots/166_pre_answer_window_1280.png new file mode 100644 index 0000000000000000000000000000000000000000..f12f6fb8699ca98f59c4d5d7d8c972d42ea644a3 GIT binary patch literal 70822 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 literal 0 HcmV?d00001 diff --git a/AGENTS.md b/AGENTS.md index c46e2fb..6b61225 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index c465dc8..7813f46 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index eabacf9..180a14b 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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 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); diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index d76acad..514b0af 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -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 ```` 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 ```` 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 + ```` 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 "" 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 ```` + # 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 diff --git a/tests/e2e/test_folder_summary_voice.py b/tests/e2e/test_folder_summary_voice.py index 90f90f1..3321086 100644 --- a/tests/e2e/test_folder_summary_voice.py +++ b/tests/e2e/test_folder_summary_voice.py @@ -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 diff --git a/tests/e2e/test_header_consistency.py b/tests/e2e/test_header_consistency.py index af2fce7..1e0e619 100644 --- a/tests/e2e/test_header_consistency.py +++ b/tests/e2e/test_header_consistency.py @@ -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() diff --git a/tests/e2e/test_kb_tree.py b/tests/e2e/test_kb_tree.py index 9d45d6c..298e18e 100644 --- a/tests/e2e/test_kb_tree.py +++ b/tests/e2e/test_kb_tree.py @@ -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 diff --git a/tests/e2e/test_no_empty_box.py b/tests/e2e/test_no_empty_box.py new file mode 100644 index 0000000..fff7d06 --- /dev/null +++ b/tests/e2e/test_no_empty_box.py @@ -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 ```` 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 → ````). + +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=`` + 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/`` — 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=`` 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 → ```` → 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 ```` + ```` + + ```` 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 …), 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 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= 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= 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= 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() diff --git a/tests/e2e/test_no_reply_autoscroll.py b/tests/e2e/test_no_reply_autoscroll.py index d1f3308..a498bf2 100644 --- a/tests/e2e/test_no_reply_autoscroll.py +++ b/tests/e2e/test_no_reply_autoscroll.py @@ -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) diff --git a/tests/e2e/test_search_tool_scope.py b/tests/e2e/test_search_tool_scope.py index 8a56426..06caf36 100644 --- a/tests/e2e/test_search_tool_scope.py +++ b/tests/e2e/test_search_tool_scope.py @@ -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
), 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 ) diff --git a/tests/e2e/test_share_chat.py b/tests/e2e/test_share_chat.py index 0114aa1..ae03656 100644 --- a/tests/e2e/test_share_chat.py +++ b/tests/e2e/test_share_chat.py @@ -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
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 + #
has no `open` attribute, and the inner runs are + # plain divs — NEVER a nested
). + 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 diff --git a/tests/e2e/test_sources_midstream_bug.py b/tests/e2e/test_sources_midstream_bug.py index 92caab8..f317c0b 100644 --- a/tests/e2e/test_sources_midstream_bug.py +++ b/tests/e2e/test_sources_midstream_bug.py @@ -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 — diff --git a/tests/e2e/test_sync_summary_visibility.py b/tests/e2e/test_sync_summary_visibility.py index 873aab5..647a729 100644 --- a/tests/e2e/test_sync_summary_visibility.py +++ b/tests/e2e/test_sync_summary_visibility.py @@ -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) diff --git a/tests/e2e/test_turn_progress_loader.py b/tests/e2e/test_turn_progress_loader.py index 04bc473..dbc7d75 100644 --- a/tests/e2e/test_turn_progress_loader.py +++ b/tests/e2e/test_turn_progress_loader.py @@ -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 ``
`` 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
; 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) diff --git a/tests/unit/test_no_empty_answer_bubble.py b/tests/unit/test_no_empty_answer_bubble.py new file mode 100644 index 0000000..97ad8b3 --- /dev/null +++ b/tests/unit/test_no_empty_answer_bubble.py @@ -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 ````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 ````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 …" + / 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 + `` 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 '
" 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 `
` 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