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