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 0000000..f12f6fb Binary files /dev/null and b/.agents/screenshots/166_pre_answer_window_1280.png differ 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