"""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 ```` 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 pinned-tail follow — the window follows the live tail while the user stays near the bottom). The per-run blocks inside the container are plain divs — NOT collapsible (the D5 refinement, owner 2026-10-06: the container is the ONE disclosure; a nested fold inside the fold used to hide the live reasoning mid-turn). 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 → ````). 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, the inner runs are NOT collapsible (zero ``
`` inside the container — the D5 refinement), 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/`` 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 .``). 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 ```` + ```` + ```` 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 div.thinking, ' + '.reasoning-body div.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 div.thinking, ' + '.reasoning-body div.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 div.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 the inner runs are NOT # collapsible (zero ``
`` inside the container — the D5 # refinement, owner 2026-10-06: the per-run blocks are plain # divs, always visible while the container is open). expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0) expect( page.locator("#messages .msg.brain details.reasoning details") ).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 (plain divs # — the D5 refinement; the at-rest fold is the container's). expect( page.locator("#messages .msg.brain details.reasoning div.thinking") ).to_have_count(2) expect( page.locator("#messages .msg.brain details.reasoning details") ).to_have_count(0) expect( page.locator( "#messages .msg.brain details.reasoning div.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 as plain divs (the D5 refinement — # the inner runs are not collapsible). 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") ).to_have_count(0) expect( anon.locator("#messages .msg.brain details.reasoning div.thinking") ).to_have_count(2) expect( anon.locator( "#messages .msg.brain details.reasoning " "div.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 .thinking")).to_have_count(0) expect( page.locator("#messages .msg.brain .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 — a plain div (the D5 refinement — # not collapsible; zero ``
`` inside the container) with # the 3 ``.tool-call`` lines, zero thinking blocks, folded at # rest (the container's fold). expect( page.locator( "#messages .msg.brain details.reasoning div.tool-calls-disclosure" ) ).to_have_count(1) expect( page.locator("#messages .msg.brain details.reasoning details") ).to_have_count(0) 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 div.thinking") ).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)