phase: 161_reasoning_bubble
Build and Push Containers / build-and-push-app (push) Successful in 1m55s
Build and Push Containers / build-and-push-db (push) Successful in 14s

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/`
This commit is contained in:
2026-10-06 06:36:09 -04:00
parent 8172956c15
commit 051f62deb7
48 changed files with 4194 additions and 166 deletions
+254
View File
@@ -206,6 +206,51 @@ Implements just enough of the aipi surface:
TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER`` precedent). Verified
2026-10-04: no existing E2E question or fixture file contains
the phrase, so every other suite is unaffected.
- user message containing ``think out loud, answer, then check
your work`` (``REASONING_MIDROUND_TRIGGER``, phase 161 task 04
— the reasoning-bubble story's dedicated suite
``tests/e2e/test_reasoning_bubble.py``) **and** the system
prompt carries the ``<tools>`` section -> the deterministic
MID-ROUND tool flow: the owner's 2026-10-05 TODO's stays-open
wire shape — a ``reasoning_content`` run, then a MID-ROUND
``content`` delta (the React-style partial answer, the content
stream BEFORE the tool call), then the tool call; the follow-up
request thinks, closes the answer, and FINISHES with a content
finish (no further tool call — the ``done`` convention).
Discriminated statelessly from the messages (streaming only —
the app's chat path is the only streaming consumer; a
non-streaming marker request never issues and falls through to
the plain ``think out loud`` scratchpad answer, like the other
marker flows): the ``tools`` parameter gates the first step (a
no-tools request with no tool results is not the flow), and the
final step is reached when a ``tool``-role result is in the
messages (regardless of the ``tools`` parameter):
* request 1 (``tools`` offered, no tool results yet): stream
``reasoning_content`` (``Step 1: I think I know the
answer — let me check the documents.``) FIRST — the 12-char
``_sse_stream`` convention — then the MID-ROUND ``content``
delta (``The reference server is ref-server-1.`` — the SAME
12-char / 0.02 s convention), and ONLY then
``tool_calls`` deltas: ``ls`` (synthetic id ``call_0``, no
arguments), ``finish_reason: "tool_calls"`` — the tool call
AFTER the delta (the exact mid-round ordering the TODO
describes);
* request 2 (a ``tool``-role result in the messages): stream
``reasoning_content`` (``Step 2: the listing confirms
it.``) FIRST, then the closing ``content`` delta (`` —
confirmed by the listing.`` — it concatenates onto the
mid-round delta, so the turn's full answer is byte-stable),
then ``finish_reason: "stop"`` + ``[DONE]`` — the flow ends
with a content finish (NO further tool call).
Checked BEFORE the phase-158 ``think out loud and use your
tools`` check: the phrases share only ``think out loud`` and
NEITHER is a substring of the other (verified 2026-10-06), and
no existing E2E question or fixture file contains the phrase
(verified 2026-10-06: every other suite is unaffected) — the
more-specific-phrase-wins convention (the ``SLOW_PRETOKEN_-
TRIGGER`` / ``THINK_PARAS_TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER``
precedent). The trigger needs the ``<tools>`` section, so
deflected turns never hit it.
- user message containing ``search your documents``
(``SEARCH_TRIGGER``, phase 68 search tool — renamed to the
harness-aligned ``grep`` in phase 70, same match/output contract)
@@ -1117,6 +1162,36 @@ THINKING_TRIGGER = "think out loud"
#: exactly like the other marker flows.
THINK_TOOLS_TRIGGER = "think out loud and use your tools"
#: Phase 161 (task 04 — the reasoning-bubble story): a user message
#: containing this substring (case-insensitive) — combined with the
#: ``<tools>`` section in the system prompt — drives the
#: deterministic MID-ROUND flow documented in the module docstring:
#: a ``reasoning_content`` run, then a MID-ROUND ``delta`` (the
#: React-style partial answer — the content stream BEFORE the tool
#: call, the exact mid-round ordering the owner's 2026-10-05 TODO
#: describes), then the tool call; the follow-up request (a
#: ``tool``-role result in the messages) thinks, emits the closing
#: ``delta``, and FINISHES with a content finish (NO further tool
#: call — the ``done`` convention). Verified 2026-10-06: no
#: existing E2E question or fixture file contains the phrase, and
#: the dispatch is disjoint from the other ``think out loud``
#: triggers — the phrase shares only ``think out loud`` with
#: ``THINKING_TRIGGER`` and ``THINK_TOOLS_TRIGGER`` ("think out
#: loud and use your tools"): NEITHER ``THINK_TOOLS_TRIGGER`` nor
#: the new phrase is a substring of the other (the existing
#: continues ``…and use your tools``, the new continues ``…answer,
#: then check…``). NOTE: the phrase CONTAINS ``THINKING_TRIGGER``
#: ("think out loud") — the more-specific-phrase-wins convention
#: (``SLOW_PRETOKEN_TRIGGER`` / ``THINK_PARAS_TRIGGER`` /
#: ``SCAFFOLD_ALWAYS_TRIGGER``): the streaming dispatch checks the
#: flow BEFORE the phase-158 check and before the generic
#: ``think out loud`` path (so the plain scratchpad never sees the
#: marker). A non-streaming marker request (never issued by the app
#: — the chat path is the only streaming consumer) falls through to
#: the plain ``think out loud`` scratchpad answer, exactly like the
#: other marker flows.
REASONING_MIDROUND_TRIGGER = "think out loud, answer, then check your work"
#: Regression pin (2026-08-29, owner report): a user message containing
#: this substring (case-insensitive) gets the phase-17 scratchpad WITH
#: REAL paragraph breaks ("\n\n"), streamed at ``THINK_PARAS_CHUNK``
@@ -2133,6 +2208,55 @@ def _thinking_tools_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("list", "Step 1: list the documents to find the reference server config.")
def _reasoning_midround_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-161 marker request into one step of the
MID-ROUND thinking + delta + tool flow (see the module
docstring).
The two-request flow — the owner's 2026-10-05 TODO's
stays-open wire shape (the mid-round ``delta`` BEFORE the tool
call is what the client's stays-open container contract needs).
Discriminated statelessly from the messages, like the other
marker flows:
* ``("midround", <step 1 thinking>, <mid-round delta>)`` —
``tools`` are offered and no tool results are in the
messages yet: the model thinks, then emits a MID-ROUND
``delta`` (the content stream BEFORE the tool call), then
calls ``ls`` (no arguments, id ``call_0``) — the tool call
AFTER the delta.
* ``("final", <step 2 thinking>, <closing delta>)`` — a
``tool``-role result is in the messages: the model thinks,
then emits the closing ``delta`` and FINISHES with a content
finish (``finish_reason: "stop"`` — the ``done`` convention;
NO further tool call). Reached regardless of the ``tools``
parameter (phase 45 keeps the tools offered until the round
cap).
* ``None`` — not the marker flow: the marker is absent, the
``<tools>`` section is not in the system prompt, or a
no-tools request with no tool results (e.g. ``agent_max_
rounds=0``) — the request behaves exactly as before.
"""
user = _user(body).lower()
if REASONING_MIDROUND_TRIGGER not in user:
return None
if "<tools>" not in _system(body):
return None
if _tool_results(body):
return (
"final",
"Step 2: the listing confirms it.",
" — confirmed by the listing.",
)
if not body.get("tools"):
return None
return (
"midround",
"Step 1: I think I know the answer — let me check the documents.",
"The reference server is ref-server-1.",
)
def _ls_teach_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-72 LS-TEACH request (see the module docstring).
@@ -4226,6 +4350,96 @@ def _tool_call_stream(
yield "data: [DONE]\n\n"
def _midround_tool_stream(
thinking: str,
mid_round_delta: str,
name: str,
arguments: dict[str, Any],
call_id: str,
) -> Any:
"""SSE frames for one MID-ROUND tool completion (phase 161, task
04 — the stays-open contract's wire shape).
The three-part ordering the owner's 2026-10-05 TODO describes:
the ``reasoning_content`` run FIRST (the 12-char
``_tool_call_stream`` convention — the 0.02 s cadence, the
aipi wire convention: reasoning before content), then the
MID-ROUND ``content`` delta (the SAME 12-char / 0.02 s
convention — the React-style partial answer, the content stream
BEFORE the tool call), and ONLY then the ``_tool_call_stream``
tool-call frames (the deterministic 16-char argument split, the
0.1 s pacing) + ``finish_reason: "tool_calls"`` + ``[DONE]``.
The app accumulates exactly this (``app/rag/llm.py`` — the
content-before-tools flush: the mid-round content reaches the
client as a ``delta`` frame BEFORE the ``tool`` frame; a rare
stream with both content and a tool call keeps the content AND
runs the tool, ``app/rag/agent.py``), so the client sees
thinking → mid-round delta → tool call → next round — the D2
ordering the stays-open container contract needs.
"""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
for piece in re.findall(r".{1,12}", thinking, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{"index": 0, "delta": {"reasoning_content": piece}, "finish_reason": None}
],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
for piece in re.findall(r".{1,12}", mid_round_delta, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
raw_args = json_dumps(arguments) if arguments else "{}"
frags = [raw_args[i : i + 16] for i in range(0, len(raw_args), 16)] or ["{}"]
for i, frag in enumerate(frags):
tc: dict[str, Any] = {"index": 0, "function": {"arguments": frag}}
delta: dict[str, Any] = {"tool_calls": [tc]}
if i == 0:
tc = {
"index": 0,
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": frag},
}
delta = {"role": "assistant", "tool_calls": [tc]}
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.1)
yield (
"data: "
+ json_dumps(
{
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
}
)
+ "\n\n"
)
yield "data: [DONE]\n\n"
@app.post("/v1/chat/completions")
def chat_completions(body: dict[str, Any]) -> Any:
# Phase 123 (task 04): the OBSERVATIONAL capture (the ring buffer —
@@ -4839,6 +5053,46 @@ def chat_completions(body: dict[str, Any]) -> Any:
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
# content stream BEFORE the tool call, the exact ordering
# the owner's 2026-10-05 TODO describes), then the tool
# call; the follow-up request thinks + the closing
# ``delta`` + the content finish (see the module
# docstring). Checked BEFORE the phase-158 check: the
# phrases share only ``think out loud`` and NEITHER is a
# substring of the other (verified 2026-10-06), and no
# existing E2E question carries the phrase (verified
# 2026-10-06: every other suite is unaffected) — the
# more-specific-phrase-wins convention (the SLOW_PRETOKEN /
# THINK_PARAS / SCAFFOLD_ALWAYS precedent). The trigger
# needs the ``<tools>`` section, so deflected turns never
# hit it.
midround = _reasoning_midround_flow(body)
if midround is not None:
if midround[0] == "midround":
# The mid-round ordering: the thinking run, then the
# MID-ROUND content delta, then the tool call — the
# ``_midround_tool_stream`` three-part shape.
stream = _midround_tool_stream(
midround[1],
midround[2],
"ls",
{},
"call_0",
)
else: # "final" — think, the closing delta, the content finish
stream = _sse_stream(
midround[2],
0.0,
thinking=midround[1],
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 158 (task 04 — the thinking/tool-call timeline
# story): the deterministic INTERLEAVED flow — a DISTINCT
# reasoning run ahead of every tool call and ahead of the
+881
View File
@@ -0,0 +1,881 @@
"""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)
+17 -2
View File
@@ -164,7 +164,12 @@ def test_thinking_block_streams_open_then_collapses(
# ---------------------------------------------------------------------------
# 2. Toggle: after settle the block is closed; the summary re-opens it
# (a real keyboard-focusable control) and closes it again
# (a real keyboard-focusable control) and closes it again.
# Phase 161 (D2): at rest the run sits INSIDE the folded reasoning
# container — the container's summary is the at-rest focusable
# surface (native <details> nesting semantics: a closed details
# hides its content, so the inner summary is focusable only after
# the container opens).
# ---------------------------------------------------------------------------
@@ -177,8 +182,18 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
details = last.locator("details.thinking")
expect(details).to_have_count(1)
expect(details).not_to_have_attribute("open") # auto-collapsed at first token
# Phase 161 (D2): the run nests in the reasoning container, which
# folds at done (the at-rest focusable surface is the container's
# summary — open it to reach the inner run's summary).
container = last.locator("details.reasoning")
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
container.locator("summary.reasoning-summary").focus()
assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY"
container.locator("summary.reasoning-summary").click()
expect(container).to_have_attribute("open", "")
# The summary is a real, keyboard-focusable control.
# The inner run's summary is a real, keyboard-focusable control.
details.locator("summary").focus()
assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY"
+176 -53
View File
@@ -25,7 +25,15 @@ own ``details.thinking`` / ``details.tool-calls-disclosure`` (D1), all
above the answer bubble in arrival order (D2 — Option B,
owner-chosen), open while the run's frames stream and folded at rest
(D5), and persists the run order in the record's ``segments`` array
(D4).
(D4). Phase 161 (the documented supersession, owner TODO direction
2026-10-05): the per-run blocks are NESTED inside ONE
``details.reasoning`` container (``.reasoning-body``) that sits
before the bubble — open during the turn, folded at done / on
restore / on the shared page; the per-run open/fold behavior itself
is unchanged. The sequence pin is shape-unchanged, read from the
container's ``.reasoning-body`` children (the container is the one
new parent — the pre-bubble ``.msg-body`` children are exactly
``["details.reasoning"]``).
KB fixture — mirrored from ``test_agent_document_tools.py`` (the
deterministic two-document pair): ``Deployments`` registered FIRST so
@@ -37,37 +45,46 @@ prompt → ``<tools>``).
Test → phase mapping (Playwright Mapping Rule):
1. ``test_timeline_order_after_completed_turn`` — D1/D2: after ``done``
the brain wrap's ``.msg-body`` children before the bubble are
EXACTLY the 7-element arrival-order sequence (4 thinking blocks +
3 one-line disclosures, alternating); the four ``.thinking-text``s
carry the DISTINCT ``Step N`` markers (distinctness = separate
blocks, not one lump); each disclosure summary is ``Tool call
(1)``; the answer bubble carries the ``Read <source/path>`` echo.
2. ``test_runs_fold_at_rest`` — D5: after ``done`` NO ``.thinking`` /
``.tool-calls-disclosure`` has the ``open`` attribute (the settle
folded the runs); the summaries are still visible.
the pre-bubble ``.msg-body`` children are EXACTLY the reasoning
container (the one new parent — phase 161), and its
``.reasoning-body`` children are EXACTLY the 7-element
arrival-order sequence (4 thinking blocks + 3 one-line
disclosures, alternating); the four ``.thinking-text``s carry the
DISTINCT ``Step N`` markers (distinctness = separate blocks, not
one lump); each disclosure summary is ``Tool call (1)``; the
answer bubble carries the ``Read <source/path>`` echo.
2. ``test_runs_fold_at_rest`` — D5 (phase 161 D2): after ``done``
the container AND every ``.thinking`` /
``.tool-calls-disclosure`` inside it has NO ``open`` attribute
(the settle folded the runs + the container); the at-rest visible
line is the container's ``Reasoning`` summary (``7 steps``);
opening it reveals the folded run summaries.
3. ``test_live_runs_open_while_streaming`` — D5 (the live half):
DURING the turn (the mock's pacing window — the 0.1 s tool frames +
the thinking stream, the answer bubble still EMPTY) the active run's
block is ``open`` (the live feedback the owner keeps).
4. ``test_restored_timeline_and_record`` — the restore path + D4: a
same-context RELOAD re-renders the same 7-element sequence (all
collapsed), and the localStorage ``bor.chat.v1`` record (phase 14)
carries a ``segments`` array of EXACTLY 7 entries in the
same-context RELOAD re-renders the same 7-element sequence in ONE
collapsed reasoning container (``7 steps``, phase 161 D5/D6), and
the localStorage ``bor.chat.v1`` record (phase 14) carries a
``segments`` array of EXACTLY 7 entries in the
``thinking, tool, ×3`` pattern (the ``Step N`` text on the thinking
entries, the ``name``/``argument`` on the tool entries); the legacy
``thinking`` (full concatenation) + ``tools`` (3 records) keys are
STILL present (the old-client graceful degradation).
5. ``test_shared_page_timeline`` — the shared-page parity (phase 51
save + share flow): a FRESH context on ``/shared/<token>`` renders
the same 7-element sequence, all collapsed (``addSegments``), the
``Step N`` markers visible on summary click (expand ONE), the answer
bubble carries the ``Read <source/path>`` echo.
the same 7-element sequence in ONE collapsed reasoning container
(``addSegments``), the ``Step N`` markers visible on container +
summary click (expand ONE each), the answer bubble carries the
``Read <source/path>`` echo.
6. ``test_no_thinking_turn_single_disclosure`` — the run-grouping
contract for no-thinking turns (the phase-117 behavior preserved): a
plain ``use your tools`` turn (NO thinking trigger) renders EXACTLY
ONE ``.tool-calls-disclosure`` (3 ``.tool-call`` lines, summary
``Tool calls (3)``) and ZERO ``.thinking``.
contract for no-thinking turns (the phase-117 behavior preserved,
now wrapped — phase 161): a plain ``use your tools`` turn (NO
thinking trigger) renders ONE folded reasoning container (``1
steps``) wrapping EXACTLY ONE ``.tool-calls-disclosure`` inside its
``.reasoning-body`` (3 ``.tool-call`` lines, summary ``Tool calls
(3)``) and ZERO ``.thinking``.
"""
from __future__ import annotations
@@ -169,9 +186,11 @@ ANSWER_QUOTE = "date: 2024-06-15" + RECORD_FILE_CONTENT[:63]
#: lump).
STEP_MARKERS = ("Step 1", "Step 2", "Step 3", "Step 4")
#: The 7-element pre-bubble segment sequence the timeline renders, in
#: arrival order (D1: alternating thinking/tool runs — one block per
#: run) — tests 1/4 (chat page) and 5 (shared page) pin it.
#: The 7-element sequence the timeline renders in the reasoning
#: container's ``.reasoning-body`` children, in arrival order (D1:
#: alternating thinking/tool runs — one block per run; phase 161: the
#: sequence is shape-unchanged — the container is the one new parent)
#: — tests 1/4 (chat page) and 5 (shared page) pin it.
TIMELINE_SEQ = [
"details.thinking",
"details.tool-calls-disclosure",
@@ -269,10 +288,12 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
# --------------------------------------------------------------------------
#: The last ``.msg.brain`` wrap's ``.msg-body`` children BEFORE the
#: bubble, as ``tag.className`` strings — the pre-bubble segment
#: sequence (the timeline's order pin; the ``.msg-meta`` row lands
#: AFTER the bubble, so it never enters the sequence).
BODY_SEQUENCE_JS = """
#: bubble, as ``tag.className`` strings. Phase 161 (D1): EXACTLY ONE
#: — the ``details.reasoning`` container (the one new anchor before
#: the bubble; the per-run blocks live inside it, not as direct
#: children; the ``.msg-meta`` row lands AFTER the bubble, so it
#: never enters the sequence).
PRE_BUBBLE_JS = """
() => {
const wrap = document.querySelector('#messages .msg.brain');
if (!wrap) return null;
@@ -286,6 +307,24 @@ BODY_SEQUENCE_JS = """
}
"""
#: The last ``.msg.brain`` wrap's reasoning container's
#: ``.reasoning-body`` children, as ``tag.className`` strings — the
#: timeline's order pin (phase 161: the 7-element arrival-order
#: sequence nested in the container — shape unchanged from phase 158).
BODY_SEQUENCE_JS = """
() => {
const wrap = document.querySelector('#messages .msg.brain');
if (!wrap) return null;
const body = wrap.querySelector('details.reasoning .reasoning-body');
if (!body) return null;
const out = [];
for (const child of body.children) {
out.push(child.tagName.toLowerCase() + '.' + child.className);
}
return out;
}
"""
#: D5's live-open predicate — the active run's block is ``open`` while
#: the answer bubble is STILL EMPTY (the turn is in flight; the mock's
#: pacing window — the 0.1 s tool frames + the thinking stream — keeps
@@ -435,13 +474,19 @@ def test_timeline_order_after_completed_turn(
_submit(page, TIMELINE_QUESTION)
_wait_settled(page)
# D1: the pre-bubble segment sequence is EXACTLY the 7-element
# arrival-order timeline — thinking, tool, ×3 (one block per
# contiguous run; the plain ``use your tools`` flow's 3 calls land
# in 3 SEPARATE one-line disclosures, one per thinking run).
# Phase 161 (D1): the container is the ONE pre-bubble anchor —
# the per-run blocks are no longer direct ``.msg-body`` children.
assert page.evaluate(PRE_BUBBLE_JS) == ["details.reasoning"], (
"the pre-bubble children must be exactly the reasoning "
"container (phase 161 D1)"
)
# D1: the container's ``.reasoning-body`` sequence is EXACTLY the
# 7-element arrival-order timeline — thinking, tool, ×3 (one block
# per contiguous run; the plain ``use your tools`` flow's 3 calls
# land in 3 SEPARATE one-line disclosures, one per thinking run).
seq = page.evaluate(BODY_SEQUENCE_JS)
assert seq == TIMELINE_SEQ, (
f"the pre-bubble segment sequence is not the timeline: {seq}"
f"the in-container segment sequence is not the timeline: {seq}"
)
# D1/D3: the four thinking blocks carry the DISTINCT ``Step N``
@@ -482,8 +527,22 @@ def test_runs_fold_at_rest(
_submit(page, TIMELINE_QUESTION)
_wait_settled(page)
# D5: at rest NOTHING is open — the ``done`` settle (closeAllRuns)
# folded every run (a closed <details> has no ``open`` attribute).
# Phase 161 (D2): the container folds at done — the at-rest
# visible line is its ``Reasoning`` summary (the D6 settled status
# is the run count — 7 runs → "7 steps").
container = page.locator("#messages .msg.brain details.reasoning")
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
expect(
page.locator("#messages .msg.brain .reasoning-summary").first
).to_be_visible()
expect(
page.locator("#messages .msg.brain .reasoning-status")
).to_have_text("7 steps")
# D5: at rest NOTHING inside is open — the ``done`` settle
# (closeAllRuns) folded every run (a closed <details> has no
# ``open`` attribute).
expect(
page.locator("#messages .msg.brain details.thinking[open]")
).to_have_count(0)
@@ -493,8 +552,11 @@ def test_runs_fold_at_rest(
)
).to_have_count(0)
# The collapsed summaries are still visible (the timeline stays
# readable at rest — 4 "Thinking" + 3 "Tool call (1)" lines).
# The inner runs stay in the DOM inside the fold — opening the
# container (the native toggle) reveals the 4 "Thinking" + 3
# "Tool call (1)" lines (the timeline stays readable at rest).
page.locator("#messages .msg.brain .reasoning-summary").click()
expect(container).to_have_attribute("open", "")
expect(
page.locator("#messages .msg.brain details.thinking > summary").first
).to_be_visible()
@@ -566,11 +628,28 @@ def test_restored_timeline_and_record(
page.reload()
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
# The restored message renders the SAME 7-element timeline, all
# COLLAPSED (D5 — the restore renders collapsed).
# Phase 161 (D5/D6): the restore renders ONE collapsed container
# around the timeline (the D6 at-rest step count). The pre-bubble
# children are exactly the container (the live pin, D1).
expect(
page.locator("#messages .msg.brain details.reasoning")
).to_have_count(1)
expect(
page.locator("#messages .msg.brain details.reasoning")
).not_to_have_attribute("open")
expect(
page.locator("#messages .msg.brain .reasoning-status")
).to_have_text("7 steps")
assert page.evaluate(PRE_BUBBLE_JS) == ["details.reasoning"], (
"the restored pre-bubble children must be exactly the "
"container (phase 161 D1)"
)
# The restored message renders the SAME 7-element timeline
# (in-container), all COLLAPSED (D5 — the restore renders
# collapsed).
seq = page.evaluate(BODY_SEQUENCE_JS)
assert seq == TIMELINE_SEQ, (
f"the restored pre-bubble sequence is not the timeline: {seq}"
f"the restored in-container sequence is not the timeline: {seq}"
)
expect(
page.locator("#messages .msg.brain details.thinking[open]")
@@ -680,12 +759,24 @@ def test_shared_page_timeline(
# The title = the auto-title (the shared chat's h1).
expect(anon.locator("#shared-title")).to_have_text(_auto_title(TIMELINE_QUESTION))
# The SAME 7-element timeline renders (the ``addSegments``
# parity), all COLLAPSED (D5 — the shared page is a finished
# conversation; a closed <details> has no ``open`` attribute).
# Phase 161 (D5/D6): the shared page renders ONE collapsed
# container (the D6 at-rest step count) around the timeline.
expect(
anon.locator("#messages .msg.brain details.reasoning")
).to_have_count(1)
expect(
anon.locator("#messages .msg.brain details.reasoning")
).not_to_have_attribute("open")
expect(
anon.locator("#messages .msg.brain .reasoning-status")
).to_have_text("7 steps")
# The SAME 7-element timeline renders inside it (the
# ``addSegments`` parity), all COLLAPSED (D5 — the shared
# page is a finished conversation; a closed <details> has no
# ``open`` attribute).
seq = anon.evaluate(BODY_SEQUENCE_JS)
assert seq == TIMELINE_SEQ, (
f"the shared pre-bubble sequence is not the timeline: {seq}"
f"the shared in-container sequence is not the timeline: {seq}"
)
expect(
anon.locator("#messages .msg.brain details.thinking[open]")
@@ -697,7 +788,10 @@ def test_shared_page_timeline(
).to_have_count(0)
# The ``Step N`` markers are visible on a summary click (expand
# ONE — the native <summary> is a real focusable toggle).
# ONE — the native <summary> is a real focusable toggle; phase
# 161: the container's own summary first — the inner runs'
# summaries sit inside its closed fold).
anon.locator("#messages .msg.brain .reasoning-summary").click()
anon.locator("#messages .msg.brain details.thinking > summary").first.click()
expect(
anon.locator("#messages .msg.brain details.thinking").first
@@ -736,22 +830,51 @@ def test_no_thinking_turn_single_disclosure(
_submit(page, PLAIN_TOOLS_QUESTION)
_wait_settled(page)
# D1 (the grouping contract): no thinking frames → ONE tool run →
# EXACTLY ONE disclosure (the phase-117 behavior for no-thinking
# turns is preserved), with the 3 ``.tool-call`` lines and the
# plural summary.
# Phase 161 (D1/D2): the tool frames create the container — it
# exists and folds at done; the D6 settled status is the single
# tool run ("1 steps").
expect(
page.locator("#messages .msg.brain .tool-calls-disclosure")
page.locator("#messages .msg.brain details.reasoning")
).to_have_count(1)
expect(page.locator("#messages .msg.brain .tool-call")).to_have_count(3)
expect(
page.locator("#messages .msg.brain details.reasoning")
).not_to_have_attribute("open")
expect(
page.locator("#messages .msg.brain .reasoning-status")
).to_have_text("1 steps")
# D1 (the grouping contract): no thinking frames → ONE tool run →
# EXACTLY ONE disclosure INSIDE THE CONTAINER (the phase-117
# behavior for no-thinking turns is preserved, now wrapped), with
# the 3 ``.tool-call`` lines and the plural summary.
expect(
page.locator(
"#messages .msg.brain details.reasoning .reasoning-body "
".tool-calls-disclosure"
)
).to_have_count(1)
expect(
page.locator(
"#messages .msg.brain details.reasoning .reasoning-body "
".tool-call"
)
).to_have_count(3)
expect(
page.locator("#messages .msg.brain .tool-calls-summary")
).to_have_text("Tool calls (3)")
# And ZERO thinking blocks (no thinking ran — no empty block).
expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0)
# And ZERO thinking blocks inside (no thinking ran — no empty
# block).
expect(
page.locator(
"#messages .msg.brain details.reasoning .reasoning-body "
"details.thinking"
)
).to_have_count(0)
# Folded at rest (the first delta / done settle) + the answer.
expect(
page.locator("#messages .msg.brain .tool-calls-disclosure[open]")
page.locator(
"#messages .msg.brain details.reasoning .reasoning-body "
".tool-calls-disclosure[open]"
)
).to_have_count(0)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(ANSWER_PREFIX)
+45 -8
View File
@@ -144,6 +144,10 @@ SUMMARY = "#messages .msg.brain .tool-calls-summary"
DISCLOSURE = "#messages .msg.brain .tool-calls-disclosure"
LINES = "#messages .msg.brain .tool-call"
#: Phase 161 (D1/D2): the ONE reasoning container — created on the
#: turn's first tool frame, OPEN live, folded at the settle (D2).
REASONING = "#messages .msg.brain details.reasoning"
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, cf. test_agent_document_tools.py)
# --------------------------------------------------------------------------
@@ -261,7 +265,12 @@ def _submit_tools_turn(page: Page, app_url: str) -> None:
def _expand_disclosure(page: Page) -> None:
"""Tap the summary — the native ``<summary>`` is a real focusable
toggle (AA), so a plain click is the user contract."""
toggle (AA), so a plain click is the user contract. Phase 161
(D2): at rest the disclosure sits INSIDE the folded reasoning
container, so the user flow is two native toggles — open the
container first (its own summary — a direct child, so the inner
runs' summaries are not matched), then the disclosure's summary."""
page.locator(f"{REASONING} > summary").click()
page.locator(SUMMARY).click()
@@ -274,16 +283,27 @@ def test_tool_calls_fold_to_one_line_after_turn(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Pin 1 (the space fix): after the deterministic ``ls`` →
``ls(scoped)`` → ``read`` turn COMPLETES, the record is ONE visible
"Tool calls (3)" summary — the disclosure is CLOSED (no ``open``
``ls(scoped)`` → ``read`` turn COMPLETES, the record is ONE
visible summary line — the disclosure is CLOSED (no ``open``
attribute: the first answer delta folded it, D3), the three
``.tool-call`` lines are still in the DOM (the permanent record)
but hidden (the first line is not visible), and the answer bubble
is present."""
is present. Phase 161 (D2): the at-rest record is wrapped in the
FOLDED reasoning container — the at-rest visible line is the
container's "Reasoning" summary; the disclosure's "Tool calls (3)"
count is the in-container record, revealed by opening it."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
# Phase 161 (D2): the container exists (the tool frames created
# it) and folds at done — the at-rest visible line is its summary.
container = page.locator(REASONING)
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
page.locator(f"{REASONING} > summary").click()
expect(container).to_have_attribute("open", "")
# One compact summary line, visible, with the live count.
summary = page.locator(SUMMARY)
expect(summary).to_be_visible()
@@ -396,10 +416,15 @@ def test_restored_turn_renders_folded(
record is written at the save points, so the reload sees the full
conversation), a same-context RELOAD re-renders the persisted
record through the restore path (``renderStoredMessage`` →
``appendToolLine`` → the after-loop fold): the disclosure is
``restoreTimeline`` → the after-loop fold): the disclosure is
present, CLOSED (no ``open`` attribute — no auto-expand on load),
the summary reads "Tool calls (3)", the three lines are in the DOM
and the first is hidden (folded on load)."""
and the first is hidden (folded on load). Phase 161 (D2/D5): the
restored record is wrapped in the FOLDED reasoning container —
the at-rest visible line is the container's "Reasoning" summary;
the disclosure's "Tool calls (3)" count is the in-container
record, revealed by opening the container (the same two-native-
toggle user flow as the live at-rest pins)."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
@@ -413,10 +438,22 @@ def test_restored_turn_renders_folded(
page.locator(".msg.brain .bubble").last
).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
# Folded on load: one compact summary line, disclosure closed.
# Folded on load (phase 161, D2/D5): the record is wrapped in the
# FOLDED reasoning container — the at-rest visible line is the
# container's "Reasoning" summary.
container = page.locator(REASONING)
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
expect(container.locator("> summary")).to_be_visible()
expect(container.locator("> summary")).to_contain_text("Reasoning")
# The disclosure is CLOSED (no auto-expand on load) — and the
# in-container record is revealed by opening the container (the
# two-native-toggle user flow).
assert page.get_attribute(DISCLOSURE, "open") is None
page.locator(f"{REASONING} > summary").click()
expect(page.locator(SUMMARY)).to_be_visible()
expect(page.locator(SUMMARY)).to_contain_text("Tool calls (3)")
assert page.get_attribute(DISCLOSURE, "open") is None
# The three lines are in the DOM (the permanent record) but folded:
# the first line is not rendered — no auto-expand.