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;
|
||||
a clipped action label shows an ellipsis hint (`.cell-clip`) with
|
||||
the full text kept reachable via the control's aria-label
|
||||
(phase 156); the document viewer's page strip is CLASS-scoped —
|
||||
(phase 156); an in-flight turn never paints an empty answer
|
||||
bubble — the box is created empty as the reasoning anchor and
|
||||
`.msg .bubble:empty { display: none }` hides it until the first
|
||||
answer token (phase 166); the document viewer's page strip is CLASS-scoped —
|
||||
the primary class shows the strip in place of the text, the
|
||||
additive class never hides the text: Text/Pages toggle group
|
||||
(`aria-pressed`, Text default) with the strip behind it
|
||||
|
||||
@@ -92,6 +92,15 @@ question caps at 90 displayed characters with a `…` (phase 157) — the
|
||||
full text stays in the hover tooltip, and clicking the chip submits
|
||||
the full question.
|
||||
|
||||
**No empty answer box mid-turn** (phase 166): the bubble that will hold the
|
||||
answer is created on the turn's first `thinking`/`tool` frame — it is the
|
||||
DOM anchor the Reasoning container inserts itself above — but it stays
|
||||
hidden until the **first answer token** lands in it, so an in-flight turn
|
||||
shows the typing indicator and the Reasoning bubble only, never a padded
|
||||
empty rectangle (nothing replaces the box: the existing loading feedback
|
||||
is the turn's visible state). The same rule covers a restored or shared
|
||||
answer whose persisted text is empty, which paints no box either.
|
||||
|
||||
The document's meta row (the chat modal **and** the standalone viewer at
|
||||
`/document.html`) carries a **Copy path** button: one click copies the
|
||||
document's `source/path` (the exact form the `read` tool, the chips, and
|
||||
@@ -486,14 +495,15 @@ whole git-source refresh in one click:
|
||||
## Thinking
|
||||
|
||||
The self-hosted `turbo` model reasons before it answers. That reasoning is
|
||||
streamed with the turn as `thinking` SSE events and shown in **
|
||||
collapsible "Thinking" blocks** — one block per
|
||||
contiguous thinking run, in arrival order (phase 158) — all wrapped in
|
||||
**one collapsible "Reasoning" bubble** above the answer bubble
|
||||
(phase 161): the turn renders as a chronological timeline
|
||||
streamed with the turn as `thinking` SSE events and shown as **"Thinking"
|
||||
blocks** — one block per contiguous thinking run, in arrival order
|
||||
(phase 158) — all wrapped in **one collapsible "Reasoning" bubble** above
|
||||
the answer bubble (phase 161): the turn renders as a chronological timeline
|
||||
(Thinking → Tool call (1) → Thinking → … → answer), condensed into a
|
||||
single bounded live window (320 px, one scroll surface) that follows
|
||||
the tail while you watch.
|
||||
the tail while you watch. The Reasoning bubble is the ONE disclosure
|
||||
(owner refinement, 2026-10-06): the per-run blocks inside it are plain and
|
||||
never fold on their own.
|
||||
|
||||
The bubble is created on the turn's first thinking (or tool) frame and
|
||||
stays **open** from creation until the turn settles — including a
|
||||
@@ -1174,11 +1184,21 @@ uv run pyright
|
||||
# Playwright E2E — install the browser once:
|
||||
uv run playwright install chromium
|
||||
|
||||
# Each phase's E2E runs IN ISOLATION:
|
||||
# Each phase's E2E runs IN ISOLATION — one file per run:
|
||||
uv run pytest tests/e2e/test_import_documents.py -v --no-cov
|
||||
uv run pytest tests/e2e/test_chat_rag.py -v --no-cov
|
||||
# ...one file per phase in tests/e2e/ (the stories live in
|
||||
# .agents/user_stories/, see .agents/phases/todo/)
|
||||
# .agents/user_stories/, see .agents/phases/todo/).
|
||||
#
|
||||
# Isolation is REQUIRED, not a style preference: every suite shares one
|
||||
# Postgres and one pair of fixed ports (the app on 8123, the mock aipi on
|
||||
# 8901 — overridable with E2E_APP_PORT / E2E_MOCK_PORT), and each file
|
||||
# truncates + reseeds the KB for its own fixture. Running the whole
|
||||
# `tests/e2e/` directory in a single pytest session interleaves those
|
||||
# fixtures and fails tests that pass file by file.
|
||||
|
||||
# To sweep every suite, loop over the files (one pytest process each):
|
||||
for f in tests/e2e/test_*.py; do uv run pytest "$f" --no-cov -q || echo "FAIL $f"; done
|
||||
```
|
||||
|
||||
**Retrieval regression battery.** `tests/fixtures/retrieval_battery.txt` is
|
||||
|
||||
@@ -507,6 +507,21 @@ body::before {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.bubble p { margin: 0.2rem 0; }
|
||||
/* Phase 166 (TODO L4): the answer bubble an in-flight turn creates is
|
||||
EMPTY by construction — app.js:4063/:4164 call addMessage("brain","")
|
||||
so ensureReasoningWrap has an anchor to insert the per-run timeline
|
||||
BEFORE. With .bubble's own padding + border + background + shadow an
|
||||
empty one paints a ~40px box, which is the "empty box before the LLM
|
||||
responds" the owner reported (image.png). :empty matches only a
|
||||
child-less element — exactly innerHTML === "" (renderMarkdown("") is
|
||||
"", so the restore and the shared page take the same path) — and the
|
||||
first delta writes content, so the box appears AT the first token.
|
||||
display:none also drops the flex item, so .msg-body's gap collapses.
|
||||
The typing indicator is never :empty (three dot <span>s) — its
|
||||
animation is untouched. Scoped to .msg .bubble: the viewer's .doc-*
|
||||
blocks and the composer never match. Static suppression — no new
|
||||
tokens, no animation (nothing for prefers-reduced-motion to still). */
|
||||
.msg .bubble:empty { display: none; }
|
||||
.bubble pre {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
|
||||
@@ -590,6 +590,37 @@ Implements just enough of the aipi surface:
|
||||
phrases — the phase-71/72/94 ordering convention); verified
|
||||
2026-09-16: no existing E2E question or fixture file contains the
|
||||
phrase, so every other suite is unaffected.
|
||||
- user message containing ``paint the pre-answer window``
|
||||
(``EMPTY_BUBBLE_TRIGGER``, phase 166 task 03 — the no-empty-
|
||||
answer-box story's dedicated suite
|
||||
``tests/e2e/test_no_empty_box.py``) **and** the system prompt
|
||||
carries the ``<tools>`` section -> the deterministic PRE-ANSWER
|
||||
WINDOW turn (TODO.md L4: "There's an empty box where the LLM
|
||||
response will go that shows up before the LLM responds"):
|
||||
thinking frames and a tool call with ZERO content deltas — the
|
||||
exact shape that used to paint the empty answer bubble — with
|
||||
baked-in delays (every visible window ≥1 s — the ``slow_llm.py``
|
||||
/ phase-109 pacing precedent) so the story E2E's window
|
||||
assertions are race-free. Discriminated statelessly from the
|
||||
messages (streaming only):
|
||||
* request 1 (``tools`` offered, no tool results yet): ~1.2 s
|
||||
pre-delay (model latency while the typing indicator shows),
|
||||
then 3 ``reasoning_content`` chunks at ~0.4 s (≈ 1.2 s of
|
||||
thinking — this request NEVER emits ``delta.content``), then
|
||||
the ``ls`` tool call (synthetic id ``call_0``, no arguments
|
||||
— the phase-37 pattern), ``finish_reason: "tool_calls"``;
|
||||
* request 2 (a ``tool``-role result in the messages — the
|
||||
server ran the ``ls``): a ~2.0 s frameless gap (the
|
||||
post-tool pre-answer window), then 3 short ``delta.content``
|
||||
chunks 0.05 s apart (the answer box appears WITH the first
|
||||
chunk), ``finish_reason: "stop"``.
|
||||
The turn's SSE is exactly ``thinking → tool → delta → done`` —
|
||||
the mid-turn state the box must survive hidden in (typing
|
||||
indicator → reasoning container → answer at the first token).
|
||||
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
|
||||
phrases — the phase-71/72/94 ordering convention); verified
|
||||
2026-10-08: no existing E2E question or fixture file contains
|
||||
the phrase, so every other suite is unaffected.
|
||||
- user message containing ``repeat your folder map``
|
||||
(``FOLDER_MAP_TRIGGER``, phase 119, D3 — the suggested-folder
|
||||
context's story suite ``tests/e2e/test_name_signal_read_chips.py``)
|
||||
@@ -3783,6 +3814,160 @@ def _turn_progress_stream(step: str) -> Any:
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 166 (task 03, the no-empty-answer-box suite): the deterministic
|
||||
# PRE-ANSWER WINDOW turn (thinking + a tool call, zero content deltas
|
||||
# until the follow-up request) — see the module docstring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A user message containing this substring (case-insensitive) —
|
||||
#: combined with the ``<tools>`` section in the system prompt — drives
|
||||
#: the phase-166 PRE-ANSWER WINDOW turn (TODO.md L4: "There's an
|
||||
#: empty box where the LLM response will go that shows up before the
|
||||
#: LLM responds"): ~2.4 s of thinking + a tool call with ZERO content
|
||||
#: deltas, then a ~2 s gap, then the answer — the exact shape that
|
||||
#: painted the empty answer bubble (the owner's image.png) before
|
||||
#: phase 166 hid it. Baked-in delays keep every visible window ≥1 s
|
||||
#: (the ``slow_llm.py`` / phase-109 pacing precedent) so the story
|
||||
#: E2E's window assertions are race-free. Verified 2026-10-08: no
|
||||
#: existing E2E question or fixture file contains the phrase, so
|
||||
#: every other suite is unaffected.
|
||||
EMPTY_BUBBLE_TRIGGER = "paint the pre-answer window"
|
||||
|
||||
#: Request 1's pre-delay (s) — model latency: the typing indicator's
|
||||
#: window (no frame of the request has arrived yet).
|
||||
EMPTY_BUBBLE_PREDELAY_S = 1.2
|
||||
|
||||
#: Per-chunk delay (s) of request 1's three ``reasoning_content``
|
||||
#: chunks — ≈ 1.2 s of visible thinking with NO ``delta.content``
|
||||
#: (the window in which the empty bubble used to paint).
|
||||
EMPTY_BUBBLE_THINK_DELAY_S = 0.4
|
||||
|
||||
#: Request 2's frameless gap (s) — the post-tool pre-answer window:
|
||||
#: ≥ 1 s by construction, so the E2E can assert nothing paints an
|
||||
#: answer box between the ``tool`` frame and the first token.
|
||||
EMPTY_BUBBLE_TOOL_GAP_S = 2.0
|
||||
|
||||
#: Per-chunk delay (s) of request 2's short content deltas.
|
||||
EMPTY_BUBBLE_CONTENT_DELAY_S = 0.05
|
||||
|
||||
#: Request 1's scratchpad (byte-stable): 3 chunks —
|
||||
#: ``marker-thought-166`` is the sentinel the story E2E matches in
|
||||
#: the open reasoning container during the pre-delta window.
|
||||
EMPTY_BUBBLE_THINKING_CHUNKS = (
|
||||
"The owner wants the exact JSON shape — ",
|
||||
"listing the deployments folder first, ",
|
||||
"then answering from the file itself. marker-thought-166.",
|
||||
)
|
||||
|
||||
#: Request 2's answer (byte-stable): 3 short chunks —
|
||||
#: ``marker-emptybox-1`` is the sentinel the story E2E matches on the
|
||||
#: bubble to prove the box appeared WITH the first token.
|
||||
EMPTY_BUBBLE_ANSWER_CHUNKS = (
|
||||
"Here is the shape from the listing: ",
|
||||
"version 3, two record sets, zone ",
|
||||
"Z0RESEELINK01. marker-emptybox-1.",
|
||||
)
|
||||
|
||||
#: The full answer (the three chunks joined) — what the bubble holds
|
||||
#: once the turn settles.
|
||||
EMPTY_BUBBLE_ANSWER = "".join(EMPTY_BUBBLE_ANSWER_CHUNKS)
|
||||
|
||||
|
||||
def _empty_bubble_flow(body: dict[str, Any]) -> str | None:
|
||||
"""Classify a phase-166 pre-answer-window request (see the module
|
||||
docstring). Stateless over the messages, like the other marker
|
||||
flows:
|
||||
|
||||
* ``"first"`` — ``tools`` are offered and no ``tool``-role result
|
||||
is in the messages yet: request 1 — thinking + the ``ls`` tool
|
||||
call, ZERO content deltas.
|
||||
* ``"second"`` — a ``tool``-role result is in the messages (the
|
||||
server ran the ``ls``): request 2 — the ~2 s gap, then the
|
||||
three content chunks.
|
||||
* ``None`` — not the flow: the trigger is absent, the
|
||||
``<tools>`` section is missing (deflected turns never carry
|
||||
it), or tools are not offered and no tool result is in the
|
||||
messages yet.
|
||||
"""
|
||||
if EMPTY_BUBBLE_TRIGGER not in _user(body).lower():
|
||||
return None
|
||||
if "<tools>" not in _system(body):
|
||||
return None
|
||||
if _tool_results(body):
|
||||
return "second"
|
||||
if not body.get("tools"):
|
||||
return None
|
||||
return "first"
|
||||
|
||||
|
||||
def _empty_bubble_stream(step: str) -> Any:
|
||||
"""SSE frames for one phase-166 pre-answer-window request.
|
||||
|
||||
``"first"`` (model call 1): the ~1.2 s pre-delay (model latency
|
||||
— the typing-indicator window), the 3 ``reasoning_content``
|
||||
chunks at 0.4 s each (≈ 1.2 s of thinking — this request emits
|
||||
NO ``delta.content`` at all: the pre-delta state that used to
|
||||
paint the empty box), the ``ls`` tool-call partial (synthetic id
|
||||
``call_0``, no arguments — the phase-37 ``_tool_call_stream``
|
||||
first-partial shape), and the ``finish_reason: "tool_calls"``
|
||||
frame.
|
||||
|
||||
``"second"`` (model call 2 — after the server ran the ``ls``):
|
||||
the ~2.0 s frameless gap (the post-tool pre-answer window), the
|
||||
3 short ``delta.content`` chunks 0.05 s apart (the box appears
|
||||
with the first chunk and grows from there), and the
|
||||
``finish_reason: "stop"`` frame.
|
||||
"""
|
||||
model = "turbo"
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4()}"
|
||||
|
||||
def frame(delta: dict[str, Any], finish: str | None = None) -> str:
|
||||
return (
|
||||
"data: "
|
||||
+ json_dumps(
|
||||
{
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
|
||||
if step == "first":
|
||||
time.sleep(EMPTY_BUBBLE_PREDELAY_S)
|
||||
for piece in EMPTY_BUBBLE_THINKING_CHUNKS:
|
||||
yield frame({"reasoning_content": piece})
|
||||
time.sleep(EMPTY_BUBBLE_THINK_DELAY_S)
|
||||
yield frame(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call_0",
|
||||
"type": "function",
|
||||
"function": {"name": "ls", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
time.sleep(0.1)
|
||||
yield frame({}, "tool_calls")
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
time.sleep(EMPTY_BUBBLE_TOOL_GAP_S)
|
||||
for piece in EMPTY_BUBBLE_ANSWER_CHUNKS:
|
||||
yield frame({"content": piece})
|
||||
time.sleep(EMPTY_BUBBLE_CONTENT_DELAY_S)
|
||||
yield frame({}, "stop")
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def compose_answer(body: dict[str, Any]) -> str:
|
||||
system = _system(body)
|
||||
user = _user(body)
|
||||
@@ -5053,6 +5238,23 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 166 (task 03, the no-empty-answer-box suite): the
|
||||
# deterministic PRE-ANSWER WINDOW turn — request 1 streams
|
||||
# thinking + the ``ls`` tool call with ZERO content deltas,
|
||||
# request 2 answers after a ~2 s gap (TODO.md L4 — the empty
|
||||
# answer box that painted before the first token; see the
|
||||
# module docstring). Checked BEFORE the plain TOOLS_TRIGGER
|
||||
# flow (disjoint trigger phrases — the phase-71/72/94
|
||||
# ordering convention; the trigger needs the ``<tools>``
|
||||
# section, so deflected turns never hit it).
|
||||
empty_bubble = _empty_bubble_flow(body)
|
||||
if empty_bubble is not None:
|
||||
stream = _empty_bubble_stream(empty_bubble)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 161 (task 04 — the reasoning-bubble story): the
|
||||
# deterministic MID-ROUND flow — a thinking run, then a
|
||||
# MID-ROUND ``delta`` (the React-style partial answer — the
|
||||
|
||||
@@ -521,7 +521,7 @@ def _expect_desc_cell(
|
||||
escape hatch, ``makeDescCell`` — set whenever the description is
|
||||
non-empty, whether or not the display ellipsizes)."""
|
||||
span = page.locator(f"#folders-tbody tr:nth-child({row_index + 1}) "
|
||||
"td:nth-child(4) span")
|
||||
"td:nth-child(4) span.kb-desc-text")
|
||||
expect(span).to_have_text(full_text)
|
||||
expect(span).to_have_attribute("title", full_text)
|
||||
|
||||
@@ -633,7 +633,7 @@ def test_rag_table_renders_the_stored_voice(
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(EXPECTED[""])
|
||||
# The rendered voice is the new one: no Description cell in the
|
||||
# level shows a forbidden opener (the shape pin, view-side).
|
||||
for cell in page.locator("#folders-tbody td:nth-child(4) span").all():
|
||||
for cell in page.locator("#folders-tbody td:nth-child(4) span.kb-desc-text").all():
|
||||
rendered = cell.text_content() or ""
|
||||
for prefix in FORBIDDEN_PREFIXES:
|
||||
assert not rendered.startswith(prefix), rendered
|
||||
|
||||
@@ -91,6 +91,33 @@ def _box_height(page: Page, selector: str) -> float:
|
||||
return box["height"]
|
||||
|
||||
|
||||
def _meta_line_count(page: Page, selector: str) -> int:
|
||||
"""How many VISUAL LINES the row's children occupy: cluster the
|
||||
children by their vertical center (a 4px tolerance absorbs the
|
||||
mixed badge/text/native-control heights on one line) and return the
|
||||
number of clusters. ONE cluster = one line; a wrap produces two (the
|
||||
second row of children sits a full line lower). Shape-first on
|
||||
purpose: the row's own height is content-sized by its tallest child
|
||||
— since phase 153 that is the ≥44px touch-target floor on
|
||||
`.doc-copy-path` at ≤640px, so a pixel bound on the row would pin
|
||||
the control's minimum, not the layout."""
|
||||
centers = page.evaluate(
|
||||
"""(sel) => [...document.querySelectorAll(sel + ' > *')]
|
||||
.map(el => { const b = el.getBoundingClientRect();
|
||||
return b.height > 0 ? b.top + b.height / 2 : null; })
|
||||
.filter(v => v !== null)""",
|
||||
selector,
|
||||
)
|
||||
assert centers, f"{selector} rendered no visible children"
|
||||
clusters: list[list[float]] = []
|
||||
for c in sorted(centers):
|
||||
if clusters and c - clusters[-1][-1] <= 4:
|
||||
clusters[-1].append(c)
|
||||
else:
|
||||
clusters.append([c])
|
||||
return len(clusters)
|
||||
|
||||
|
||||
def _header_heights(page: Page, app_url: str) -> dict[str, float]:
|
||||
"""Measured heights of the sticky top bar on the three pages."""
|
||||
heights: dict[str, float] = {}
|
||||
@@ -201,13 +228,19 @@ def test_viewer_header_content_still_fits(
|
||||
assert _box_height(page, "#doc-title") < 30 # one line at either size
|
||||
|
||||
# Meta row: badges + path present and visible, single line.
|
||||
# The bound catches a WRAP (a second line would be ≥ ~44px),
|
||||
# not a pixel-exact line height: the row is content-sized by
|
||||
# its tallest child — the native type=date input (phase 106
|
||||
# D8), whose metrics are Chromium/font-dependent and render
|
||||
# the single line at ~26px on this host (the pre-phase 26px
|
||||
# pin red-lined on the 26.125px measurement, 2026-09-16).
|
||||
assert _box_height(page, ".doc-meta") < 34
|
||||
# SHAPE, not pixels: the pre-phase absolute bound (<34px) went
|
||||
# stale the moment the row gained a control carrying the ≥44px
|
||||
# touch-target floor (phase 153: .doc-copy-path min-height 44px
|
||||
# at ≤640px — PLAN §7.1), which makes a single, correctly-wrapped
|
||||
# mobile line measure 44px. What the contract actually forbids is
|
||||
# the row STACKING (a wrap grows the header), so the pin reads the
|
||||
# geometry directly: every child centered on the row's own center
|
||||
# = ONE line, whatever the tallest control's height (the same
|
||||
# shape-first reading the 2026-09-16 note above already moved to).
|
||||
assert _meta_line_count(page, ".doc-meta") == 1, (
|
||||
"the meta row must stay on ONE line (badges, path, dates and "
|
||||
"controls centered on the row's own center)"
|
||||
)
|
||||
expect(page.locator(".doc-source-badge", has_text="docs")).to_be_visible()
|
||||
expect(page.locator(".format-badge", has_text="md")).to_be_visible()
|
||||
expect(page.locator(".doc-path", has_text="homelab/kubernetes.md")).to_be_visible()
|
||||
|
||||
@@ -545,10 +545,10 @@ def test_top_level_lists_sources_with_descriptions(
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text(ALPHA)
|
||||
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text(str(ALPHA_COUNT))
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(ALPHA_ROOT_SUM)
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ALPHA_ROOT_SUM)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text(BETA)
|
||||
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text(str(BETA_COUNT))
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(BETA_ROOT_SUM)
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(BETA_ROOT_SUM)
|
||||
# The edit affordance is ALWAYS present (a description can be
|
||||
# CREATED where none is stored) — the row surface, one per row.
|
||||
expect(page.locator("#folders-tbody .kb-summary-edit")).to_have_count(2)
|
||||
@@ -607,10 +607,10 @@ def test_drill_into_source(
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("one")
|
||||
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text("2")
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(ONE_SUM)
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ONE_SUM)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text("two")
|
||||
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text("2")
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(TWO_SUM)
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(TWO_SUM)
|
||||
|
||||
# The level's direct files: root-note.md — the 6-column contract
|
||||
# (makeRow): Source | Path | Title | Chunks | Created | Indexed
|
||||
@@ -718,7 +718,7 @@ def test_edit_folder_description(
|
||||
# (the textContent re-render — the canned text is gone); the
|
||||
# always-present Edit button is back.
|
||||
expect(status).to_have_text("Description updated.")
|
||||
expect(row.locator("td:nth-child(4) span")).to_have_text(NEW_ALPHA_ONE)
|
||||
expect(row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(NEW_ALPHA_ONE)
|
||||
expect(row.locator(".kb-summary-edit")).to_be_visible()
|
||||
|
||||
# The server row: the new text AND the manually_edited flag (the
|
||||
@@ -761,7 +761,7 @@ def test_clear_folder_description(
|
||||
# The text is gone: the cell empties (the always-present Edit
|
||||
# button stays — a description can be re-created from the empty
|
||||
# cell).
|
||||
expect(row.locator("td:nth-child(4) span")).to_have_text("")
|
||||
expect(row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text("")
|
||||
expect(row.locator(".kb-summary-edit")).to_be_visible()
|
||||
|
||||
# The SQL assert: no row for the folder (the next KB-changing
|
||||
@@ -794,7 +794,7 @@ def test_manual_description_survives_a_changed_sync(
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(1)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("gamma")
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(GAMMA_SUM)
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(GAMMA_SUM)
|
||||
|
||||
# The LEVEL-BLOCK Edit (the level surface — test 4 pins the row
|
||||
# surface): the static #kb-level-edit button, the source root
|
||||
@@ -836,7 +836,7 @@ def test_manual_description_survives_a_changed_sync(
|
||||
expect(
|
||||
page.locator("#folders-tbody tr")
|
||||
.nth(0)
|
||||
.locator("td:nth-child(4) span")
|
||||
.locator("td:nth-child(4) span.kb-desc-text")
|
||||
).to_have_text(GAMMA_SUM)
|
||||
|
||||
# The new document landed in the tree: back to the top (the
|
||||
@@ -847,7 +847,7 @@ def test_manual_description_survives_a_changed_sync(
|
||||
one_row = page.locator("#folders-tbody tr").nth(0)
|
||||
expect(one_row.locator("a.folder-link")).to_have_text("one")
|
||||
expect(one_row.locator("td:nth-child(2)")).to_have_text("3")
|
||||
expect(one_row.locator("td:nth-child(4) span")).to_have_text(ONE_SUM)
|
||||
expect(one_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ONE_SUM)
|
||||
|
||||
# The SQL asserts: the manual row survived (text + flag), the
|
||||
# untouched rows were regenerated (the canned text, the flag back
|
||||
|
||||
@@ -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
|
||||
# the enabled Stop control while a turn runs — it is no longer
|
||||
# disabled, so the disabled-state proxy is gone).
|
||||
#
|
||||
# WHAT IS MEASURED (2026-10-08): the VISUAL position of the text the
|
||||
# user is reading — the first answer bubble's top relative to the
|
||||
# viewport — not the raw scrollY. Chromium's native scroll anchoring
|
||||
# legitimately compensates scrollY when layout above the viewport
|
||||
# changes (at settle the Edit/Retry buttons re-target to the newest
|
||||
# turn, so the FIRST turn's meta row collapses 44px→0): the reader
|
||||
# sees nothing move, but a bare scrollY-constancy check would flag
|
||||
# the compensating adjustment itself. A real up-hop (the regression
|
||||
# this suite pins — the old block:"end" submit jump, programmatic
|
||||
# follow-scrolls) moves the marker just the same, so the contract is
|
||||
# asserted strictly, and correctly, on what the user perceives.
|
||||
marker_top = """() => {
|
||||
const el = document.querySelector('.msg.brain .bubble');
|
||||
return el ? el.getBoundingClientRect().top : null;
|
||||
}"""
|
||||
samples: list[float] = []
|
||||
mid_stream = 0
|
||||
deadline = time.monotonic() + 40
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.25)
|
||||
samples.append(scroll_state(page)["y"])
|
||||
samples.append(page.evaluate(marker_top))
|
||||
if page.locator("#send-label").inner_text() == "Stop":
|
||||
mid_stream += 1
|
||||
if len(samples) >= 10 and page.locator("#send-label").inner_text() == "Send":
|
||||
@@ -283,7 +299,7 @@ def test_no_autoscroll_during_long_answer(
|
||||
# queued behind the stream may move the page either).
|
||||
for _ in range(3):
|
||||
time.sleep(0.3)
|
||||
samples.append(scroll_state(page)["y"])
|
||||
samples.append(page.evaluate(marker_top))
|
||||
break
|
||||
else:
|
||||
raise AssertionError("the long turn did not settle within 40s")
|
||||
@@ -291,8 +307,8 @@ def test_no_autoscroll_during_long_answer(
|
||||
|
||||
spread = max(samples) - min(samples)
|
||||
assert spread <= STABLE_PX, (
|
||||
f"the viewport moved {spread:.1f}px while the user was scrolled up "
|
||||
"(no-reply-autoscroll contract)"
|
||||
f"the page moved {spread:.1f}px under the user's reading position "
|
||||
"while they were scrolled up (no-reply-autoscroll contract)"
|
||||
)
|
||||
|
||||
# The whole answer still landed (off-screen below — by design).
|
||||
@@ -318,13 +334,17 @@ def test_no_autoscroll_during_thinking(
|
||||
# The thinking turn: the submit reveals the user's message (the kept
|
||||
# app scroll) ...
|
||||
submit(page, THINKING_QUESTION)
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
# 690fd98 refinement: ONE details.reasoning container per turn (the
|
||||
# live per-run blocks are plain divs that never fold); the container
|
||||
# is created OPEN and auto-folds on settle — same view-stability
|
||||
# contract phase 17 pinned, one level up.
|
||||
details = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
expect(details).to_have_attribute("open", "") # created open (phase 17)
|
||||
# ... and, once the reasoning stream is clearly running ...
|
||||
page.wait_for_function(
|
||||
"() => { const el = document.querySelector('details.thinking .thinking-text');"
|
||||
" return !!el && el.innerText.length > 300; }",
|
||||
"() => { const els = document.querySelectorAll('details.reasoning .thinking-text');"
|
||||
" let n = 0; els.forEach((e) => { n += e.innerText.length; }); return n > 300; }",
|
||||
timeout=30_000,
|
||||
)
|
||||
# ... let the submit's smooth reveal settle, then the user goes up.
|
||||
@@ -339,7 +359,7 @@ def test_no_autoscroll_during_thinking(
|
||||
time.sleep(0.3)
|
||||
state = page.evaluate(
|
||||
"""() => {
|
||||
const block = document.querySelector('details.thinking');
|
||||
const block = document.querySelector('details.reasoning');
|
||||
const wrap = block ? block.closest('.msg.brain') : null;
|
||||
const el = wrap ? wrap.querySelector('.bubble') : null;
|
||||
return { y: window.scrollY,
|
||||
@@ -366,7 +386,9 @@ def test_no_autoscroll_during_thinking(
|
||||
# the point of the story).
|
||||
wait_settled(page)
|
||||
assert abs(scroll_state(page)["y"]) <= STABLE_PX
|
||||
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
# Settled: the container auto-folded; its last run holds the tail.
|
||||
assert not details.get_attribute("open")
|
||||
expect(details.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
|
||||
|
||||
@@ -484,9 +506,12 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
|
||||
# (phase 17: open while streaming, closed from the first delta on).
|
||||
submit(page, THINKING_QUESTION)
|
||||
wait_settled(page)
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
# 690fd98 refinement: settled turns carry ONE details.reasoning
|
||||
# container (auto-collapsed by settle, phase-17 contract) holding the
|
||||
# plain per-run blocks — the per-run details.thinking is gone live.
|
||||
details = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(details).not_to_have_attribute("open")
|
||||
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(details.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
|
||||
# Persistence: four messages, the thinking text stored raw — and
|
||||
@@ -508,9 +533,10 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
|
||||
expect(page.locator(".msg.brain .bubble")).to_have_count(2)
|
||||
expect(page.locator(".msg.brain .bubble").first).to_contain_text(LONG_ANSWER_END)
|
||||
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
|
||||
restored = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
# Restore writes the SAME container shape (segments-based restore).
|
||||
restored = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(restored).not_to_have_attribute("open")
|
||||
expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(restored.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
|
||||
wait_settled(page)
|
||||
|
||||
|
||||
|
||||
@@ -651,12 +651,19 @@ def test_scoped_scope_survives_save_restore(
|
||||
page.goto(app_url + f"/?chat={row['id']}")
|
||||
_assert_scoped_line(_tool_line(page))
|
||||
|
||||
# Phase 117 (D3): a restored turn renders FOLDED — one compact
|
||||
# "Tool call(s) (N)" line (the disclosure shape is unchanged by
|
||||
# this phase: the `open` attribute is ABSENT).
|
||||
disclosure = page.locator(".msg.brain .tool-calls-disclosure").last
|
||||
expect(disclosure).not_to_have_attribute("open", timeout=30_000)
|
||||
expect(disclosure.locator("summary")).to_have_text(
|
||||
# Phase 117 (D3), re-pinned for the phase-161 D5 refinement (the
|
||||
# owner's 2026-10-06 shape, shipped in 690fd98): a restored turn
|
||||
# renders FOLDED inside the ONE details.reasoning container — the
|
||||
# container is the ONLY disclosure (zero nested <details>), the
|
||||
# run itself is a plain div whose label is the
|
||||
# .tool-calls-summary SPAN. The compact "Tool call (1)" line the
|
||||
# phase-117 pin named is unchanged; only the element carrying it
|
||||
# moved (this phase's scope — the scoped line — is asserted above).
|
||||
container = page.locator(".msg.brain details.reasoning").last
|
||||
expect(container).not_to_have_attribute("open", timeout=30_000)
|
||||
expect(container.locator("details")).to_have_count(0)
|
||||
run = page.locator(".msg.brain .tool-calls-disclosure").last
|
||||
expect(run.locator(".tool-calls-summary")).to_have_text(
|
||||
"Tool call (1)", timeout=30_000
|
||||
)
|
||||
|
||||
|
||||
@@ -340,12 +340,23 @@ def test_anonymous_shared_view(
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
assert bubble.inner_text() == answer, "the shared answer must match the admin's"
|
||||
|
||||
# The thinking block exists and is RESTORED COLLAPSED (the
|
||||
# phase-17 restore convention — a closed <details> has no
|
||||
# `open` attribute).
|
||||
think = anon.locator("details.thinking")
|
||||
expect(think).to_have_count(1)
|
||||
expect(think.first).not_to_have_attribute("open")
|
||||
# The reasoning timeline exists and renders COLLAPSED (the
|
||||
# phase-161 D5 convention, updated by the 2026-10-06 owner
|
||||
# refinement shipped in 690fd98 — the saved turn carries
|
||||
# `segments`, so shared.js's addSegments wraps the per-run
|
||||
# timeline in the ONE details.reasoning container; a closed
|
||||
# <details> has no `open` attribute, and the inner runs are
|
||||
# plain divs — NEVER a nested <details>).
|
||||
cont = anon.locator("details.reasoning")
|
||||
expect(cont).to_have_count(1)
|
||||
expect(cont.first).not_to_have_attribute("open")
|
||||
expect(cont.locator("details")).to_have_count(0)
|
||||
# The thinking RUN itself is present inside the collapsed
|
||||
# container (the plain-div shape — the guest sees the same
|
||||
# timeline the admin saw, just folded at rest).
|
||||
runs = cont.locator("div.thinking")
|
||||
expect(runs).to_have_count(1)
|
||||
expect(runs.first.locator(".thinking-text")).not_to_be_empty()
|
||||
|
||||
# Source chips: ZERO — the shared turn read nothing, so
|
||||
# (phase 119, LOCKED A1) its done sources are empty and the
|
||||
|
||||
@@ -366,9 +366,11 @@ def test_nav_switch_before_first_token_completes(
|
||||
# running — NO content frame has landed yet. (The .msg.brain bubble
|
||||
# element exists from turn start with its thinking block — the
|
||||
# pre-token state is "no content text", not "no bubble element".)
|
||||
thinking = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
# 690fd98 refinement: the live thinking block is a plain div.thinking
|
||||
# RUN (never folding mid-turn) inside the details.reasoning container.
|
||||
thinking = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
thinking.wait_for(state="attached", timeout=10_000)
|
||||
expect(thinking.locator(".thinking-text")).to_contain_text(
|
||||
expect(thinking.locator(".thinking-text").last).to_contain_text(
|
||||
THINKING_TAIL, timeout=30_000
|
||||
)
|
||||
# Still pre-token: the button is the enabled Stop control (phase 48
|
||||
@@ -444,9 +446,11 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
|
||||
# which outruns Playwright's 5s assertion auto-wait on a loaded host
|
||||
# (the wait started at the FIRST thinking frame — the pre-existing
|
||||
# race, phase 55 task 02 fix).
|
||||
thinking = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
# 690fd98 refinement: the live thinking block is a plain div.thinking
|
||||
# RUN (never folding mid-turn) inside the details.reasoning container.
|
||||
thinking = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
thinking.wait_for(state="attached", timeout=10_000)
|
||||
expect(thinking.locator(".thinking-text")).to_contain_text(
|
||||
expect(thinking.locator(".thinking-text").last).to_contain_text(
|
||||
THINKING_TAIL, timeout=30_000
|
||||
)
|
||||
# Still pre-token: the button is the enabled Stop control (phase 48 —
|
||||
|
||||
@@ -862,7 +862,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
src_row = page.locator("#folders-tbody tr")
|
||||
expect(src_row.locator("a.folder-link")).to_have_text(SOURCE)
|
||||
expect(src_row.locator("td:nth-child(2)")).to_have_text(str(N_FILES))
|
||||
expect(src_row.locator("td:nth-child(4) span")).to_have_text(SUM_ROOT)
|
||||
expect(src_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ROOT)
|
||||
expect(page.locator(".kb-summary-pending")).to_have_count(0)
|
||||
|
||||
# The gap: delete the bravo row AND the source-root row directly
|
||||
@@ -877,7 +877,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
page.click("#nav-sources")
|
||||
_wait_top_level(page)
|
||||
src_row = page.locator("#folders-tbody tr")
|
||||
s_span = src_row.locator("td:nth-child(4) span")
|
||||
s_span = src_row.locator("td:nth-child(4) span.kb-desc-text")
|
||||
expect(s_span).to_have_text(PENDING_COPY)
|
||||
# Phase 99 (task 01): the marker toggles onto the cell's base
|
||||
# .kb-desc-text span (the one-line clamp) — the class pair, exact.
|
||||
@@ -900,13 +900,13 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
bravo_row = rows.nth(1)
|
||||
expect(alpha_row.locator("a.folder-link")).to_have_text(FOLDER_A)
|
||||
# The intact folder: the stored line, never the marker.
|
||||
expect(alpha_row.locator("td:nth-child(4) span")).to_have_text(SUM_ALPHA)
|
||||
expect(alpha_row.locator("td:nth-child(4) span")).not_to_have_class(
|
||||
expect(alpha_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ALPHA)
|
||||
expect(alpha_row.locator("td:nth-child(4) span.kb-desc-text")).not_to_have_class(
|
||||
"kb-summary-pending"
|
||||
)
|
||||
# The affected folder: the marker (copy + class + title) — the
|
||||
# class pair with the phase-99 .kb-desc-text base span, exact.
|
||||
b_span = bravo_row.locator("td:nth-child(4) span")
|
||||
b_span = bravo_row.locator("td:nth-child(4) span.kb-desc-text")
|
||||
expect(b_span).to_have_text(PENDING_COPY)
|
||||
expect(b_span).to_have_class("kb-desc-text kb-summary-pending")
|
||||
expect(b_span).to_have_attribute("title", PENDING_TITLE)
|
||||
@@ -929,7 +929,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
expect(bravo_row.locator(".kb-summary-status")).to_have_text(
|
||||
"Description updated."
|
||||
)
|
||||
b_span = bravo_row.locator("td:nth-child(4) span")
|
||||
b_span = bravo_row.locator("td:nth-child(4) span.kb-desc-text")
|
||||
expect(b_span).to_have_text(MANUAL_BRavo)
|
||||
expect(b_span).not_to_have_class("kb-summary-pending")
|
||||
|
||||
@@ -962,14 +962,14 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
expect(page.locator("#kb-level-title")).to_have_text(SOURCE)
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(SUM_ROOT)
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(SUM_ALPHA)
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(MANUAL_BRavo)
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ALPHA)
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(MANUAL_BRavo)
|
||||
expect(page.locator(".kb-summary-pending")).to_have_count(0)
|
||||
# And at the top level: the source row's cell carries the
|
||||
# regenerated root line, no marker.
|
||||
page.locator("#kb-crumb a.kb-crumb-link").nth(0).click()
|
||||
src_row = page.locator("#folders-tbody tr")
|
||||
s_span = src_row.locator("td:nth-child(4) span")
|
||||
s_span = src_row.locator("td:nth-child(4) span.kb-desc-text")
|
||||
expect(s_span).to_have_text(SUM_ROOT)
|
||||
expect(s_span).not_to_have_class("kb-summary-pending")
|
||||
expect(page.locator(".kb-summary-pending")).to_have_count(0)
|
||||
|
||||
@@ -36,8 +36,13 @@ thinking frame) is REPLACED by the per-run timeline (D1/D5): each
|
||||
contiguous thinking run streams into its OWN block, and the first frame
|
||||
of a new kind closes the previous run. The SSE stream is unchanged, so
|
||||
this turn renders ONE ``.tool-calls-disclosure`` + TWO ``.thinking``
|
||||
blocks (arrival order, all ABOVE the answer bubble — D2), open in their
|
||||
live windows and folded at rest. The loader pins
|
||||
blocks (arrival order) — wrapped since phase 161 in the ONE
|
||||
``details.reasoning`` container ABOVE the answer bubble (D1), the
|
||||
container open from creation until the settle fold (D2). The
|
||||
2026-10-06 D5 refinement (owner, shipped in ``690fd98``): the per-run
|
||||
blocks are PLAIN DIVS — always visible inside the container, never
|
||||
themselves collapsible (the container is the ONE disclosure). The
|
||||
loader pins
|
||||
(``test_loader_visible_from_send_through_the_tool_gap``,
|
||||
``test_loader_a11y_and_reduced_motion``) stay unchanged — the
|
||||
never-stale loader contract (D16) is intact.
|
||||
@@ -53,15 +58,17 @@ context, ``_reset_db`` reseeds the KB deterministically):
|
||||
the phase-87 elapsed counter in the tool gap, and ``#send-status``
|
||||
carries a state text (not empty) throughout.
|
||||
* ``test_thinking_runs_open_in_live_window_with_visible_loader`` —
|
||||
THE reported symptom's state, re-pinned for phase 158 (D5 replaces
|
||||
phase-109 D15 — a NEW block instead of a re-open): the post-delta
|
||||
thinking frames open a per-run ``.thinking`` block (exactly one
|
||||
block, open + non-empty ``.thinking-text`` while the bubble already
|
||||
carries call 1's content — the run starts AFTER the first delta) with
|
||||
the loader STILL visible (the frozen window is gone — the user sees
|
||||
the new thinking in its own live block); the terminal state is clean
|
||||
(loader hidden, the scratchpad holds TWO ``.thinking`` blocks —
|
||||
EACH carrying its own round's text — BOTH folded at rest, the bubble
|
||||
THE reported symptom's state, re-pinned for phase 158 + the phase-161
|
||||
D5 refinement (a NEW plain-div run instead of a re-open; the runs
|
||||
never fold, the ONE container does): while the
|
||||
``details.reasoning`` container is OPEN, exactly one ``.thinking``
|
||||
run is VISIBLE with non-empty ``.thinking-text`` while the bubble
|
||||
already carries call 1's content — the run starts AFTER the first
|
||||
delta — with the loader STILL visible (the frozen window is gone —
|
||||
the user sees the new thinking in its own live run); the terminal
|
||||
state is clean (loader hidden, the container folded at the settle —
|
||||
zero nested ``<details>`` inside it — the scratchpad holds TWO
|
||||
``.thinking`` runs EACH carrying its own round's text, the bubble
|
||||
carries BOTH answers, the send button reads "Send" not "Stop",
|
||||
``#send-status`` back to the idle shape).
|
||||
* ``test_loader_a11y_and_reduced_motion`` — after a full turn: the
|
||||
@@ -223,25 +230,32 @@ def test_thinking_runs_open_in_live_window_with_visible_loader(
|
||||
loader = page.locator("#turn-loader")
|
||||
|
||||
# THE reported symptom's state (phase 158 D5 replaces phase-109
|
||||
# D15 — the post-delta thinking frames open a NEW per-run block
|
||||
# instead of re-opening the first delta's block): exactly ONE
|
||||
# ``.thinking`` block exists, it is OPEN with non-empty text WHILE
|
||||
# the answer bubble already carries call 1's content — the first
|
||||
# thinking run starts AFTER the first delta (the owner's "thinking
|
||||
# after responding" repro), and the loader is STILL visible (the
|
||||
# frozen window is gone — the user sees the new thinking in its
|
||||
# own live block).
|
||||
# D15 — the post-delta thinking frames open a NEW per-run block,
|
||||
# and the phase-161 D5 refinement (owner 2026-10-06, 690fd98) made
|
||||
# the runs PLAIN DIVS inside the ONE details.reasoning container —
|
||||
# always visible, the CONTAINER is the disclosure): while the
|
||||
# container is OPEN, exactly ONE ``.thinking`` run exists and
|
||||
# paints non-empty text WHILE the answer bubble already carries
|
||||
# call 1's content — the first thinking run starts AFTER the first
|
||||
# delta (the owner's "thinking after responding" repro), and the
|
||||
# loader is STILL visible (the frozen window is gone — the user
|
||||
# sees the new thinking in its own live run).
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const b = document.querySelectorAll('.msg.brain .thinking');
|
||||
const cont = document.querySelector(
|
||||
'.msg.brain details.reasoning[open]');
|
||||
if (!cont) return false;
|
||||
const b = cont.querySelectorAll('.thinking');
|
||||
const t = b.length === 1 ? b[0].querySelector('.thinking-text') : null;
|
||||
return b.length === 1 && !!b[0].open && !!t
|
||||
&& t.textContent.trim().length > 0;
|
||||
return b.length === 1 && !!t && t.textContent.trim().length > 0;
|
||||
}""",
|
||||
timeout=30_000,
|
||||
)
|
||||
expect(blocks).to_have_count(1)
|
||||
expect(blocks).to_have_attribute("open", "")
|
||||
expect(blocks.first).to_be_visible()
|
||||
expect(
|
||||
page.locator(".msg.brain details.reasoning").first
|
||||
).to_have_attribute("open", "")
|
||||
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
|
||||
# The run is genuinely AFTER a delta: call 1's content is already
|
||||
# in the bubble ...
|
||||
@@ -265,11 +279,12 @@ def test_thinking_runs_open_in_live_window_with_visible_loader(
|
||||
# reasoning streams — separate blocks, not one lump) ...
|
||||
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
|
||||
expect(blocks.nth(1).locator(".thinking-text")).to_contain_text(SENTINEL_FINAL_THOUGHT)
|
||||
# ... and BOTH runs are FOLDED at rest (D5 — the per-run
|
||||
# fold-at-rest; the phase-109 symptom — the frozen chat — stays
|
||||
# fixed: a run is open in its live window, never re-opened after
|
||||
# done) ...
|
||||
expect(page.locator(".msg.brain .thinking[open]")).to_have_count(0)
|
||||
# ... and the ONE container FOLDED at the settle (phase 161 D2 —
|
||||
# the container is the ONLY disclosure; the inner runs are plain
|
||||
# divs, never collapsible: zero nested <details>; the phase-109
|
||||
# symptom — the frozen chat — stays fixed) ...
|
||||
expect(page.locator(".msg.brain details.reasoning[open]")).to_have_count(0)
|
||||
expect(page.locator(".msg.brain details.reasoning details")).to_have_count(0)
|
||||
# ... the bubble carries BOTH answers (call 1 + call 2) ...
|
||||
expect(bubble).to_contain_text(SENTINEL_FIRST)
|
||||
expect(bubble).to_contain_text(SENTINEL_FINAL)
|
||||
|
||||
@@ -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