Files
brain-of-reese/tests/e2e/test_reasoning_bubble.py
T
ducoterra 051f62deb7
Build and Push Containers / build-and-push-app (push) Successful in 1m55s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 161_reasoning_bubble
All gates verified. Final check — AGENTS.md rules against the project (commands, gates, protocols) — the quick-reference commands all exist and the rules (PLAN §2/§7/§9 references, phase protocol, No CDN, Debugpy, git gates) accurately describe the project; the reasoning bubble is a pure UI change with no new commands or env vars, so no AGENTS.md change is needed.

**Verification complete — full results:**

- **Verified** (implemented by prior task passes, all in working tree): `createReasoningWrap`/`ensureReasoningWrap` live container (D1), `settleReasoning` fold at the 3 settle sites + not at delta (D2), 320px `.reasoning-body` window + pinned-tail follow (D3), zero persistence change (D4), collapsed restore + shared-page parity with D6 step counts, `styles.css` family treatment + reduced-motion still, mock `REASONING_MIDROUND_TRIGGER` flow, 6-test story suite, phase-158 pin updates
- **Full suite:** `uv run pytest` → **3738 passed, 0 failed** · `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99% (>90%)** · `uv run ruff check .` → all checks passed · `uv run pyright` → **0 errors, 0 warnings**
- **E2E in isolation** (DB up; port-8123 collision with an unrelated `undercooked` service → `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902`, no live change made): `test_reasoning_bubble.py` **6/6** · `test_thinking_timeline.py` ✓ · `test_thinking_display.py` ✓ · `test_tool_call_compact.py` ✓ · `test_big_read_progress.py` ✓ · `test_turn_progress_loader.py` ✓ · `test_share_chat.py` ✓ · `test_chat_persistence.py` ✓ · `test_smoke.py` ✓ (all exit 0, zero failures)
- **Completion criteria:** all 9 checked against the code + test outcomes — container open/live/nested ✓, stays open past mid-round delta ✓, folds at done ✓, restore + shared collapsed with "3 steps" ✓, plain turn = no container ✓, regression suites green ✓, suite/coverage/lint green ✓, diff scope limited to `frontend/assets/{app.js,shared.js,styles.css}` + `tests/` + `README.md` + phase files (no `app/` — D4) ✓
- **Documentation:** README.md "Thinking" section names the Reasoning bubble accurately (320px window, open-until-settle incl. mid-round deltas, "N steps", byte-identical no-container, collapsed on restore/shared) — verified consistent, no changes needed; AGENTS.md — verified consistent, no changes needed; all `.env.example` vars documented
- **Decisions:** commit + phase-dir move left to the harness (per pass rules — no git index/history commands executed); `undercooked` dev server on port 8123 untouched, E2E used the env port overrides
- **Next pending phase:** none — `161_reasoning_bubble` is the last in `todo/`
2026-10-06 06:36:09 -04:00

882 lines
36 KiB
Python

"""Phase 161 E2E (Playwright, mock-only): ONE live-open, condensed
reasoning bubble (owner TODO 2026-10-05 — "the reasoning process
should be one collapsible section that stays open during the
reasoning process … This new thinking/tool calling bubble should only
collapse once the final response has been returned and the session has
stopped").
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the story's
gate is the deterministic marker flow in ``tests/e2e/mock_llm.py``
(user message contains ``think out loud, answer, then check your
work`` (``REASONING_MIDROUND_TRIGGER``) **and** the system prompt
carries the ``<tools>`` section of the HIGH prompt): the
MID-ROUND flow — a ``reasoning_content`` run (``Step 1``), then a
MID-ROUND ``content`` delta (the React-style partial answer — the
content stream BEFORE the tool call, the exact mid-round ordering the
TODO describes), then the ``ls`` tool call; the follow-up request
(thinking ``Step 2`` + the closing delta + the content finish — no
further tool call). The client renders the ENTIRE per-run timeline
inside ONE ``details.reasoning`` container (D1): open from creation
until the turn settles (D2 — it STAYS OPEN through a mid-round
delta; only ``done`` / stop / the failed-turn funnel fold it),
condensed in its bounded 320px ``.reasoning-body`` live window (D3),
the summary carrying the D6 status literals (``Thinking…`` /
``Calling <name>…`` live, ``N steps`` at rest). The restore path and
the shared page render the SAME container COLLAPSED (D5) with the
at-rest step count (D6). A turn with no thinking and no tools creates
NO container (D1/D4 — the byte-identical DOM).
KB fixture — mirrored from ``test_thinking_timeline.py`` (the
deterministic two-document pair): the marker question cosines
≈0.595 against the retrievable document (well past the E2E 0.30
threshold) plus FTS hits, so the turn is solidly grounded (HIGH
prompt → ``<tools>``).
Test → phase mapping (Playwright Mapping Rule):
1. ``test_container_open_during_the_turn`` — D1/D3: during the turn
(a predicate over the mock's 0.1 s pacing window — the house
deadline-poll pattern) — ONE ``details.reasoning`` exists, is ``open``, contains a per-run
block NESTED inside its ``.reasoning-body`` (the phase-158
direct-children layout is superseded), and its
``.reasoning-status`` carries a live suffix (``Thinking…`` or
``Calling …``). The D3 computed-style pin lives here too: the
``.reasoning-body``'s computed ``max-height`` ≤ 320px +
``overflow-y: auto`` (the bounded-window contract — the CSS
verified in the browser, not just in source).
2. ``test_container_stays_open_past_a_mid_round_delta`` — D2 (the
TODO's core): while the answer bubble is NON-EMPTY (the mid-round
delta rendered) AND the turn is not settled (the in-flight
``Stop`` control), the container is STILL ``open`` with a run
visible.
3. ``test_container_folds_at_done`` — D2/D5/D6: after ``done`` the
container has NO ``open`` attribute, its summary is visible, EVERY
inner run has no ``open`` attribute (the phase-158 fold-at-rest
contract preserved inside the fold), and the status is ``3 steps``
(the flow's contiguous-run count — ``thinking (Step 1)``,
``tool (ls)``, ``thinking (Step 2)`` — the D6 live-settle
literal); the bubble carries the mid-round + closing answer.
4. ``test_restored_container_collapsed_with_step_count`` — D5/D6: a
same-context RELOAD renders ONE ``details.reasoning`` (NO ``open``
attribute) around the same per-run timeline, with the status
``3 steps`` (the D6 at-rest count — the restore's incremental
count matches the live ``segRuns.length`` semantics).
5. ``test_shared_page_container_collapsed`` — D5 (the phase-51 save +
share flow): a FRESH context on ``/shared/<token>`` renders ONE
``details.reasoning`` (collapsed) with the same timeline + step
count (the ``shared.js`` parity — ``addSegments``).
6. ``test_no_reasoning_turn_has_no_container`` — D1/D4: a plain
answer question renders ZERO ``details.reasoning`` (the
byte-identical DOM); a plain ``use your tools`` question (NO
thinking trigger) DOES create the container (the tool frames
create it), wrapping the ONE phase-117 disclosure — open live
(a deadline-poll window over the 0.1 s tool-frame pacing),
folded at ``done`` — with the status
``1 steps`` (the D6 uniform literal — the no-thinking turn's
single tool run; the phase-117 single-disclosure behavior
preserved, now wrapped).
"""
from __future__ import annotations
import hashlib
import re
import time
from collections.abc import Callable
from datetime import UTC, datetime
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
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
# --------------------------------------------------------------------------
# Fixture documents (deterministic, token-controlled — mirrored from
# test_thinking_timeline.py so the gate is byte-stable)
# --------------------------------------------------------------------------
SEED_SOURCE = "Homelab"
SEED_PATH = "aws-route53.md"
SEED_SP = f"{SEED_SOURCE}/{SEED_PATH}"
READ_SOURCE = "Deployments"
READ_PATH = "example-record-file.json"
READ_SP = f"{READ_SOURCE}/{READ_PATH}"
#: The retrievable document: references the JSON file but never
#: includes it. The repeated record-file lines carry the marker
#: question's key tokens (aws, route53, hosted, zone, reeselink,
#: json, exact, shape) — verified ≈0.595 cosine against the mock's
#: embeddings (E2E threshold 0.30) plus FTS hits, so the turn is
#: solidly grounded.
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"
)
#: The referenced document: the exact JSON shape (indexed, catalogued,
#: readable — but NO chunks, so retrieval never puts it in context).
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-161 marker question (``REASONING_MIDROUND_TRIGGER`` + the
#: KB topic — the ≈0.595 cosine keeps the turn grounded over the
#: mock-calibrated E2E threshold).
MIDROUND_QUESTION = (
"Think out loud, answer, then check your work: what is the exact "
"JSON shape of reeselink.json for my aws route53 hosted zone?"
)
#: The NO-thinking tool control — the plain ``use your tools`` marker
#: (``test_thinking_timeline.py``'s question, mirrored so the 3-call
#: flow is the phase-37/117 one).
PLAIN_TOOLS_QUESTION = (
"Use your tools: what is the exact JSON shape of reeselink.json "
"for my aws route53 hosted zone?"
)
#: The plain-answer control (NO trigger at all — the generic
#: ``compose_answer`` path: no thinking, no tools → NO container,
#: the byte-identical DOM — D1/D4).
PLAIN_ANSWER_QUESTION = (
"What is the exact JSON shape of reeselink.json for my aws "
"route53 hosted zone?"
)
#: The mock's mid-round delta + the closing delta — the turn's full
#: answer (byte-stable: the closing delta concatenates onto the
#: mid-round delta).
MIDROUND_DELTA = "The reference server is ref-server-1."
CLOSING_DELTA = " — confirmed by the listing."
MIDROUND_ANSWER = MIDROUND_DELTA + CLOSING_DELTA
#: The mock's two DISTINCT reasoning runs (the ``Step N`` markers —
#: their distinctness proves the runs are separate blocks, not one
#: lump).
STEP1_MARKER = "Step 1"
STEP2_MARKER = "Step 2"
#: The plain-tools flow's answer prefix (the phase-37 shape —
#: ``Read <source/path>.``).
ANSWER_PREFIX = f"Read {READ_SP}."
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, mirrored from test_thinking_timeline.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 D1/D3 live predicate — the LAST brain message's container
#: exists, is ``open``, NESTS a per-run block in its
#: ``.reasoning-body`` (the phase-158 direct-children layout is
#: superseded), and its status carries a LIVE suffix (``Thinking…``
#: or ``Calling …`` — the D6 live literals).
CONTAINER_LIVE_JS = """
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
if (!wrap) return false;
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
const status = cont.querySelector('.reasoning-status');
const st = status ? status.textContent : '';
const live = st === 'Thinking…' || st.indexOf('Calling ') === 0;
if (!live) return false;
return !!cont.querySelector(
'.reasoning-body details.thinking, ' +
'.reasoning-body details.tool-calls-disclosure');
}
"""
#: The D2 predicate (test 2's core) — the answer bubble is NON-EMPTY
#: (the mid-round delta has rendered) AND the turn is NOT settled
#: (the in-flight ``Stop`` control — the settled label is ``Send``)
#: AND the container is STILL ``open`` with a run nested in its
#: ``.reasoning-body`` (the TODO's "only collapse once the final
#: response has been returned and the session has stopped").
MIDROUND_STILL_OPEN_JS = """
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
if (!wrap) return false;
const body = wrap.querySelector('.msg-body');
const bubble = body ? body.querySelector('.bubble') : null;
if (!bubble || bubble.textContent === '') return false;
const label = document.querySelector('#send-label');
if (!label || label.textContent !== 'Stop') return false;
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
return !!cont.querySelector(
'.reasoning-body details.thinking, ' +
'.reasoning-body details.tool-calls-disclosure');
}
"""
#: The no-thinking live predicate (test 6's tool half) — the tool
#: frames created the container; it is ``open`` with a
#: ``.tool-calls-disclosure`` nested in its ``.reasoning-body``
#: (the phase-117 disclosure, now wrapped — D1 for tool-only turns).
TOOL_ONLY_LIVE_JS = """
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
if (!wrap) return false;
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
return !!cont.querySelector(
'.reasoning-body details.tool-calls-disclosure');
}
"""
#: The D3 computed-style pin — the ``.reasoning-body``'s computed
#: ``max-height`` (≤ 320px) + ``overflow-y`` (the bounded-window
#: contract, verified in the browser, not just in source).
REASONING_BODY_STYLE_JS = """
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
if (!wrap) return null;
const el = wrap.querySelector('details.reasoning .reasoning-body');
if (!el) return null;
const cs = getComputedStyle(el);
return { maxH: cs.maxHeight, ovy: cs.overflowY };
}
"""
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_thinking_timeline.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. D1/D3: the live turn renders ONE open container with the runs
# NESTED inside its bounded .reasoning-body live window
# --------------------------------------------------------------------------
def test_container_open_during_the_turn(
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="/")
page.fill("#message-input", MIDROUND_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(MIDROUND_QUESTION)
# D1/D3: during the turn — the mock's 0.1 s pacing window (the
# 0.1 s tool frames + the thinking/delta streams) — the
# container exists, is OPEN, NESTS a per-run block in its
# .reasoning-body (the phase-158 direct-children layout is
# superseded by the container nesting), and its status carries a
# LIVE suffix (Thinking… / Calling …). The house deadline-poll
# pattern (a 50 ms stride over the ~2 s turn) comfortably
# samples the open window.
seen_live = False
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
if page.evaluate(CONTAINER_LIVE_JS):
seen_live = True
break
time.sleep(0.05)
assert seen_live, (
"no open reasoning container with a NESTED per-run block "
"and a live status suffix was observed during the turn "
"(the D1/D3 live window — mid-round turn)"
)
# The turn settles (the D2 fold — test 3's contract).
_wait_settled(page)
# The D3 computed-style pin (folded in per the phase overview):
# the .reasoning-body is the ONE bounded live window — the
# computed max-height is ≤ 320px and overflow-y is auto (the CSS
# verified in the browser, not just in source).
style = page.evaluate(REASONING_BODY_STYLE_JS)
assert style is not None, "the .reasoning-body must exist after the turn"
max_h = style["maxH"]
assert max_h != "none", f"the .reasoning-body must be bounded, got {max_h!r}"
if max_h.endswith("px"):
assert float(max_h[:-2]) <= 320, f"max-height must be ≤ 320px, got {max_h!r}"
assert style["ovy"] == "auto", (
f"the .reasoning-body must be overflow-y: auto, got {style['ovy']!r}"
)
# The turn's full answer (the mid-round delta + the closing
# delta — the byte-stable concatenation).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
# --------------------------------------------------------------------------
# 2. D2 (the TODO's core): the container STAYS OPEN through a
# mid-round delta — it folds only once the final response has
# been returned and the session has stopped
# --------------------------------------------------------------------------
def test_container_stays_open_past_a_mid_round_delta(
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="/")
page.fill("#message-input", MIDROUND_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(MIDROUND_QUESTION)
# D2: while the answer bubble is NON-EMPTY (the mid-round delta
# has rendered — the React-style partial answer) AND the turn is
# NOT settled (the in-flight Stop control), the container is
# STILL ``open`` with a run nested in its .reasoning-body. The
# house deadline-poll pattern (a 50 ms stride over the ~1 s
# window — the tool call + the app's ls execution + the
# follow-up request) comfortably samples it.
seen_still_open = False
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
if page.evaluate(MIDROUND_STILL_OPEN_JS):
seen_still_open = True
break
time.sleep(0.05)
assert seen_still_open, (
"the container was not observed OPEN while the answer bubble "
"was non-empty and the turn was in flight (the D2 window — "
"the mid-round delta must NOT fold the container)"
)
# The turn settles with the full answer (the fold is test 3's
# contract — the settle proves the window ended in a completed
# turn, not a failure).
_wait_settled(page)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
# --------------------------------------------------------------------------
# 3. D2/D5/D6: the container folds at done — every inner run folds
# with it, and the status reads the live run count
# --------------------------------------------------------------------------
def test_container_folds_at_done(
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, MIDROUND_QUESTION)
_wait_settled(page)
# D2/D5: at rest the container has NO ``open`` attribute (the
# done settle folded it) — and EVERY inner run has no ``open``
# attribute either (the phase-158 fold-at-rest contract
# preserved inside the fold).
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking[open]")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure[open]"
)
).to_have_count(0)
# The collapsed summary is still visible (the bubble stays
# readable at rest).
expect(
page.locator("#messages .msg.brain details.reasoning > .reasoning-summary").first
).to_be_visible()
# D6: the at-rest status is the live run count — the flow's 3
# contiguous runs (thinking Step 1, tool ls, thinking Step 2).
expect(
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
).to_have_text("3 steps")
# The per-run timeline inside the fold: the two DISTINCT
# ``Step N`` thinking runs + the one tool line (the phase-37 ls
# echo — no-arg top-level listing), in arrival order.
texts = page.locator(
"#messages .msg.brain details.reasoning .thinking-text"
)
expect(texts).to_have_count(2)
expect(texts.nth(0)).to_contain_text(STEP1_MARKER)
expect(texts.nth(1)).to_contain_text(STEP2_MARKER)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-calls-disclosure"
)
).to_have_count(1)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-call"
)
).to_have_count(1)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-calls-summary"
)
).to_have_text("Tool call (1)")
# The turn's full answer (mid-round delta + closing delta).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
# --------------------------------------------------------------------------
# 4. D5/D6: a RELOAD restores the SAME container COLLAPSED around the
# per-run timeline, with the at-rest step count
# --------------------------------------------------------------------------
def test_restored_container_collapsed_with_step_count(
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, MIDROUND_QUESTION)
_wait_settled(page)
# RELOAD (same context) — the phase-14 record restores through
# the restore path (restoreTimeline — the segments take
# precedence).
page.reload()
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
# D5: ONE ``details.reasoning`` per restored brain message, NO
# ``open`` attribute (the restore renders collapsed; the live
# open state is exclusively the live turn's).
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
# The SAME per-run timeline, nested in the container (all
# collapsed — the D5 restore convention).
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking")
).to_have_count(2)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
)
).to_have_count(1)
texts = page.locator(
"#messages .msg.brain details.reasoning .thinking-text"
)
expect(texts.nth(0)).to_contain_text(STEP1_MARKER)
expect(texts.nth(1)).to_contain_text(STEP2_MARKER)
# D6: the at-rest step count — the restore's INCREMENTAL count
# (each thinking entry + one per contiguous tool group) matches
# the live segRuns.length semantics: 3 contiguous runs.
expect(
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
).to_have_text("3 steps")
# The restored answer (the restore is synchronous at boot).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MIDROUND_ANSWER, timeout=30_000
)
# --------------------------------------------------------------------------
# 5. D5: the shared page (phase-51 save + share flow) renders the
# SAME container COLLAPSED — the addSegments parity
# --------------------------------------------------------------------------
def test_shared_page_container_collapsed(
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, MIDROUND_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(MIDROUND_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(MIDROUND_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 title = the auto-title (the shared chat's h1).
expect(anon.locator("#shared-title")).to_have_text(
_auto_title(MIDROUND_QUESTION)
)
# D5 (the shared-page half): ONE ``details.reasoning``, NO
# ``open`` attribute (the pure-render convention — the
# shared page is a finished conversation), wrapping the SAME
# per-run timeline, all collapsed.
expect(anon.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
expect(
anon.locator("#messages .msg.brain details.reasoning[open]")
).to_have_count(0)
expect(
anon.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
).to_have_count(0)
expect(
anon.locator(
"#messages .msg.brain details.reasoning "
"details.tool-calls-disclosure[open]"
)
).to_have_count(0)
expect(
anon.locator("#messages .msg.brain details.reasoning details.thinking")
).to_have_count(2)
expect(
anon.locator(
"#messages .msg.brain details.reasoning "
"details.tool-calls-disclosure"
)
).to_have_count(1)
anon_texts = anon.locator(
"#messages .msg.brain details.reasoning .thinking-text"
)
expect(anon_texts.nth(0)).to_contain_text(STEP1_MARKER)
expect(anon_texts.nth(1)).to_contain_text(STEP2_MARKER)
# D6: the SAME at-rest step count (the addSegments parity —
# the same incremental count semantics as the restore).
expect(
anon.locator(
"#messages .msg.brain details.reasoning .reasoning-status"
).first
).to_have_text("3 steps")
# The shared answer bubble carries the turn's full answer.
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(MIDROUND_ANSWER)
finally:
if anon_ctx is not None:
anon_ctx.close()
if created:
_delete_chat(app_url, cookies, created)
# --------------------------------------------------------------------------
# 6. D1/D4: a plain answer turn creates NO container (the
# byte-identical DOM); a no-thinking tool turn DOES (the tool
# frames create it — the phase-117 disclosure, now wrapped)
# --------------------------------------------------------------------------
def test_no_reasoning_turn_has_no_container(
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="/")
# (a) The plain answer question (NO trigger — the generic
# compose_answer path: no thinking, no tools): D1/D4 — the
# byte-identical DOM, ZERO ``details.reasoning`` (and no
# thinking blocks / disclosures either).
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(0)
expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0)
expect(
page.locator("#messages .msg.brain details.tool-calls-disclosure")
).to_have_count(0)
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("")
# (b) The plain ``use your tools`` marker (NO thinking trigger —
# the mock's tool frames carry no ``reasoning_content``): the
# tool frames CREATE the container (D1 for tool-only turns),
# wrapping the ONE phase-117 disclosure.
page.fill("#message-input", PLAIN_TOOLS_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(PLAIN_TOOLS_QUESTION)
# D1 (the tool-only live half): during the turn — the mock's
# 0.1 s pacing window (the 0.1 s tool frames; the 3 phase-37
# calls) — the container is ``open`` with the disclosure
# NESTED in its .reasoning-body. The house deadline-poll
# pattern (a 50 ms stride over the ~1 s window) comfortably
# samples it.
seen_tool_live = False
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
if page.evaluate(TOOL_ONLY_LIVE_JS):
seen_tool_live = True
break
time.sleep(0.05)
assert seen_tool_live, (
"no open reasoning container with the NESTED tool disclosure "
"was observed while the turn streamed (the D1 tool-only "
"live window)"
)
# The turn settles: the container FOLDS at done (the D2 settle —
# the no-thinking turn's single tool run), and the status reads
# the D6 UNIFORM literal — 1 run → ``1 steps``.
_wait_settled(page)
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
expect(
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
).to_have_text("1 steps")
# The phase-117 single-disclosure behavior, preserved and now
# wrapped: ONE disclosure with the 3 ``.tool-call`` lines, zero
# thinking blocks, folded at rest.
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
)
).to_have_count(1)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-call"
)
).to_have_count(3)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-calls-summary"
)
).to_have_text("Tool calls (3)")
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
).to_have_count(0)
# The phase-37 answer echo (the read document reached the model).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(ANSWER_PREFIX)