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

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

- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
  - `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
  - `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
  - `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
This commit is contained in:
2026-10-08 04:27:03 -04:00
parent b0c7aed613
commit ab9391d019
36 changed files with 2348 additions and 93 deletions
+202
View File
@@ -590,6 +590,37 @@ Implements just enough of the aipi surface:
phrases — the phase-71/72/94 ordering convention); verified
2026-09-16: no existing E2E question or fixture file contains the
phrase, so every other suite is unaffected.
- user message containing ``paint the pre-answer window``
(``EMPTY_BUBBLE_TRIGGER``, phase 166 task 03 — the no-empty-
answer-box story's dedicated suite
``tests/e2e/test_no_empty_box.py``) **and** the system prompt
carries the ``<tools>`` section -> the deterministic PRE-ANSWER
WINDOW turn (TODO.md L4: "There's an empty box where the LLM
response will go that shows up before the LLM responds"):
thinking frames and a tool call with ZERO content deltas — the
exact shape that used to paint the empty answer bubble — with
baked-in delays (every visible window ≥1 s — the ``slow_llm.py``
/ phase-109 pacing precedent) so the story E2E's window
assertions are race-free. Discriminated statelessly from the
messages (streaming only):
* request 1 (``tools`` offered, no tool results yet): ~1.2 s
pre-delay (model latency while the typing indicator shows),
then 3 ``reasoning_content`` chunks at ~0.4 s (≈ 1.2 s of
thinking — this request NEVER emits ``delta.content``), then
the ``ls`` tool call (synthetic id ``call_0``, no arguments
— the phase-37 pattern), ``finish_reason: "tool_calls"``;
* request 2 (a ``tool``-role result in the messages — the
server ran the ``ls``): a ~2.0 s frameless gap (the
post-tool pre-answer window), then 3 short ``delta.content``
chunks 0.05 s apart (the answer box appears WITH the first
chunk), ``finish_reason: "stop"``.
The turn's SSE is exactly ``thinking → tool → delta → done`` —
the mid-turn state the box must survive hidden in (typing
indicator → reasoning container → answer at the first token).
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
phrases — the phase-71/72/94 ordering convention); verified
2026-10-08: no existing E2E question or fixture file contains
the phrase, so every other suite is unaffected.
- user message containing ``repeat your folder map``
(``FOLDER_MAP_TRIGGER``, phase 119, D3 — the suggested-folder
context's story suite ``tests/e2e/test_name_signal_read_chips.py``)
@@ -3783,6 +3814,160 @@ def _turn_progress_stream(step: str) -> Any:
yield "data: [DONE]\n\n"
# ---------------------------------------------------------------------------
# Phase 166 (task 03, the no-empty-answer-box suite): the deterministic
# PRE-ANSWER WINDOW turn (thinking + a tool call, zero content deltas
# until the follow-up request) — see the module docstring
# ---------------------------------------------------------------------------
#: A user message containing this substring (case-insensitive) —
#: combined with the ``<tools>`` section in the system prompt — drives
#: the phase-166 PRE-ANSWER WINDOW turn (TODO.md L4: "There's an
#: empty box where the LLM response will go that shows up before the
#: LLM responds"): ~2.4 s of thinking + a tool call with ZERO content
#: deltas, then a ~2 s gap, then the answer — the exact shape that
#: painted the empty answer bubble (the owner's image.png) before
#: phase 166 hid it. Baked-in delays keep every visible window ≥1 s
#: (the ``slow_llm.py`` / phase-109 pacing precedent) so the story
#: E2E's window assertions are race-free. Verified 2026-10-08: no
#: existing E2E question or fixture file contains the phrase, so
#: every other suite is unaffected.
EMPTY_BUBBLE_TRIGGER = "paint the pre-answer window"
#: Request 1's pre-delay (s) — model latency: the typing indicator's
#: window (no frame of the request has arrived yet).
EMPTY_BUBBLE_PREDELAY_S = 1.2
#: Per-chunk delay (s) of request 1's three ``reasoning_content``
#: chunks — ≈ 1.2 s of visible thinking with NO ``delta.content``
#: (the window in which the empty bubble used to paint).
EMPTY_BUBBLE_THINK_DELAY_S = 0.4
#: Request 2's frameless gap (s) — the post-tool pre-answer window:
#: ≥ 1 s by construction, so the E2E can assert nothing paints an
#: answer box between the ``tool`` frame and the first token.
EMPTY_BUBBLE_TOOL_GAP_S = 2.0
#: Per-chunk delay (s) of request 2's short content deltas.
EMPTY_BUBBLE_CONTENT_DELAY_S = 0.05
#: Request 1's scratchpad (byte-stable): 3 chunks —
#: ``marker-thought-166`` is the sentinel the story E2E matches in
#: the open reasoning container during the pre-delta window.
EMPTY_BUBBLE_THINKING_CHUNKS = (
"The owner wants the exact JSON shape — ",
"listing the deployments folder first, ",
"then answering from the file itself. marker-thought-166.",
)
#: Request 2's answer (byte-stable): 3 short chunks —
#: ``marker-emptybox-1`` is the sentinel the story E2E matches on the
#: bubble to prove the box appeared WITH the first token.
EMPTY_BUBBLE_ANSWER_CHUNKS = (
"Here is the shape from the listing: ",
"version 3, two record sets, zone ",
"Z0RESEELINK01. marker-emptybox-1.",
)
#: The full answer (the three chunks joined) — what the bubble holds
#: once the turn settles.
EMPTY_BUBBLE_ANSWER = "".join(EMPTY_BUBBLE_ANSWER_CHUNKS)
def _empty_bubble_flow(body: dict[str, Any]) -> str | None:
"""Classify a phase-166 pre-answer-window request (see the module
docstring). Stateless over the messages, like the other marker
flows:
* ``"first"`` — ``tools`` are offered and no ``tool``-role result
is in the messages yet: request 1 — thinking + the ``ls`` tool
call, ZERO content deltas.
* ``"second"`` — a ``tool``-role result is in the messages (the
server ran the ``ls``): request 2 — the ~2 s gap, then the
three content chunks.
* ``None`` — not the flow: the trigger is absent, the
``<tools>`` section is missing (deflected turns never carry
it), or tools are not offered and no tool result is in the
messages yet.
"""
if EMPTY_BUBBLE_TRIGGER not in _user(body).lower():
return None
if "<tools>" not in _system(body):
return None
if _tool_results(body):
return "second"
if not body.get("tools"):
return None
return "first"
def _empty_bubble_stream(step: str) -> Any:
"""SSE frames for one phase-166 pre-answer-window request.
``"first"`` (model call 1): the ~1.2 s pre-delay (model latency
— the typing-indicator window), the 3 ``reasoning_content``
chunks at 0.4 s each (≈ 1.2 s of thinking — this request emits
NO ``delta.content`` at all: the pre-delta state that used to
paint the empty box), the ``ls`` tool-call partial (synthetic id
``call_0``, no arguments — the phase-37 ``_tool_call_stream``
first-partial shape), and the ``finish_reason: "tool_calls"``
frame.
``"second"`` (model call 2 — after the server ran the ``ls``):
the ~2.0 s frameless gap (the post-tool pre-answer window), the
3 short ``delta.content`` chunks 0.05 s apart (the box appears
with the first chunk and grows from there), and the
``finish_reason: "stop"`` frame.
"""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
def frame(delta: dict[str, Any], finish: str | None = None) -> str:
return (
"data: "
+ json_dumps(
{
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
}
)
+ "\n\n"
)
if step == "first":
time.sleep(EMPTY_BUBBLE_PREDELAY_S)
for piece in EMPTY_BUBBLE_THINKING_CHUNKS:
yield frame({"reasoning_content": piece})
time.sleep(EMPTY_BUBBLE_THINK_DELAY_S)
yield frame(
{
"role": "assistant",
"tool_calls": [
{
"index": 0,
"id": "call_0",
"type": "function",
"function": {"name": "ls", "arguments": "{}"},
}
],
}
)
time.sleep(0.1)
yield frame({}, "tool_calls")
yield "data: [DONE]\n\n"
return
time.sleep(EMPTY_BUBBLE_TOOL_GAP_S)
for piece in EMPTY_BUBBLE_ANSWER_CHUNKS:
yield frame({"content": piece})
time.sleep(EMPTY_BUBBLE_CONTENT_DELAY_S)
yield frame({}, "stop")
yield "data: [DONE]\n\n"
def compose_answer(body: dict[str, Any]) -> str:
system = _system(body)
user = _user(body)
@@ -5053,6 +5238,23 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 166 (task 03, the no-empty-answer-box suite): the
# deterministic PRE-ANSWER WINDOW turn — request 1 streams
# thinking + the ``ls`` tool call with ZERO content deltas,
# request 2 answers after a ~2 s gap (TODO.md L4 — the empty
# answer box that painted before the first token; see the
# module docstring). Checked BEFORE the plain TOOLS_TRIGGER
# flow (disjoint trigger phrases — the phase-71/72/94
# ordering convention; the trigger needs the ``<tools>``
# section, so deflected turns never hit it).
empty_bubble = _empty_bubble_flow(body)
if empty_bubble is not None:
stream = _empty_bubble_stream(empty_bubble)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 161 (task 04 — the reasoning-bubble story): the
# deterministic MID-ROUND flow — a thinking run, then a
# MID-ROUND ``delta`` (the React-style partial answer — the
+2 -2
View File
@@ -521,7 +521,7 @@ def _expect_desc_cell(
escape hatch, ``makeDescCell`` — set whenever the description is
non-empty, whether or not the display ellipsizes)."""
span = page.locator(f"#folders-tbody tr:nth-child({row_index + 1}) "
"td:nth-child(4) span")
"td:nth-child(4) span.kb-desc-text")
expect(span).to_have_text(full_text)
expect(span).to_have_attribute("title", full_text)
@@ -633,7 +633,7 @@ def test_rag_table_renders_the_stored_voice(
expect(page.locator("#kb-level-summary")).to_have_text(EXPECTED[""])
# The rendered voice is the new one: no Description cell in the
# level shows a forbidden opener (the shape pin, view-side).
for cell in page.locator("#folders-tbody td:nth-child(4) span").all():
for cell in page.locator("#folders-tbody td:nth-child(4) span.kb-desc-text").all():
rendered = cell.text_content() or ""
for prefix in FORBIDDEN_PREFIXES:
assert not rendered.startswith(prefix), rendered
+40 -7
View File
@@ -91,6 +91,33 @@ def _box_height(page: Page, selector: str) -> float:
return box["height"]
def _meta_line_count(page: Page, selector: str) -> int:
"""How many VISUAL LINES the row's children occupy: cluster the
children by their vertical center (a 4px tolerance absorbs the
mixed badge/text/native-control heights on one line) and return the
number of clusters. ONE cluster = one line; a wrap produces two (the
second row of children sits a full line lower). Shape-first on
purpose: the row's own height is content-sized by its tallest child
— since phase 153 that is the ≥44px touch-target floor on
`.doc-copy-path` at ≤640px, so a pixel bound on the row would pin
the control's minimum, not the layout."""
centers = page.evaluate(
"""(sel) => [...document.querySelectorAll(sel + ' > *')]
.map(el => { const b = el.getBoundingClientRect();
return b.height > 0 ? b.top + b.height / 2 : null; })
.filter(v => v !== null)""",
selector,
)
assert centers, f"{selector} rendered no visible children"
clusters: list[list[float]] = []
for c in sorted(centers):
if clusters and c - clusters[-1][-1] <= 4:
clusters[-1].append(c)
else:
clusters.append([c])
return len(clusters)
def _header_heights(page: Page, app_url: str) -> dict[str, float]:
"""Measured heights of the sticky top bar on the three pages."""
heights: dict[str, float] = {}
@@ -201,13 +228,19 @@ def test_viewer_header_content_still_fits(
assert _box_height(page, "#doc-title") < 30 # one line at either size
# Meta row: badges + path present and visible, single line.
# The bound catches a WRAP (a second line would be ≥ ~44px),
# not a pixel-exact line height: the row is content-sized by
# its tallest child — the native type=date input (phase 106
# D8), whose metrics are Chromium/font-dependent and render
# the single line at ~26px on this host (the pre-phase 26px
# pin red-lined on the 26.125px measurement, 2026-09-16).
assert _box_height(page, ".doc-meta") < 34
# SHAPE, not pixels: the pre-phase absolute bound (<34px) went
# stale the moment the row gained a control carrying the ≥44px
# touch-target floor (phase 153: .doc-copy-path min-height 44px
# at ≤640px — PLAN §7.1), which makes a single, correctly-wrapped
# mobile line measure 44px. What the contract actually forbids is
# the row STACKING (a wrap grows the header), so the pin reads the
# geometry directly: every child centered on the row's own center
# = ONE line, whatever the tallest control's height (the same
# shape-first reading the 2026-09-16 note above already moved to).
assert _meta_line_count(page, ".doc-meta") == 1, (
"the meta row must stay on ONE line (badges, path, dates and "
"controls centered on the row's own center)"
)
expect(page.locator(".doc-source-badge", has_text="docs")).to_be_visible()
expect(page.locator(".format-badge", has_text="md")).to_be_visible()
expect(page.locator(".doc-path", has_text="homelab/kubernetes.md")).to_be_visible()
+9 -9
View File
@@ -545,10 +545,10 @@ def test_top_level_lists_sources_with_descriptions(
rows = page.locator("#folders-tbody tr")
expect(rows.nth(0).locator("a.folder-link")).to_have_text(ALPHA)
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text(str(ALPHA_COUNT))
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(ALPHA_ROOT_SUM)
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ALPHA_ROOT_SUM)
expect(rows.nth(1).locator("a.folder-link")).to_have_text(BETA)
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text(str(BETA_COUNT))
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(BETA_ROOT_SUM)
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(BETA_ROOT_SUM)
# The edit affordance is ALWAYS present (a description can be
# CREATED where none is stored) — the row surface, one per row.
expect(page.locator("#folders-tbody .kb-summary-edit")).to_have_count(2)
@@ -607,10 +607,10 @@ def test_drill_into_source(
expect(rows).to_have_count(2)
expect(rows.nth(0).locator("a.folder-link")).to_have_text("one")
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text("2")
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(ONE_SUM)
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ONE_SUM)
expect(rows.nth(1).locator("a.folder-link")).to_have_text("two")
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text("2")
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(TWO_SUM)
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(TWO_SUM)
# The level's direct files: root-note.md — the 6-column contract
# (makeRow): Source | Path | Title | Chunks | Created | Indexed
@@ -718,7 +718,7 @@ def test_edit_folder_description(
# (the textContent re-render — the canned text is gone); the
# always-present Edit button is back.
expect(status).to_have_text("Description updated.")
expect(row.locator("td:nth-child(4) span")).to_have_text(NEW_ALPHA_ONE)
expect(row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(NEW_ALPHA_ONE)
expect(row.locator(".kb-summary-edit")).to_be_visible()
# The server row: the new text AND the manually_edited flag (the
@@ -761,7 +761,7 @@ def test_clear_folder_description(
# The text is gone: the cell empties (the always-present Edit
# button stays — a description can be re-created from the empty
# cell).
expect(row.locator("td:nth-child(4) span")).to_have_text("")
expect(row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text("")
expect(row.locator(".kb-summary-edit")).to_be_visible()
# The SQL assert: no row for the folder (the next KB-changing
@@ -794,7 +794,7 @@ def test_manual_description_survives_a_changed_sync(
rows = page.locator("#folders-tbody tr")
expect(rows).to_have_count(1)
expect(rows.nth(0).locator("a.folder-link")).to_have_text("gamma")
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(GAMMA_SUM)
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(GAMMA_SUM)
# The LEVEL-BLOCK Edit (the level surface — test 4 pins the row
# surface): the static #kb-level-edit button, the source root
@@ -836,7 +836,7 @@ def test_manual_description_survives_a_changed_sync(
expect(
page.locator("#folders-tbody tr")
.nth(0)
.locator("td:nth-child(4) span")
.locator("td:nth-child(4) span.kb-desc-text")
).to_have_text(GAMMA_SUM)
# The new document landed in the tree: back to the top (the
@@ -847,7 +847,7 @@ def test_manual_description_survives_a_changed_sync(
one_row = page.locator("#folders-tbody tr").nth(0)
expect(one_row.locator("a.folder-link")).to_have_text("one")
expect(one_row.locator("td:nth-child(2)")).to_have_text("3")
expect(one_row.locator("td:nth-child(4) span")).to_have_text(ONE_SUM)
expect(one_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(ONE_SUM)
# The SQL asserts: the manual row survived (text + flag), the
# untouched rows were regenerated (the canned text, the flag back
+713
View File
@@ -0,0 +1,713 @@
"""Phase 166 E2E (Playwright, mock-only): NO empty answer box paints
before the first token (owner TODO.md L4 — "There's an empty box where
the LLM response will go that shows up before the LLM responds. It
looks ugly." — the attached image.png: Thinking ▸ / Tool calls (2) ▸ /
an expanded Thinking run, then a bordered, padded, EMPTY rounded box).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_no_empty_box.py -v --no-cov
MOCK-ONLY suite: the gate is the deterministic paced marker flow in
``tests/e2e/mock_llm.py`` (user message contains ``paint the pre-answer
window`` (``EMPTY_BUBBLE_TRIGGER``) **and** the system prompt carries
the ``<tools>`` section of the HIGH prompt): request 1 streams ~1.2 s
of ``reasoning_content`` and one ``ls`` tool call with ZERO content
deltas (the exact mid-turn state of the owner's screenshot), request 2
(id a ``tool``-result) waits ~2.0 s, then answers in three short
chunks. Every visible window is ≥ 1 s by construction (the phase-109
pacing precedent), so the window assertions are race-free — the
pre-delta window from the first thinking frame to the first delta is
~3.2 s.
The fix is the ONE CSS rule ``.msg .bubble:empty { display: none; }``
(LOCKED A1 — hide in CSS, the JS keeps creating the empty bubble as
the phase-158/161 reasoning-container anchor); nothing replaces the
box (A2 — the typing indicator + the reasoning container ARE the
mid-turn feedback, PLAN §7.4), and the same rule covers the restore
and the shared page (A3). The unit pins live in
``tests/unit/test_no_empty_answer_bubble.py``; this file proves the
browser behavior.
KB fixture — the deterministic two-document pair mirrored from
``test_reasoning_bubble.py``: the marker question cosines well past
the mock-calibrated 0.30 E2E threshold plus FTS hits, so the turn is
solidly grounded (HIGH prompt → ``<tools>``).
Test → phase mapping (Playwright Mapping Rule):
1. ``test_no_box_paints_before_the_first_token`` — the defect window
(D1/D3): once ``details.reasoning`` exists (the sync point — the
first thinking frame, which also removes the typing indicator),
``.msg.brain .bubble`` is NOT visible and its box is zero-height
while the thinking + tool frames stream, while the reasoning
container IS open with a live status literal (``Thinking…`` /
``Calling ``) and the composer reads the in-flight state (``Stop``).
The mid-turn screenshot is the owner's before/after record. THE
SETTLE: after ``done`` the SAME locator is visible with ``height >
0`` holding the answer, the container folds, and the phase-130 rows
(chips, actions) sit BELOW the bubble (hiding the bubble never
hides them).
2. ``test_plain_turn_typing_indicator_carries_the_window`` — the
no-thinking control: a plain answer question shows the typing
indicator (dots ⇒ never ``:empty`` ⇒ painted with height > 0)
while in flight, and NO empty bubble ever paints a box. The plain
turn is FAST (the mock streams unpaced), so the proof is an
IN-PAGE sampler on a 10 ms interval installed BEFORE the click:
typing painted ≥ 1 sample, painted empty boxes 0 samples across
every sample of the turn (the answer bubble is created WITH its
first delta in one JS task — nothing can paint between them, and
the sampler is the browser-side evidence of exactly that; A2).
3. ``test_restored_answered_chat_has_no_empty_box`` — the restore
corner (A3): the auto-saved chat re-loaded through ``/?chat=<id>``
renders its answer bubble normally and carries ZERO
``.bubble:empty`` elements.
4. ``test_shared_page_has_no_empty_box`` — the shared-page corner
(A3): the phase-51 save + share flow, a fresh anonymous context on
``/shared/<token>`` — the answer renders, zero ``.bubble:empty``.
5. ``test_empty_persisted_answer_paints_no_box`` — the empty-TEXT
record corner (A3, the shared-page defect half): a saved chat
whose persisted brain text is whitespace (passes the client's
``text.length > 0`` filter, renders through ``renderMarkdown(" ")
=== ""``) paints NO box on ``/?chat=<id>`` AND on its shared
page — the bubble exists in the DOM (the anchor contract) but
zero-height and hidden.
"""
from __future__ import annotations
import hashlib
import re
import time
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
from playwright.sync_api import Browser, BrowserContext, Page, expect
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, GitSource, SavedChat
from e2e.auth_helpers import login
from tests.e2e.mock_llm import EMPTY_BUBBLE_ANSWER, EMPTY_BUBBLE_TRIGGER, embed_text
REPO = Path(__file__).resolve().parents[2]
SCREENSHOTS = REPO / ".agents" / "screenshots" # house convention for visual records
# --------------------------------------------------------------------------
# Fixture documents (deterministic, token-controlled — mirrored from
# test_reasoning_bubble.py so the grounding gate is byte-stable)
# --------------------------------------------------------------------------
SEED_SOURCE = "Homelab"
SEED_PATH = "aws-route53.md"
READ_SOURCE = "Deployments"
READ_PATH = "example-record-file.json"
ROUTE53_CONTENT = (
"# AWS Route 53 Notes\n\n"
"## Record file\n\n"
+ (
"The aws route53 hosted zone for reeselink keeps every record in "
"reseelink.json — the exact JSON shape of reeselink.json is "
"documented in example-record-file.json.\n"
)
* 10
+ "\n## Sync job\n\n"
"A cron job pushes reeselink.json to the aws route53 hosted zone "
"every fifteen minutes; the diff is applied through the route53 api.\n"
)
RECORD_FILE_CONTENT = (
'{ "version": 3, "comment": "ReeseLink hosted zone records — the exact '
"JSON shape of reeselink.json\",\n"
' "hosted_zone_id": "Z0RESEELINK01",\n'
' "record_sets": [\n'
' { "name": "www.reeselink.example", "type": "A", "ttl": 300,\n'
' "resource_records": [ { "value": "10.0.0.20" } ] },\n'
' { "name": "api.reeselink.example", "type": "CNAME", "ttl": 300,\n'
' "resource_records": [ { "value": "www.reeselink.example" } ] }\n'
" ]\n"
"}\n"
)
#: The phase-166 marker question: ``EMPTY_BUBBLE_TRIGGER`` + the KB
#: topic — the mock-calibrated cosine keeps the turn grounded (HIGH
#: prompt → ``<tools>`` → the deterministic pre-answer-window flow).
EMPTY_WINDOW_QUESTION = (
f"{EMPTY_BUBBLE_TRIGGER.capitalize()}: what is the exact JSON shape "
"of reeselink.json for my aws route53 hosted zone?"
)
#: The no-thinking control (NO trigger — the generic ``compose_answer``
#: path: no thinking frames, no tool frames; the typing indicator is
#: the whole pre-answer state).
PLAIN_ANSWER_QUESTION = (
"What is the exact JSON shape of reeselink.json for my aws "
"route53 hosted zone?"
)
#: The live status literals of the reasoning container (phase 161 D6)
#: — the thinking/tool state's visible text while the box stays hidden.
LIVE_STATUS_RE = re.compile(r"^(Thinking…|Calling )")
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, mirrored from test_reasoning_bubble.py)
# --------------------------------------------------------------------------
def _seed(db: Session) -> None:
"""The two-document pair (see the module docstring), plus the
phase-94 registry rows: both sources registered, ``Deployments``
FIRST (the operator's ``BOR_GIT_SOURCES`` is ignored — a non-empty
table wins). COMMIT between the inserts (not flush): ``added_at``
is ``server_default now()`` and the tie-break is the random uuid
``id`` — one-transaction rows order nondeterministically."""
db.add(GitSource(url=READ_SOURCE, kind="local"))
db.commit()
db.add(GitSource(url=SEED_SOURCE, kind="local"))
md = Document(
source=SEED_SOURCE,
path=SEED_PATH,
full_path=f"/tmp/{SEED_PATH}",
title="AWS Route 53 Notes",
content=ROUTE53_CONTENT,
content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2024, 6, 15, tzinfo=UTC),
)
db.add(md)
db.flush()
# One chunk carrying the mock's own embedding → genuine token
# overlap between the marker question and this document.
db.add(
Chunk(
document_id=md.id,
position=0,
content=ROUTE53_CONTENT,
embedding=embed_text(ROUTE53_CONTENT),
)
)
db.add(
Document(
source=READ_SOURCE,
path=READ_PATH,
full_path=f"/tmp/{READ_PATH}",
title="Example Record File",
content=RECORD_FILE_CONTENT,
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2024, 6, 15, tzinfo=UTC),
)
)
def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
"""Truncate the KB (plus the prompt-shaping tables and the
phase-55 auto-save rows), then re-seed — the E2E isolation
pattern. ``steering_notes`` / ``kb_overview`` are truncated too,
so the HIGH prompt is exactly ``<relevance>`` + ``<documents>`` +
``<tools>`` regardless of leftovers — byte-stable prompts,
byte-stable answers."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, "
"steering_notes, kb_overview, saved_chats, git_sources"
)
)
db.commit()
if seed is not None:
seed(db)
db.commit()
# --------------------------------------------------------------------------
# Page helpers
# --------------------------------------------------------------------------
#: The defect's visible signature: a ``.msg .bubble`` element that is
#: EMPTY (child-less — exactly what ``addMessage("brain","")``
#: produces) yet still PAINTS A BOX (height > 0). The owner's
#: image.png is this number being ≥ 1; phase 166 makes it 0 at every
#: sample, live, restored, and shared (the same stylesheet on the
#: shared page — LOCKED A3).
PAINTED_EMPTY_BOXES_JS = """
() => [...document.querySelectorAll('#messages .msg .bubble')]
.filter((el) => el.matches(':empty') && el.getBoundingClientRect().height > 0)
.length
"""
#: The IN-PAGE sampler (the no-thinking control's race-free proof):
#: a 10 ms interval installed BEFORE the submit counts (a) samples
#: where ``#typing-indicator .bubble`` painted a box and (b) samples
#: where any EMPTY ``.msg .bubble`` painted a box. The plain turn can
#: settle in ~100 ms — too fast for out-of-band polling to bracket
#: reliably; the sampler sees every 10 ms slice of the page from the
#: click to the settle (and a few past it) from INSIDE the page.
SAMPLER_ON_JS = """
() => {
window.__p166 = { samples: 0, typingPainted: 0, paintedEmpty: 0 };
window.__p166.timer = setInterval(() => {
const s = window.__p166;
s.samples += 1;
const t = document.querySelector('#typing-indicator .bubble');
if (t && t.getBoundingClientRect().height > 0) s.typingPainted += 1;
const empties = [...document.querySelectorAll('#messages .msg .bubble')]
.filter((el) => el.matches(':empty')
&& el.getBoundingClientRect().height > 0);
if (empties.length > 0) s.paintedEmpty += 1;
}, 10);
}
"""
SAMPLER_OFF_JS = """
() => {
clearInterval(window.__p166.timer);
const { samples, typingPainted, paintedEmpty } = window.__p166;
return { samples, typingPainted, paintedEmpty };
}
"""
def _bubble_height(locator: Any) -> float:
"""The painted box height of one bubble — ``display: none`` (the
phase-166 rule) yields exactly 0."""
height: float = locator.evaluate("el => el.getBoundingClientRect().height")
return height
def _submit(page: Page, question: str) -> None:
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button
recovered (phase 48 — the in-flight button is the enabled Stop
control, so the label assertion carries the settle wait)."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# --------------------------------------------------------------------------
# Phase-51 save + share flow (the test_reasoning_bubble.py pattern — the
# auto-saved row is shared from the chat page; the row is deleted in a
# finally)
# --------------------------------------------------------------------------
def _admin_cookies(page: Page) -> dict[str, str]:
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55 — auto-saves are SILENT,
so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so poll the admin
list until the row with the conversation's auto-title appears
with the expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the admin context
(headless Chromium requires the grant before ``writeText``
resolves — without it the inline-link fallback fires)."""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _click_share_and_assert_status(page: Page, app_url: str) -> None:
"""Press the chat page's Share pill and pin the outcome on the
live region (the assertion branches on the API's availability —
the test_share_chat.py convention)."""
page.locator("#share-chat-btn").click()
if page.evaluate("() => !!navigator.clipboard"):
expect(page.locator("#send-status")).to_have_text(
"Share link copied.", timeout=15_000
)
else:
expect(page.locator("#send-status")).to_have_text(
"Share link ready — copy it from the field.", timeout=15_000
)
field = page.locator(".share-link-fallback")
expect(field).to_be_visible()
href = field.get_attribute("href")
assert href is not None
assert href.startswith(app_url)
assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url))
# --------------------------------------------------------------------------
# 1. The defect window: thinking + tool frames stream and NO answer
# box paints — the box appears WITH the first delta (D1/D3)
# --------------------------------------------------------------------------
def test_no_box_paints_before_the_first_token(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
_submit(page, EMPTY_WINDOW_QUESTION)
# THE SYNC POINT: details.reasoning exists only after the FIRST
# thinking frame (ensureReasoningWrap runs in the same handler
# tick as addMessage("brain", "") and removeTyping()) — so from
# here on the ONLY .msg.brain .bubble is the empty answer bubble,
# and the mock's pacing keeps the window open ≥ 1 s of thinking +
# the tool round + the ~2 s gap (≈ 3.2 s total before the first
# delta — race-free by construction).
expect(page.locator("#messages .msg.brain details.reasoning")).to_be_visible()
# THE DEFECT WINDOW: the answer bubble exists in the DOM (the
# phase-158/161 reasoning anchor — LOCKED A1 keeps it) but paints
# NO BOX: not visible AND zero-height (the owner's image.png box
# is gone). The typing indicator is gone too (the live block
# replaced the dots), so the single strict match doubles as the
# no-stray-bubble pin.
bubble = page.locator("#messages .msg.brain .bubble")
expect(bubble).not_to_be_visible()
assert _bubble_height(bubble) == 0, (
"the pre-delta answer bubble must paint zero box (the phase-166 defect)"
)
assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0, (
"no empty .bubble may paint a box mid-turn (live window)"
)
# §7.4 — the mid-turn feedback DOES paint: the reasoning container
# is OPEN with a thinking run and its live status literal
# (Thinking… / Calling <name>…), and the composer carries the
# in-flight state (the phase-48 Stop label).
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_be_visible()
expect(
page.locator("#messages .msg.brain details.reasoning .thinking-text")
).to_be_visible()
expect(
page.locator("#messages .msg.brain details.reasoning .reasoning-status")
).to_have_text(LIVE_STATUS_RE)
expect(page.locator("#send-label")).to_have_text("Stop")
# The owner's before/after record (image.png's twin): the open
# reasoning run with NO empty box under it.
SCREENSHOTS.mkdir(parents=True, exist_ok=True)
shot = SCREENSHOTS / "166_pre_answer_window_1280.png"
page.screenshot(path=str(shot))
assert shot.exists() and shot.stat().st_size > 0
# THE SETTLE: the box appears at (and after) the first token. The
# SAME locator is now visible with a real box and holds the
# byte-stable answer (the three chunks of the mock's request 2).
_wait_settled(page)
expect(bubble).to_be_visible()
assert _bubble_height(bubble) > 0, "the answer box must paint with the first token"
expect(bubble).to_contain_text(EMPTY_BUBBLE_ANSWER)
# The container folded at done (phase 161 D2 — the settle fold).
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
# Phase 130 ordering survives the hidden box: the chips row and
# the actions row are SIBLING rows of .msg-body BELOW the bubble
# (hiding an empty bubble never hides a chip or an action).
order = page.evaluate(
"""
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps[wraps.length - 1];
const body = wrap ? wrap.querySelector('.msg-body') : null;
const kids = body ? [...body.children] : [];
return {
bubbleIdx: kids.findIndex((k) => k.classList.contains('bubble')),
metaIdxs: kids
.map((k, i) => (k.classList.contains('msg-meta') ? i : -1))
.filter((i) => i >= 0),
};
}
"""
)
assert order["bubbleIdx"] >= 0, "the settled turn keeps its answer bubble"
assert order["metaIdxs"], "the settled turn keeps its .msg-meta rows"
assert all(idx > order["bubbleIdx"] for idx in order["metaIdxs"]), (
f"every .msg-meta row sits BELOW the bubble: {order}"
)
expect(
page.locator("#messages .msg.brain .msg-meta.msg-actions")
).to_have_count(1)
# --------------------------------------------------------------------------
# 2. The no-thinking control: the typing indicator (dots ⇒ never
# :empty) carries the pre-answer window; no empty box at any point
# --------------------------------------------------------------------------
def test_plain_turn_typing_indicator_carries_the_window(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
# The plain turn is FAST (the mock streams unpaced — the whole
# turn can settle in ~100 ms), so the proof runs IN-PAGE: the
# sampler counts 10 ms slices from BEFORE the click to a few
# hundred ms past the settle. The typing indicator lands
# synchronously in the click handler (setUiState(thinking) →
# addTyping(): three dot <span>s ⇒ never :empty ⇒ painted) and
# the answer bubble is created WITH its first delta in one JS
# task — every slice of the turn is accounted for (locked A2:
# this affordance IS the pre-answer window).
page.evaluate(SAMPLER_ON_JS)
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
# A few extra samples past the settle (the painted answer bubble
# is non-empty — the sampler must still read zero painted boxes).
time.sleep(0.3)
stats = page.evaluate(SAMPLER_OFF_JS)
assert stats["samples"] >= 5, f"the sampler never ran: {stats}"
assert stats["typingPainted"] >= 1, (
"the typing indicator was never observed painting its dots box "
f"while the turn was in flight: {stats}"
)
assert stats["paintedEmpty"] == 0, (
f"an empty answer box painted during the plain turn: {stats}"
)
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("")
assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
# --------------------------------------------------------------------------
# 3. The restore corner: an auto-saved chat re-loaded through
# /?chat=<id> renders its bubble normally, zero .bubble:empty
# --------------------------------------------------------------------------
def test_restored_answered_chat_has_no_empty_box(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(PLAIN_ANSWER_QUESTION))
try:
# The phase-50 restore: /?chat=<id> boots into the saved
# conversation through renderStoredMessage — the answered turn
# renders its bubble normally (non-empty, painted).
page.goto(app_url + f"/?chat={row['id']}")
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_be_visible()
expect(bubble).not_to_have_text("")
assert _bubble_height(bubble) > 0
# ZERO child-less bubbles exist in the restored DOM — the
# restore creates no empty anchor (the anchor is a LIVE-turn
# construct), so nothing even needs hiding.
assert page.locator("#messages .msg .bubble:empty").count() == 0
assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
finally:
_delete_chat(app_url, cookies, row["id"])
# --------------------------------------------------------------------------
# 4. The shared-page corner (LOCKED A3): the phase-51 share flow, a
# fresh anonymous context — zero .bubble:empty, the answer painted
# --------------------------------------------------------------------------
def test_shared_page_has_no_empty_box(
page: Page,
browser: Browser,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
_reset_db(_seed)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
# The phase-51 flow: the conversation is AUTO-SAVED by the time
# the answer settles (phase 55) — the Share click shares the
# linked row.
cookies = _admin_cookies(page)
_grant_clipboard(page, app_url)
_wait_saved_row(app_url, cookies, _auto_title(PLAIN_ANSWER_QUESTION))
share = page.locator("#share-chat-btn")
expect(share).to_be_visible()
_click_share_and_assert_status(page, app_url)
row = _find_row(_chats(app_url, cookies), _auto_title(PLAIN_ANSWER_QUESTION))
assert row is not None and row.get("share_url"), (
"the shared row must carry a share_url"
)
share_url: str = row["share_url"]
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
created: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
# A FRESH context: a separate session with no cookies — the
# guest's only credential is the token in the URL.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
# The shared answer bubble renders normally (non-empty,
# painted) — and shared.js builds the identical
# .msg/.msg-body/.bubble structure on the same stylesheet, so
# ZERO child-less bubbles exist here either (locked A3).
bubble = anon.locator(".msg.brain .bubble").first
expect(bubble).to_be_visible()
expect(bubble).not_to_have_text("")
assert _bubble_height(bubble) > 0
assert anon.locator("#messages .msg .bubble:empty").count() == 0
assert anon.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
finally:
if anon_ctx is not None:
anon_ctx.close()
if created:
_delete_chat(app_url, cookies, created)
# --------------------------------------------------------------------------
# 5. The empty-TEXT record corner (locked A3): a persisted brain answer
# of " " passes the client's length>0 filter and renders through
# renderMarkdown(" ") === "" — the bubble lands CHILD-LESS in the
# DOM and the SAME rule hides it on /?chat=<id> AND the shared page
# --------------------------------------------------------------------------
def test_empty_persisted_answer_paints_no_box(
page: Page,
browser: Browser,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
# The crafted record: the whitespace brain text passes the
# restore's `text.length > 0` filter (a real empty string would be
# filtered out before rendering), and renderMarkdown(" ") is ""
# (unit-pinned) — so the bubble renders CHILD-LESS: exactly the
# :empty shape the phase-166 rule suppresses. share_token rides
# the same row so the shared page takes the identical corner.
token = uuid.uuid4()
with SessionLocal() as db:
row = SavedChat(
title="Phase 166 empty-answer corner",
messages=[
{"who": "user", "text": "What is the exact JSON shape?"},
{"who": "brain", "text": " "},
],
share_token=token,
)
db.add(row)
db.commit()
chat_id = str(row.id)
anon_ctx: BrowserContext | None = None
try:
# The restore: the user record renders; the empty brain
# bubble EXISTS in the DOM (the same structure, no restore
# branch was added) but paints NO BOX.
page.goto(app_url + f"/?chat={chat_id}")
expect(page.locator(".msg.user .bubble").first).to_contain_text(
"What is the exact JSON shape?"
)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_have_count(1)
expect(bubble).not_to_be_visible()
assert _bubble_height(bubble) == 0, (
"the empty persisted answer must paint zero box (restore — A3)"
)
assert page.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
# The shared page: the same record, a fresh anonymous context
# — same zero-box outcome (shared.js renders the identical
# structure on the same stylesheet).
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + f"/shared/{token}")
expect(anon.locator(".msg.user .bubble").first).to_contain_text(
"What is the exact JSON shape?"
)
anon_bubble = anon.locator(".msg.brain .bubble").last
expect(anon_bubble).to_have_count(1)
expect(anon_bubble).not_to_be_visible()
assert (
anon_bubble.evaluate("el => el.getBoundingClientRect().height") == 0
), "the empty persisted answer must paint zero box (shared page — A3)"
assert anon.evaluate(PAINTED_EMPTY_BOXES_JS) == 0
finally:
if anon_ctx is not None:
anon_ctx.close()
with SessionLocal() as db:
db.execute(
text("DELETE FROM saved_chats WHERE id = :id"), {"id": chat_id}
)
db.commit()
+39 -13
View File
@@ -270,12 +270,28 @@ def test_no_autoscroll_during_long_answer(
# Phase 48: in-flight detection rides the Stop label (the button is
# the enabled Stop control while a turn runs — it is no longer
# disabled, so the disabled-state proxy is gone).
#
# WHAT IS MEASURED (2026-10-08): the VISUAL position of the text the
# user is reading — the first answer bubble's top relative to the
# viewport — not the raw scrollY. Chromium's native scroll anchoring
# legitimately compensates scrollY when layout above the viewport
# changes (at settle the Edit/Retry buttons re-target to the newest
# turn, so the FIRST turn's meta row collapses 44px→0): the reader
# sees nothing move, but a bare scrollY-constancy check would flag
# the compensating adjustment itself. A real up-hop (the regression
# this suite pins — the old block:"end" submit jump, programmatic
# follow-scrolls) moves the marker just the same, so the contract is
# asserted strictly, and correctly, on what the user perceives.
marker_top = """() => {
const el = document.querySelector('.msg.brain .bubble');
return el ? el.getBoundingClientRect().top : null;
}"""
samples: list[float] = []
mid_stream = 0
deadline = time.monotonic() + 40
while time.monotonic() < deadline:
time.sleep(0.25)
samples.append(scroll_state(page)["y"])
samples.append(page.evaluate(marker_top))
if page.locator("#send-label").inner_text() == "Stop":
mid_stream += 1
if len(samples) >= 10 and page.locator("#send-label").inner_text() == "Send":
@@ -283,7 +299,7 @@ def test_no_autoscroll_during_long_answer(
# queued behind the stream may move the page either).
for _ in range(3):
time.sleep(0.3)
samples.append(scroll_state(page)["y"])
samples.append(page.evaluate(marker_top))
break
else:
raise AssertionError("the long turn did not settle within 40s")
@@ -291,8 +307,8 @@ def test_no_autoscroll_during_long_answer(
spread = max(samples) - min(samples)
assert spread <= STABLE_PX, (
f"the viewport moved {spread:.1f}px while the user was scrolled up "
"(no-reply-autoscroll contract)"
f"the page moved {spread:.1f}px under the user's reading position "
"while they were scrolled up (no-reply-autoscroll contract)"
)
# The whole answer still landed (off-screen below — by design).
@@ -318,13 +334,17 @@ def test_no_autoscroll_during_thinking(
# The thinking turn: the submit reveals the user's message (the kept
# app scroll) ...
submit(page, THINKING_QUESTION)
details = page.locator(".msg.brain").last.locator("details.thinking")
# 690fd98 refinement: ONE details.reasoning container per turn (the
# live per-run blocks are plain divs that never fold); the container
# is created OPEN and auto-folds on settle — same view-stability
# contract phase 17 pinned, one level up.
details = page.locator(".msg.brain").last.locator("details.reasoning")
details.wait_for(state="attached", timeout=10_000)
expect(details).to_have_attribute("open", "") # created open (phase 17)
# ... and, once the reasoning stream is clearly running ...
page.wait_for_function(
"() => { const el = document.querySelector('details.thinking .thinking-text');"
" return !!el && el.innerText.length > 300; }",
"() => { const els = document.querySelectorAll('details.reasoning .thinking-text');"
" let n = 0; els.forEach((e) => { n += e.innerText.length; }); return n > 300; }",
timeout=30_000,
)
# ... let the submit's smooth reveal settle, then the user goes up.
@@ -339,7 +359,7 @@ def test_no_autoscroll_during_thinking(
time.sleep(0.3)
state = page.evaluate(
"""() => {
const block = document.querySelector('details.thinking');
const block = document.querySelector('details.reasoning');
const wrap = block ? block.closest('.msg.brain') : null;
const el = wrap ? wrap.querySelector('.bubble') : null;
return { y: window.scrollY,
@@ -366,7 +386,9 @@ def test_no_autoscroll_during_thinking(
# the point of the story).
wait_settled(page)
assert abs(scroll_state(page)["y"]) <= STABLE_PX
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
# Settled: the container auto-folded; its last run holds the tail.
assert not details.get_attribute("open")
expect(details.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
@@ -484,9 +506,12 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
# (phase 17: open while streaming, closed from the first delta on).
submit(page, THINKING_QUESTION)
wait_settled(page)
details = page.locator(".msg.brain").last.locator("details.thinking")
# 690fd98 refinement: settled turns carry ONE details.reasoning
# container (auto-collapsed by settle, phase-17 contract) holding the
# plain per-run blocks — the per-run details.thinking is gone live.
details = page.locator(".msg.brain").last.locator("details.reasoning")
expect(details).not_to_have_attribute("open")
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
expect(details.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
# Persistence: four messages, the thinking text stored raw — and
@@ -508,9 +533,10 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
expect(page.locator(".msg.brain .bubble")).to_have_count(2)
expect(page.locator(".msg.brain .bubble").first).to_contain_text(LONG_ANSWER_END)
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
restored = page.locator(".msg.brain").last.locator("details.thinking")
# Restore writes the SAME container shape (segments-based restore).
restored = page.locator(".msg.brain").last.locator("details.reasoning")
expect(restored).not_to_have_attribute("open")
expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
expect(restored.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
wait_settled(page)
+13 -6
View File
@@ -651,12 +651,19 @@ def test_scoped_scope_survives_save_restore(
page.goto(app_url + f"/?chat={row['id']}")
_assert_scoped_line(_tool_line(page))
# Phase 117 (D3): a restored turn renders FOLDED — one compact
# "Tool call(s) (N)" line (the disclosure shape is unchanged by
# this phase: the `open` attribute is ABSENT).
disclosure = page.locator(".msg.brain .tool-calls-disclosure").last
expect(disclosure).not_to_have_attribute("open", timeout=30_000)
expect(disclosure.locator("summary")).to_have_text(
# Phase 117 (D3), re-pinned for the phase-161 D5 refinement (the
# owner's 2026-10-06 shape, shipped in 690fd98): a restored turn
# renders FOLDED inside the ONE details.reasoning container — the
# container is the ONLY disclosure (zero nested <details>), the
# run itself is a plain div whose label is the
# .tool-calls-summary SPAN. The compact "Tool call (1)" line the
# phase-117 pin named is unchanged; only the element carrying it
# moved (this phase's scope — the scoped line — is asserted above).
container = page.locator(".msg.brain details.reasoning").last
expect(container).not_to_have_attribute("open", timeout=30_000)
expect(container.locator("details")).to_have_count(0)
run = page.locator(".msg.brain .tool-calls-disclosure").last
expect(run.locator(".tool-calls-summary")).to_have_text(
"Tool call (1)", timeout=30_000
)
+17 -6
View File
@@ -340,12 +340,23 @@ def test_anonymous_shared_view(
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
assert bubble.inner_text() == answer, "the shared answer must match the admin's"
# The thinking block exists and is RESTORED COLLAPSED (the
# phase-17 restore convention — a closed <details> has no
# `open` attribute).
think = anon.locator("details.thinking")
expect(think).to_have_count(1)
expect(think.first).not_to_have_attribute("open")
# The reasoning timeline exists and renders COLLAPSED (the
# phase-161 D5 convention, updated by the 2026-10-06 owner
# refinement shipped in 690fd98 — the saved turn carries
# `segments`, so shared.js's addSegments wraps the per-run
# timeline in the ONE details.reasoning container; a closed
# <details> has no `open` attribute, and the inner runs are
# plain divs — NEVER a nested <details>).
cont = anon.locator("details.reasoning")
expect(cont).to_have_count(1)
expect(cont.first).not_to_have_attribute("open")
expect(cont.locator("details")).to_have_count(0)
# The thinking RUN itself is present inside the collapsed
# container (the plain-div shape — the guest sees the same
# timeline the admin saw, just folded at rest).
runs = cont.locator("div.thinking")
expect(runs).to_have_count(1)
expect(runs.first.locator(".thinking-text")).not_to_be_empty()
# Source chips: ZERO — the shared turn read nothing, so
# (phase 119, LOCKED A1) its done sources are empty and the
+8 -4
View File
@@ -366,9 +366,11 @@ def test_nav_switch_before_first_token_completes(
# running — NO content frame has landed yet. (The .msg.brain bubble
# element exists from turn start with its thinking block — the
# pre-token state is "no content text", not "no bubble element".)
thinking = page.locator(".msg.brain").last.locator("details.thinking")
# 690fd98 refinement: the live thinking block is a plain div.thinking
# RUN (never folding mid-turn) inside the details.reasoning container.
thinking = page.locator(".msg.brain").last.locator("div.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(
expect(thinking.locator(".thinking-text").last).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48
@@ -444,9 +446,11 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
# which outruns Playwright's 5s assertion auto-wait on a loaded host
# (the wait started at the FIRST thinking frame — the pre-existing
# race, phase 55 task 02 fix).
thinking = page.locator(".msg.brain").last.locator("details.thinking")
# 690fd98 refinement: the live thinking block is a plain div.thinking
# RUN (never folding mid-turn) inside the details.reasoning container.
thinking = page.locator(".msg.brain").last.locator("div.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(
expect(thinking.locator(".thinking-text").last).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48 —
+9 -9
View File
@@ -862,7 +862,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
src_row = page.locator("#folders-tbody tr")
expect(src_row.locator("a.folder-link")).to_have_text(SOURCE)
expect(src_row.locator("td:nth-child(2)")).to_have_text(str(N_FILES))
expect(src_row.locator("td:nth-child(4) span")).to_have_text(SUM_ROOT)
expect(src_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ROOT)
expect(page.locator(".kb-summary-pending")).to_have_count(0)
# The gap: delete the bravo row AND the source-root row directly
@@ -877,7 +877,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
page.click("#nav-sources")
_wait_top_level(page)
src_row = page.locator("#folders-tbody tr")
s_span = src_row.locator("td:nth-child(4) span")
s_span = src_row.locator("td:nth-child(4) span.kb-desc-text")
expect(s_span).to_have_text(PENDING_COPY)
# Phase 99 (task 01): the marker toggles onto the cell's base
# .kb-desc-text span (the one-line clamp) — the class pair, exact.
@@ -900,13 +900,13 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
bravo_row = rows.nth(1)
expect(alpha_row.locator("a.folder-link")).to_have_text(FOLDER_A)
# The intact folder: the stored line, never the marker.
expect(alpha_row.locator("td:nth-child(4) span")).to_have_text(SUM_ALPHA)
expect(alpha_row.locator("td:nth-child(4) span")).not_to_have_class(
expect(alpha_row.locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ALPHA)
expect(alpha_row.locator("td:nth-child(4) span.kb-desc-text")).not_to_have_class(
"kb-summary-pending"
)
# The affected folder: the marker (copy + class + title) — the
# class pair with the phase-99 .kb-desc-text base span, exact.
b_span = bravo_row.locator("td:nth-child(4) span")
b_span = bravo_row.locator("td:nth-child(4) span.kb-desc-text")
expect(b_span).to_have_text(PENDING_COPY)
expect(b_span).to_have_class("kb-desc-text kb-summary-pending")
expect(b_span).to_have_attribute("title", PENDING_TITLE)
@@ -929,7 +929,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
expect(bravo_row.locator(".kb-summary-status")).to_have_text(
"Description updated."
)
b_span = bravo_row.locator("td:nth-child(4) span")
b_span = bravo_row.locator("td:nth-child(4) span.kb-desc-text")
expect(b_span).to_have_text(MANUAL_BRavo)
expect(b_span).not_to_have_class("kb-summary-pending")
@@ -962,14 +962,14 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
expect(page.locator("#kb-level-title")).to_have_text(SOURCE)
expect(page.locator("#kb-level-summary")).to_have_text(SUM_ROOT)
rows = page.locator("#folders-tbody tr")
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(SUM_ALPHA)
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(MANUAL_BRavo)
expect(rows.nth(0).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(SUM_ALPHA)
expect(rows.nth(1).locator("td:nth-child(4) span.kb-desc-text")).to_have_text(MANUAL_BRavo)
expect(page.locator(".kb-summary-pending")).to_have_count(0)
# And at the top level: the source row's cell carries the
# regenerated root line, no marker.
page.locator("#kb-crumb a.kb-crumb-link").nth(0).click()
src_row = page.locator("#folders-tbody tr")
s_span = src_row.locator("td:nth-child(4) span")
s_span = src_row.locator("td:nth-child(4) span.kb-desc-text")
expect(s_span).to_have_text(SUM_ROOT)
expect(s_span).not_to_have_class("kb-summary-pending")
expect(page.locator(".kb-summary-pending")).to_have_count(0)
+43 -28
View File
@@ -36,8 +36,13 @@ thinking frame) is REPLACED by the per-run timeline (D1/D5): each
contiguous thinking run streams into its OWN block, and the first frame
of a new kind closes the previous run. The SSE stream is unchanged, so
this turn renders ONE ``.tool-calls-disclosure`` + TWO ``.thinking``
blocks (arrival order, all ABOVE the answer bubble — D2), open in their
live windows and folded at rest. The loader pins
blocks (arrival order) — wrapped since phase 161 in the ONE
``details.reasoning`` container ABOVE the answer bubble (D1), the
container open from creation until the settle fold (D2). The
2026-10-06 D5 refinement (owner, shipped in ``690fd98``): the per-run
blocks are PLAIN DIVS — always visible inside the container, never
themselves collapsible (the container is the ONE disclosure). The
loader pins
(``test_loader_visible_from_send_through_the_tool_gap``,
``test_loader_a11y_and_reduced_motion``) stay unchanged — the
never-stale loader contract (D16) is intact.
@@ -53,15 +58,17 @@ context, ``_reset_db`` reseeds the KB deterministically):
the phase-87 elapsed counter in the tool gap, and ``#send-status``
carries a state text (not empty) throughout.
* ``test_thinking_runs_open_in_live_window_with_visible_loader`` —
THE reported symptom's state, re-pinned for phase 158 (D5 replaces
phase-109 D15 — a NEW block instead of a re-open): the post-delta
thinking frames open a per-run ``.thinking`` block (exactly one
block, open + non-empty ``.thinking-text`` while the bubble already
carries call 1's content — the run starts AFTER the first delta) with
the loader STILL visible (the frozen window is gone — the user sees
the new thinking in its own live block); the terminal state is clean
(loader hidden, the scratchpad holds TWO ``.thinking`` blocks —
EACH carrying its own round's text — BOTH folded at rest, the bubble
THE reported symptom's state, re-pinned for phase 158 + the phase-161
D5 refinement (a NEW plain-div run instead of a re-open; the runs
never fold, the ONE container does): while the
``details.reasoning`` container is OPEN, exactly one ``.thinking``
run is VISIBLE with non-empty ``.thinking-text`` while the bubble
already carries call 1's content — the run starts AFTER the first
delta — with the loader STILL visible (the frozen window is gone —
the user sees the new thinking in its own live run); the terminal
state is clean (loader hidden, the container folded at the settle —
zero nested ``<details>`` inside it — the scratchpad holds TWO
``.thinking`` runs EACH carrying its own round's text, the bubble
carries BOTH answers, the send button reads "Send" not "Stop",
``#send-status`` back to the idle shape).
* ``test_loader_a11y_and_reduced_motion`` — after a full turn: the
@@ -223,25 +230,32 @@ def test_thinking_runs_open_in_live_window_with_visible_loader(
loader = page.locator("#turn-loader")
# THE reported symptom's state (phase 158 D5 replaces phase-109
# D15 — the post-delta thinking frames open a NEW per-run block
# instead of re-opening the first delta's block): exactly ONE
# ``.thinking`` block exists, it is OPEN with non-empty text WHILE
# the answer bubble already carries call 1's content — the first
# thinking run starts AFTER the first delta (the owner's "thinking
# after responding" repro), and the loader is STILL visible (the
# frozen window is gone — the user sees the new thinking in its
# own live block).
# D15 — the post-delta thinking frames open a NEW per-run block,
# and the phase-161 D5 refinement (owner 2026-10-06, 690fd98) made
# the runs PLAIN DIVS inside the ONE details.reasoning container —
# always visible, the CONTAINER is the disclosure): while the
# container is OPEN, exactly ONE ``.thinking`` run exists and
# paints non-empty text WHILE the answer bubble already carries
# call 1's content — the first thinking run starts AFTER the first
# delta (the owner's "thinking after responding" repro), and the
# loader is STILL visible (the frozen window is gone — the user
# sees the new thinking in its own live run).
page.wait_for_function(
"""() => {
const b = document.querySelectorAll('.msg.brain .thinking');
const cont = document.querySelector(
'.msg.brain details.reasoning[open]');
if (!cont) return false;
const b = cont.querySelectorAll('.thinking');
const t = b.length === 1 ? b[0].querySelector('.thinking-text') : null;
return b.length === 1 && !!b[0].open && !!t
&& t.textContent.trim().length > 0;
return b.length === 1 && !!t && t.textContent.trim().length > 0;
}""",
timeout=30_000,
)
expect(blocks).to_have_count(1)
expect(blocks).to_have_attribute("open", "")
expect(blocks.first).to_be_visible()
expect(
page.locator(".msg.brain details.reasoning").first
).to_have_attribute("open", "")
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
# The run is genuinely AFTER a delta: call 1's content is already
# in the bubble ...
@@ -265,11 +279,12 @@ def test_thinking_runs_open_in_live_window_with_visible_loader(
# reasoning streams — separate blocks, not one lump) ...
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
expect(blocks.nth(1).locator(".thinking-text")).to_contain_text(SENTINEL_FINAL_THOUGHT)
# ... and BOTH runs are FOLDED at rest (D5 — the per-run
# fold-at-rest; the phase-109 symptom — the frozen chat — stays
# fixed: a run is open in its live window, never re-opened after
# done) ...
expect(page.locator(".msg.brain .thinking[open]")).to_have_count(0)
# ... and the ONE container FOLDED at the settle (phase 161 D2 —
# the container is the ONLY disclosure; the inner runs are plain
# divs, never collapsible: zero nested <details>; the phase-109
# symptom — the frozen chat — stays fixed) ...
expect(page.locator(".msg.brain details.reasoning[open]")).to_have_count(0)
expect(page.locator(".msg.brain details.reasoning details")).to_have_count(0)
# ... the bubble carries BOTH answers (call 1 + call 2) ...
expect(bubble).to_contain_text(SENTINEL_FIRST)
expect(bubble).to_contain_text(SENTINEL_FINAL)