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.
+46 -4
View File
@@ -32,8 +32,10 @@ And — the phase's explicit "NOT touched" contract — ``retryLastTurn``
is byte-unchanged, ``runTurn`` is pinned at exactly the state the
phase left it (its sanctioned additions — the task-01
``lastUserWrap`` assignment + the idle-settle ``markLastEditable()``
mark pass, and the task-03 aborted-settle mark pair — included;
no task may restructure it), and — the task-04 regression pins —
mark pass, the task-03 aborted-settle mark pair, the phase-160
done-settle pair, and the phase-161 live-container additions —
included; no task may restructure it), and — the task-04 regression
pins —
``handleSend`` and ``rememberBrainTurn`` are byte-unchanged (the
plain send's pre-work and the brain-record funnel the re-ask's
settle rides are touched by no phase-159 task). Task 03 (the
@@ -852,6 +854,7 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
thinkingAcc = "";
segRuns = []; // phase 158: a fresh turn starts with no runs (the pagehide partial reads the current turn's runs)
currentRun = null;
reasoningWrapEl = null; // phase 161: a fresh turn starts with no container (the pagehide partial reads the live container, like segRuns)
persistedOnLeave = false;
leftThePage = false;
leavePartialIndex = -1;
@@ -954,6 +957,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
if (!wrap) wrap = addMessage("brain", "");
removeTyping(); // the live block replaces the dots as feedback
const block = ensureThinkingRun(wrap);
// Phase 161 (D6): the container's live status — "Thinking…"
// while a thinking frame streams (a tool frame overwrites it
// with "Calling <name>…"; the settle sites overwrite it with
// "N steps").
setReasoningStatus("Thinking…");
currentRun.run.text += ev.text || ""; // the RUN's text — the per-run block renders it
const textEl = block.querySelector(".thinking-text");
// Pin state is measured BEFORE the re-render: a chunk taller
@@ -967,6 +975,13 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// THIS block (per-run — each run's block pins its own tail;
// the pre-update capture is verbatim).
const pinned = block.open && isThinkingNearBottom(textEl);
// Phase 161 (D3): the container window's PRE-update pin
// capture — the phase-17 pre-measure convention, BEFORE the
// re-render grows the content (the inner per-block follow
// above stays — inside the container the .thinking-text is
// no longer scrollable, so it no-ops there; task 03).
const rbody = reasoningWrapEl?.querySelector(".reasoning-body");
const rPinned = !!rbody && isReasoningPinned(rbody);
textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe
if (pinned) {
// Follow the live tail only while the user was pinned to the
@@ -975,6 +990,7 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// returning to the bottom re-arms the pin on the next chunk.
textEl.scrollTop = textEl.scrollHeight;
}
if (rPinned && rbody) scrollReasoningToBottom(rbody); // post-append apply
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
// state stays "thinking" — the button stays the enabled "Stop"
@@ -1057,6 +1073,14 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// wiring.
const disc = ensureToolRun(wrap);
currentRun.run.records.push(toolRecord);
// Phase 161 (D6): "Calling <name>…" — the display name the
// tool line already shows (the phase-70 legacy-name mapping
// produced `name` before this point).
setReasoningStatus(`Calling ${name}…`);
// Phase 161 (D3): the container window's PRE-update pin
// capture — before the line append grows the content.
const tbody = reasoningWrapEl?.querySelector(".reasoning-body");
const tPinned = !!tbody && isReasoningPinned(tbody);
const line = appendToolLine(
disc,
name,
@@ -1065,6 +1089,7 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
frameLimit,
framePath
);
if (tPinned && tbody) scrollReasoningToBottom(tbody); // post-append apply
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
// baseline resets per line, so each line counts its OWN
// frameless silence. This live branch is the ONLY arm call site
@@ -1121,6 +1146,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// late double-close is a no-op). Replaces the phase-117
// closeToolCalls fold + the D15 block close.
closeAllRuns(wrap);
// Phase 161 (D2): the turn settled — fold the container too
// (the final response has been returned); the summary reads
// the live run count (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
if (!wrap) {
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
@@ -1332,6 +1362,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// (replaces the phase-117 closeToolCalls fold + the D15 block
// close, like `done`).
closeAllRuns(wrap);
// Phase 161 (D2): the session stopped — fold the container
// alongside the runs; the summary reads the live run count
// (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
@@ -1427,12 +1462,19 @@ def test_run_turn_is_pinned_at_phase_159_state() -> None:
plus phase 160 (task 04)'s sanctioned additions (task 02 consumes
its ``reask`` path AS-IS — the phase's "NOT touched" contract;
the phase-160 additions are the done settle's additive
``budget_exhausted`` meta key + the flag-gated Continue button):
``budget_exhausted`` meta key + the flag-gated Continue button)
and phase 161 (task 01)'s sanctioned additions (the
``reasoningWrapEl`` turn-local reset — the pagehide partial reads
the live container, like segRuns; the D6 status writes + the D3
pre/post follow in the thinking/tool frame branches; the
``settleReasoning(wrap)`` fold at the done / stop / failed
settle sites — the container's live-path contract, D1/D2/D3/D6):
the full source must match the pin — the ``reask`` path, the
turn-local resets, the SSE contract, the save points, and the
sanctioned additions (the task-01 ``lastUserWrap`` assignment +
the idle-settle mark pass, the task-03 aborted-settle mark pair,
and the phase-160 done-settle pair) included."""
the phase-160 done-settle pair, and the phase-161 live-container
additions) included."""
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
+14 -7
View File
@@ -145,11 +145,13 @@ def test_thinking_event_is_a_first_class_turn_branch() -> None:
def test_thinking_run_helpers_are_idempotent() -> None:
"""Phase 158 (D1/D5 — the phase-109 D15 pin, replaced by owner
direction 2026-10-04): ``ensureThinkingRun`` returns the active
run's block or creates a NEW one OPEN above the .bubble (a frame
of a new kind closes the previous run first); ``closeCurrentRun``
is a no-op without a run and folds the active one (the D15
re-open of the shared block is gone — a post-delta thinking frame
opens a NEW block)."""
run's block or creates a NEW one OPEN (phase 161, D1: appended
into the reasoning container's ``.reasoning-body`` — the
per-run bubble anchor is superseded; a frame of a new kind
closes the previous run first); ``closeCurrentRun`` is a no-op
without a run and folds the active one (the D15 re-open of the
shared block is gone — a post-delta thinking frame opens a NEW
block)."""
js = _js()
fn = js.find("function ensureThinkingRun")
assert fn != -1, "ensureThinkingRun must exist (near addTyping/removeTyping)"
@@ -157,8 +159,13 @@ def test_thinking_run_helpers_are_idempotent() -> None:
assert "closeCurrentRun()" in body, ("a frame of a new kind closes the "
"previous run (D1)")
assert "block.open = true" in body, "D5: created open — the stream is the show"
assert "insertBefore" in body
assert 'querySelector(".bubble")' in body, "the block sits ABOVE the bubble"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in body
), "phase 161 (D1): appended into the container's body (before the bubble)"
assert "insertBefore" not in body, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
fn2 = js.find("function closeCurrentRun")
assert fn2 != -1, "closeCurrentRun must exist"
body2 = js[fn2 : js.find("\n}\n", fn2)]
+31 -16
View File
@@ -140,8 +140,10 @@ def test_tool_lines_render_into_the_bubble_wrap() -> None:
"""Phase 158 (D1): the tool lines render into the run's
disclosure — ``createToolDisclosure`` builds the phase-117
structure (a ``.tool-calls`` list, role=list + accessible name
"Tool calls") and ``ensureToolRun`` inserts it BEFORE the bubble
(D2: above the answer, in arrival order); each line is a
"Tool calls") and ``ensureToolRun`` appends it into the
phase-161 reasoning container's ``.reasoning-body`` (D1: the
container sits ABOVE the answer bubble — the per-run bubble
anchor is superseded; arrival order preserved); each line is a
.tool-call listitem with the exact marks: 🔎 Listing documents /
📄 Reading <code>path</code>. The path goes through textContent
(storage can never inject HTML)."""
@@ -155,8 +157,12 @@ def test_tool_lines_render_into_the_bubble_wrap() -> None:
)
fn2 = js.find("function ensureToolRun")
run = js[fn2 : js.find("\n}\n", fn2)]
assert 'insertBefore(disc, body.querySelector(".bubble"))' in run, (
"the disclosure sits ABOVE the answer, in arrival order"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in run
), (
"phase 161 (D1): the disclosure nests in the container, which "
"sits above the answer (arrival order preserved)"
)
fn = js.find("function appendToolLine")
assert fn != -1, "appendToolLine must exist"
@@ -266,25 +272,34 @@ def test_tool_lines_re_render_on_restore() -> None:
def test_segments_keep_arrival_order_above_the_bubble() -> None:
"""Phase 158 (D2): both run builders insert their element
BEFORE the bubble — later insertions land after earlier ones, so
document order is ARRIVAL order (a tool frame preceding the
first thinking frame lands the scratchpad BELOW the tool lines —
the timeline order; the old "scratchpad on top" anchor chain is
gone)."""
"""Phase 158 (D2) + 161 (D1): both run builders append their
element into the reasoning container's ``.reasoning-body`` —
later appends land after earlier ones, so document order is
ARRIVAL order (a tool frame preceding the first thinking frame
lands the scratchpad BELOW the tool lines — the timeline order;
the old "scratchpad on top" anchor chain is gone). The
container itself sits before the bubble (the ONE anchor)."""
js = _js()
fn = js.find("function ensureThinkingRun")
t = js[fn : js.find("\n}\n", fn)]
fn2 = js.find("function ensureToolRun")
d = js[fn2 : js.find("\n}\n", fn2)]
assert 'insertBefore(block, body.querySelector(".bubble"))' in t, (
"the thinking run inserts before the bubble"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in t
), "the thinking run appends into the container's body"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in d
), "the tool run appends into the container's body"
assert "insertBefore" not in t, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert 'insertBefore(disc, body.querySelector(".bubble"))' in d, (
"the tool run inserts before the bubble"
assert "insertBefore" not in d, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert "??" not in t, ("the old anchor chain is GONE — the bubble is "
"the only anchor")
assert "??" not in t, ("the old anchor chain is GONE — the container "
"append is the only anchor")
assert "block.open = true" in t
assert "disc.open = true" in d
+956
View File
@@ -0,0 +1,956 @@
"""Unit: the phase-161 live reasoning container contract (task 01).
The owner's report (2026-10-05, live chat): the per-run thinking /
tool-call blocks (phase 158) stacked above the answer take up a ton
of space and obscure the answer. The fix (phase 161, owner decisions
D1–D6): ONE ``details.reasoning`` container per brain message wraps
the ENTIRE per-run timeline — created on the turn's FIRST
thinking/tool frame, OPEN from creation until the turn settles
(done / stop / the failed-turn funnel), condensed in its ONE bounded
``.reasoning-body`` live window (320px, task 03) with the
pinned-tail follow (the phase-17 pre-measure convention); the
per-run blocks keep their structure and open/fold behavior — only
their PARENT changed (the per-run bubble anchor is superseded by the
container's ``.reasoning-body`` append, arrival order preserved). The
container's summary carries the D6 status literals: the static
"Reasoning" + a live span ("Thinking…" / "Calling <name>…" / "N
steps"). A turn with no thinking and no tools creates NO container
(byte-identical DOM — D1).
Task 01 pins the LIVE path: the static builder (createElement only,
innerHTML ABSENT), the get-or-create wrapper (open at creation,
before the bubble, module-scope declaration + reset in runTurn — the
pagehide partial reads the live container, like segRuns), the
re-anchored per-run inserts, the ``settleReasoning`` fold at the
THREE settle sites (and NOT at the mid-round delta — D2's core
contract), the D6 status literals, and the D3 pre/post follow split.
Task 02 pins the RESTORE + SHARED path: the collapsed container in
``restoreTimeline`` (the shared task-01 builder, created CLOSED,
inserted ONCE before the bubble; the per-run blocks append into the
container's ``.reasoning-body`` — the per-run bubble anchor is
GONE), the D6 at-rest step count (the incremental contiguous-run
semantics — each thinking entry is ONE run; a contiguous tool group
of N entries is ONE run; the uniform ``${runs} steps`` literal), the
legacy no-segments path (the ``renderStoredMessage`` dispatch
UNCHANGED — a record without ``segments`` never reaches
``restoreTimeline`` — no container, D4), and the ``shared.js``
``addSegments`` parity (the LOCAL parity builder — the shared page
has no import of app.js — created CLOSED, the SAME step-count
semantics, the SAME line bytes, innerHTML ABSENT from both
wrappers).
No ``app/`` logic in this phase (D4 — zero persistence change) — the
behavior lives in ``frontend/assets/app.js`` and is E2E-gated by the
story suite (``tests/e2e/test_reasoning_bubble.py``, task 04). Like
the other frontend-adjacent unit files (the house
read-the-assets-as-text pattern, cf. ``test_thinking_timeline.py``),
this module pins the JS markers the phase depends on.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
SHARED_JS = FRONTEND / "assets" / "shared.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
# The shared-page parity line bytes (the same four label literals
# addSegments mirrors from addToolLines — unchanged by task 02).
PINNED_LABELS_SHARED = (
'line.textContent = "📄 Reading "',
'line.textContent = "🔎 Searching for "',
'line.textContent = "🔎 Listing documents in "',
'line.textContent = "🔎 Listing documents"',
)
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _js_shared() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _rule(css: str, selector: str) -> str:
"""The first ``<selector> { … }`` rule body — the house pin
pattern (the reasoning selectors are unique in the sheet)."""
m = re.search(re.escape(selector) + r" \{([^}]*)\}", css)
assert m, f"styles.css must style {selector}"
return m.group(1)
def _comment_before(css: str, selector: str) -> str:
"""The comment region immediately preceding the rule (the sheet
documents each rule's phase + decision right above it)."""
i = css.find(selector)
assert i != -1, f"styles.css must style {selector}"
return css[max(0, i - 1600) : i]
def _fn(src: str, name: str) -> str:
"""The flat function body (house extraction: first ``\\n}\\n`` after
the definition — the function must stay flat, no nested
declarations)."""
fn = src.find(f"function {name}")
assert fn != -1, f"{name} must exist"
return src[fn : src.find("\n}\n", fn)]
def _branch(js: str, open_type: str, close_type: str) -> str:
"""One turn-handler branch, between the named event types."""
start = js.find(f'ev.type === "{open_type}"')
end = js.find(f'ev.type === "{close_type}"')
assert -1 < start < end, f"the {open_type} branch must exist"
return js[start:end]
# ---------- the static builder (D1/D6) ----------
def test_create_reasoning_wrap_builds_the_container_with_create_element() -> None:
"""D1/D6: ``createReasoningWrap`` builds ``details.reasoning``
wrapping ``summary.reasoning-summary`` (the "Reasoning" literal —
D6's ONLY new static literal — + the EMPTY
``span.reasoning-status`` a frame fills) and the bounded
``.reasoning-body`` window (task 03), with createElement only
(innerHTML ABSENT — the house rule; the literal is a static text
node, no data flows through it). The caller owns open/fold +
insertion."""
body = _fn(_js(), "createReasoningWrap")
assert 'document.createElement("details")' in body
assert 'cont.className = "reasoning"' in body
assert 'document.createElement("summary")' in body
assert 'summary.className = "reasoning-summary"' in body
assert 'summary.textContent = "Reasoning"' in body, (
"D6: the ONLY new static literal"
)
assert 'document.createElement("span")' in body
assert 'status.className = "reasoning-status"' in body
assert "summary.appendChild(status)" in body, (
"the status span rides the summary (after the literal)"
)
assert 'document.createElement("div")' in body
assert 'body.className = "reasoning-body"' in body
assert "cont.appendChild(body)" in body
assert "return cont" in body
assert "innerHTML" not in body, (
"createElement only — innerHTML ABSENT (the house rule)"
)
def test_the_reasoning_status_span_starts_empty() -> None:
"""D6: the ``span.reasoning-status`` is EMPTY until a frame sets
it — the builder never writes a status into the span itself."""
body = _fn(_js(), "createReasoningWrap")
assert "status.textContent" not in body, (
"the span starts empty — a frame (or the settle) fills it"
)
# ---------- the get-or-create wrapper (D1) ----------
def test_ensure_reasoning_wrap_is_get_or_create_open_before_bubble() -> None:
"""D1: ``ensureReasoningWrap`` is get-or-create — an existing
container is returned as-is; otherwise it is created (the shared
builder), OPEN (``open = true`` — open from creation until the
turn settles, D2), inserted BEFORE the bubble (the ONE new
anchor), and stored in the module-scope ``reasoningWrapEl``. The
container is created by the FIRST run's ensure call — no other
call site."""
body = _fn(_js(), "ensureReasoningWrap")
assert "if (reasoningWrapEl) return reasoningWrapEl;" in body, (
"get-or-create: the existing container is returned as-is"
)
assert "createReasoningWrap()" in body, "the shared builder is reused"
assert "cont.open = true" in body, (
"D1: OPEN at creation (it folds at the settle — D2)"
)
assert 'wrap.querySelector(".msg-body")' in body
assert 'body.insertBefore(cont, body.querySelector(".bubble"))' in body, (
"the container sits ABOVE the answer bubble"
)
assert "reasoningWrapEl = cont" in body, (
"stored in the module scope (the pagehide partial reads it)"
)
assert "return cont" in body
def test_the_reasoning_wrap_is_module_scoped_and_reset_in_run_turn() -> None:
"""D1 (module-scope readability): ``reasoningWrapEl`` is declared
at MODULE SCOPE (like segRuns / currentRun — the pagehide partial
reads the live container while a turn is in flight) and is reset
per turn in ``runTurn`` alongside the other turn locals."""
js = _js()
assert "\nlet reasoningWrapEl = null;" in js, (
"module-scope declaration (column 0 — not a function local)"
)
run = _fn(js, "runTurn")
assert "reasoningWrapEl = null;" in run, (
"reset per turn in runTurn — like segRuns"
)
# reset with the other turn locals (the reset block, one place)
assert "segRuns = []" in run
assert "currentRun = null" in run
assert run.find("segRuns = []") < run.find("reasoningWrapEl = null;")
# ---------- the re-anchored per-run inserts (D1) ----------
def test_per_run_inserts_append_into_the_reasoning_body() -> None:
"""D1: the per-run blocks append into the container's
``.reasoning-body`` — ``ensureThinkingRun`` / ``ensureToolRun``
insert via ``ensureReasoningWrap(wrap).querySelector(
".reasoning-body").appendChild(…)`` (arrival order preserved: a
new run appends after the previous run's block). EVERYTHING ELSE
in both functions is unchanged (closeCurrentRun, ``open = true``
D5, the segRuns push, currentRun)."""
js = _js()
t = _fn(js, "ensureThinkingRun")
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in t
), "the thinking run appends into the container's body"
assert "closeCurrentRun()" in t
assert "block.open = true" in t, "D5: the per-run open/fold is unchanged"
assert "segRuns.push(run)" in t
assert "currentRun =" in t
d = _fn(js, "ensureToolRun")
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in d
), "the tool run appends into the container's body"
assert "closeCurrentRun()" in d
assert "disc.open = true" in d, "D5: the per-run open/fold is unchanged"
assert "segRuns.push(run)" in d
def test_the_per_run_bubble_anchor_is_gone() -> None:
"""D1 (the documented anchor supersession): the per-run
``insertBefore(…, .bubble)`` anchor is GONE from
``ensureThinkingRun`` / ``ensureToolRun`` — the container is the
ONE new anchor (it sits before the bubble; the runs nest inside
it). The container itself still anchors on the bubble (one site).
"""
js = _js()
t = _fn(js, "ensureThinkingRun")
d = _fn(js, "ensureToolRun")
assert "insertBefore" not in t, (
"the per-run bubble anchor is gone from ensureThinkingRun"
)
assert "insertBefore" not in d, (
"the per-run bubble anchor is gone from ensureToolRun"
)
er = _fn(js, "ensureReasoningWrap")
assert 'body.insertBefore(cont, body.querySelector(".bubble"))' in er, (
"the ONE anchor: the container before the bubble"
)
# ---------- the settle fold (D2) ----------
def test_settle_reasoning_is_defined_and_folds_the_container() -> None:
"""D2: ``settleReasoning`` removes the container's ``open``
attribute — idempotent; a no-op without a container (the
plain-answer turn settles byte-identically). Wrap-scoped (the
caller's wrap), like closeAllRuns."""
body = _fn(_js(), "settleReasoning")
assert (
'wrap?.querySelector("details.reasoning")?.removeAttribute("open")'
in body
), "the container fold — the open attribute is removed"
assert "reasoningWrapEl" not in body, (
"wrap-scoped (the caller's wrap), like closeAllRuns"
)
def test_settle_reasoning_runs_at_the_three_settle_sites() -> None:
"""D2: the container folds at the THREE settle sites — the
``done`` branch, the stop/abort path, the failed-turn funnel —
alongside ``closeAllRuns(wrap)`` (immediately after it), with
the D6 "N steps" summary (the live run count — ``segRuns``,
module scope)."""
js = _js()
done = _branch(js, "done", "tool_result")
assert "closeAllRuns(wrap)" in done
assert "settleReasoning(wrap)" in done
assert done.find("closeAllRuns(wrap)") < done.find(
"settleReasoning(wrap)"
), "done: settleReasoning sits alongside (after) closeAllRuns"
assert "`${segRuns.length} steps`" in done
# the stop path: the catch's stop branch up to the error else
start = js.find('stoppedByUser || err?.name === "AbortError"')
end = js.find("\n } else {\n", start)
assert -1 < start < end, "the stop path must exist"
stop = js[start:end]
assert "closeAllRuns(wrap)" in stop
assert "settleReasoning(wrap)" in stop
assert stop.find("closeAllRuns(wrap)") < stop.find(
"settleReasoning(wrap)"
), "stop: settleReasoning sits alongside (after) closeAllRuns"
assert "`${segRuns.length} steps`" in stop
ff = _fn(js, "finalizeFailedTurn")
assert "closeAllRuns(wrap)" in ff
assert "settleReasoning(wrap)" in ff
assert ff.find("closeAllRuns(wrap)") < ff.find(
"settleReasoning(wrap)"
), "failed: settleReasoning sits alongside (after) closeAllRuns"
assert "`${segRuns.length} steps`" in ff
def test_settle_reasoning_does_not_fold_at_the_mid_round_delta() -> None:
"""D2 (the TODO's core contract): a mid-round ``delta`` FOLDS the
active run (``closeCurrentRun`` — phase 158 D5, unchanged) but
the container STAYS OPEN — ``settleReasoning`` is NOT called in
the delta branch (the container collapses only once the final
response has been returned and the session has stopped)."""
js = _js()
delta = _branch(js, "delta", "done")
assert "closeCurrentRun()" in delta, (
"the active run still folds at the first delta (phase 158 D5)"
)
assert "settleReasoning" not in delta, (
"the container STAYS OPEN through a mid-round delta (D2)"
)
# ---------- the D6 status literals ----------
def test_the_d6_status_literals_are_pinned() -> None:
"""D6: the live status literals — "Thinking…" (the thinking frame
branch), "Calling <name>…" (the tool frame branch — the display
name the tool line already shows, the phase-70 legacy-name
mapping already produced it) — and "N steps" (the settle sites,
pinned in the settle test) all write to ``.reasoning-status``
through the ``setReasoningStatus`` setter (a no-op without a
container — a plain-answer turn never writes one)."""
js = _js()
thinking = _branch(js, "thinking", "tool")
assert '"Thinking…"' in thinking, "the live 'Thinking…' suffix"
tool = _branch(js, "tool", "retry")
assert "`Calling ${name}…`" in tool, (
"'Calling <name>…' — the display name the tool line shows"
)
assert "`${segRuns.length} steps`" in js, "the settle 'N steps' count"
assert js.count('setReasoningStatus(`${segRuns.length} steps`)') == 3, (
"the 'N steps' template at the three settle sites"
)
def test_the_status_setter_is_the_no_op_guarded_chain() -> None:
"""D6: the status writes go through ``setReasoningStatus`` — a
direct ``reasoningWrapEl?.…()?.textContent = x`` left-hand side
is a syntax error (an LHS allows at most one trailing ``?.``
member — not a call), which kills the module at parse time, so
the setter reads the span into a LOCAL reference and writes it
only when the container exists (a no-op without a container)."""
js = _js()
setter = _fn(js, "setReasoningStatus")
assert 'reasoningWrapEl?.querySelector(".reasoning-status")' in setter
assert "el.textContent = text" in setter, (
"the span is filled by a frame (or the settle)"
)
assert 'reasoning-status")?.textContent' not in js, (
"the multi-optional LHS assignment is GONE (it is a syntax "
"error — the parse-time module killer)"
)
assert js.count("setReasoningStatus(") == 6, (
"one definition + exactly the five status sites "
"(thinking / tool / done / stop / failed)"
)
def test_the_status_lands_after_the_run_ensures_the_container() -> None:
"""D6: the status is set AFTER ``ensureThinkingRun`` /
``ensureToolRun`` (the container exists by then —
``ensureReasoningWrap`` runs inside the run ensure), so the first
frame's status lands on a live container."""
js = _js()
thinking = _branch(js, "thinking", "tool")
assert thinking.find("ensureThinkingRun(wrap)") >= 0
assert thinking.find("ensureThinkingRun(wrap)") < thinking.find(
'"Thinking…"'
), "the thinking status follows the run's ensure"
tool = _branch(js, "tool", "retry")
assert tool.find("ensureToolRun(wrap)") >= 0
assert tool.find("ensureToolRun(wrap)") < tool.find(
"`Calling ${name}…`"
), "the tool status follows the run's ensure"
# ---------- the D3 live follow ----------
def test_the_reasoning_follow_band_is_pinned_at_32px() -> None:
"""D3: ``REASONING_NEAR_BOTTOM_PX = 32`` — the same 32px band as
the phase-17 per-block window (exported — the unit-pin
convention, same pattern as TURN_TIMEOUT_MS). The pin check is
the phase-17 pre-measure convention (the distance-to-bottom
form, mirrored)."""
js = _js()
assert "export const REASONING_NEAR_BOTTOM_PX = 32;" in js, (
"the band is the phase-17 32px threshold, exported"
)
pin = _fn(js, "isReasoningPinned")
assert "el.scrollHeight - REASONING_NEAR_BOTTOM_PX" in pin
assert "el.scrollTop + el.clientHeight" in pin
def test_the_follow_is_the_phase_17_pre_update_post_append_split() -> None:
"""D3: the pre-update capture (BEFORE the chunk/line grows the
content — the phase-17 pre-measure convention) and the
post-append apply (``el.scrollTop = el.scrollHeight``) — called
from BOTH the thinking frame branch (pre: before the block
re-render; post: after it) and the tool frame branch (pre: before
``appendToolLine``; post: after it), on
``reasoningWrapEl?.querySelector(".reasoning-body")`` (a no-op
when absent — the plain-answer turn never creates one)."""
js = _js()
scroll = _fn(js, "scrollReasoningToBottom")
assert "el.scrollTop = el.scrollHeight" in scroll, "the post-append apply"
thinking = _branch(js, "thinking", "tool")
pre_t = thinking.find("isReasoningPinned(")
render_t = thinking.find("textEl.innerHTML = renderMarkdown")
post_t = thinking.find("scrollReasoningToBottom(")
assert 0 < pre_t < render_t < post_t, (
"thinking: the pre capture is BEFORE the re-render, the "
"post apply AFTER it"
)
assert 'reasoningWrapEl?.querySelector(".reasoning-body")' in thinking
tool = _branch(js, "tool", "retry")
pre = tool.find("isReasoningPinned(")
append = tool.find("appendToolLine(")
post = tool.find("scrollReasoningToBottom(")
assert 0 < pre < append < post, (
"tool: the pre capture is BEFORE the line append, the post "
"apply AFTER it"
)
assert 'reasoningWrapEl?.querySelector(".reasoning-body")' in tool
def test_the_existing_per_block_follow_is_untouched() -> None:
"""D3 (harmless): the EXISTING per-block
``isThinkingNearBottom(textEl)`` follow is unchanged — inside the
container the ``.thinking-text`` is no longer scrollable (task
03's ``max-height: none``), so it no-ops there; the legacy
no-container path keeps its behavior, and it costs nothing."""
js = _js()
thinking = _branch(js, "thinking", "tool")
assert "isThinkingNearBottom(textEl)" in thinking
assert "textEl.scrollTop = textEl.scrollHeight" in thinking
# ---------- task 02: the collapsed restore wrapper (app.js) ----------
def test_restore_creates_the_container_closed_once_before_the_bubble() -> None:
"""D5: ``restoreTimeline`` creates the container via the SHARED
task-01 builder, created CLOSED (``cont.open = false`` — the
restore renders collapsed; explicit for the pin), and inserted
ONCE before the bubble (a get-or-create shape — a single
``details.reasoning`` per message: exactly one builder call and
one container insertion). The ``.reasoning-body`` window is
referenced for the per-run appends."""
body = _fn(_js(), "restoreTimeline")
assert "createReasoningWrap()" in body, "the shared task-01 builder is reused"
assert body.count("createReasoningWrap()") == 1, (
"the container is created ONCE per message (a get-or-create shape)"
)
assert "cont.open = false" in body, (
"D5: the restore renders collapsed (explicit for the pin)"
)
assert "body.insertBefore(cont, bubble)" in body, (
"the container sits ABOVE the answer bubble (the ONE anchor)"
)
assert body.count("body.insertBefore(cont, bubble)") == 1, "inserted ONCE"
assert 'cont.querySelector(".reasoning-body")' in body, (
"the .reasoning-body window is the per-run append target"
)
def test_restore_per_run_blocks_append_into_the_reasoning_body() -> None:
"""D1 (the documented anchor supersession): the per-run blocks
append into the container's ``.reasoning-body`` — the per-run
``insertBefore(…, bubble)`` anchor is GONE from
``restoreTimeline`` (the container sits before the bubble; the
runs nest inside it, arrival order preserved). EVERYTHING ELSE
in the loop is unchanged (the per-run create, the closed state,
the tool-run seal, the well-formedness guards, the truncation
note)."""
body = _fn(_js(), "restoreTimeline")
assert "runsEl.appendChild(block)" in body, (
"the thinking block appends into the container's body"
)
assert "runsEl.appendChild(toolDisc)" in body, (
"the tool disclosure appends into the container's body"
)
assert "insertBefore(block" not in body, (
"the per-run bubble anchor is GONE from restoreTimeline (thinking)"
)
assert "insertBefore(toolDisc" not in body, (
"the per-run bubble anchor is GONE from restoreTimeline (tool)"
)
# the per-run create + the closed state are unchanged.
assert 'summary.textContent = "Thinking"' in body, (
"the per-run thinking block structure is unchanged"
)
assert "block.open = true" not in body, "D5: created COLLAPSED (unchanged)"
assert "toolDisc.open = false" in body
assert "if (toolDisc) toolDisc = null" in body, "the tool-run seal is unchanged"
def test_restore_computes_the_step_count_incrementally() -> None:
"""D6: the at-rest step count is computed INCREMENTALLY inside
the loop — matching the LIVE ``segRuns.length`` semantics
(contiguous runs, NOT entry count): a ``thinking`` entry after a
tool group starts a NEW run (count+1); two ADJACENT thinking
entries are TWO runs (each thinking entry counts, the increment
is NOT gated on the previous kind); a tool group of N entries is
ONE run (count+1 only at the group's first entry, i.e. when
``toolDisc`` was null before it created it). After the loop,
``.reasoning-status`` is set to ``"${runs} steps"`` (the D6
literal is UNIFORM — no singular/plural special-case)."""
body = _fn(_js(), "restoreTimeline")
assert "runs = 0" in body, "the step counter starts at zero"
# the thinking branch increments per thinking entry.
think_i = body.find('entry.kind === "thinking"')
tool_i = body.find('entry.kind === "tool"')
assert -1 < think_i < tool_i
think_branch = body[think_i:tool_i]
assert "runs += 1" in think_branch, (
"a thinking entry (after a tool group) starts a NEW run"
)
# the increment is NOT gated on a previous-kind check — two
# adjacent thinking entries are TWO runs (the increment is
# unconditional within the thinking branch).
inc_i = think_branch.find("runs += 1")
inc_line = think_branch.splitlines()[think_branch.count("\n", 0, inc_i)]
assert not inc_line.lstrip().startswith("if ("), (
"two adjacent thinking entries are TWO runs (no kind gate)"
)
# the tool branch increments only at a NEW contiguous group
# (count+1 when toolDisc was null before it created it) — a
# group of N entries is ONE run.
assert "if (!toolDisc)" in body, "the contiguous tool-group guard"
tool_branch = body[tool_i:]
assert "runs += 1" in tool_branch, "a tool group's first entry is ONE run"
# exactly ONE tool increment — at the group's first entry
# (inside the !toolDisc guard); the group's other entries do
# not increment.
assert tool_branch.count("runs += 1") == 1, (
"exactly ONE tool increment — at the group's first entry "
"(a group of N entries is ONE run)"
)
# the uniform D6 literal (no singular/plural special-case).
assert 'textContent = `${runs} steps`' in body, (
"the D6 literal is UNIFORM — `${runs} steps`"
)
assert "steps`" in body
assert "runs} step`" not in body, "no singular special-case"
def test_restore_sets_the_status_after_the_loop() -> None:
"""D6: the ``.reasoning-status`` is filled with the at-rest
count AFTER the loop (the count is complete) — written to the
container's ``.reasoning-status`` span via the ``cont``
reference (the container the loop appended into)."""
body = _fn(_js(), "restoreTimeline")
loop_end = body.rfind("for (const entry of segments")
# the status write is after the loop's last append and before the
# function's close.
status_i = body.find('.reasoning-status")')
assert status_i != -1
# the status write uses the runs counter and the uniform literal.
status_line_region = body[status_i : status_i + 80]
assert "runs} steps" in status_line_region
# it is positioned after the loop (the count is complete).
assert loop_end < status_i, "the status is set AFTER the loop"
def test_the_restore_dispatch_keeps_the_legacy_path_untouched() -> None:
"""D4: the ``renderStoredMessage`` dispatch is UNCHANGED — a
record WITHOUT ``segments`` never reaches ``restoreTimeline``
(the legacy path restores byte-identically — no container); a
record WITH segments takes the ``restoreTimeline`` path (which
now wraps in the collapsed container). The dispatch comment
cites the container."""
js = _js()
fn = js.find("function renderStoredMessage")
end = js.find("function restoreConversation")
assert -1 < fn < end
body = js[fn:end]
assert "Array.isArray(m.segments) && m.segments.length" in body, (
"the dispatch guard is unchanged"
)
assert "restoreTimeline(wrap, m.segments)" in body, (
"the segments path renders restoreTimeline"
)
# the legacy path (the else branch) creates NO container.
else_i = body.find("} else {")
legacy = body[else_i:]
assert "createReasoningWrap" not in legacy, (
"the legacy path (no segments) creates NO container (D4)"
)
# the dispatch comment cites the container (the segments path
# wraps in the collapsed container).
assert "Phase 161" in body, "the dispatch comment cites the container"
# ---------- task 02: the shared-page parity wrapper (shared.js) ----------
def test_shared_create_reasoning_wrap_is_the_local_parity_builder() -> None:
"""D1/D6 (the shared page has NO import of app.js):
``createReasoningWrap`` in shared.js is a LOCAL parity builder
mirroring the app.js one — the same
``reasoning`` / ``reasoning-summary`` / ``reasoning-status`` /
``reasoning-body`` classes, the ``Reasoning`` literal via
textContent, the EMPTY status span, createElement only
(innerHTML ABSENT — the house rule; the literal is a static text
node, no data flows through it)."""
body = _fn(_js_shared(), "createReasoningWrap")
assert 'document.createElement("details")' in body
assert 'cont.className = "reasoning"' in body
assert 'document.createElement("summary")' in body
assert 'summary.className = "reasoning-summary"' in body
assert 'summary.textContent = "Reasoning"' in body, (
"the Reasoning literal (D6 — parity with app.js)"
)
assert 'document.createElement("span")' in body
assert 'status.className = "reasoning-status"' in body
assert "summary.appendChild(status)" in body
assert 'document.createElement("div")' in body
assert 'body.className = "reasoning-body"' in body
assert "cont.appendChild(body)" in body
assert "return cont" in body
assert "status.textContent" not in body, (
"the span starts empty — the at-rest count fills it"
)
assert "innerHTML" not in body, (
"createElement only — innerHTML ABSENT (the house rule)"
)
def test_shared_add_segments_creates_the_container_closed_once_before_the_bubble() -> None:
"""D5: ``addSegments`` creates the container via the LOCAL parity
builder, created CLOSED (``cont.open = false`` — the
pure-render convention; explicit for the pin), and inserted ONCE
before the bubble (the shared page's EXISTING insertion
convention — mirror the ``addSegments``/``addToolLines``
anchor). A get-or-create shape: exactly one builder call and one
container insertion per message."""
body = _fn(_js_shared(), "addSegments")
assert "createReasoningWrap()" in body, "the local parity builder is reused"
assert body.count("createReasoningWrap()") == 1, (
"the container is created ONCE per message (a get-or-create shape)"
)
assert "cont.open = false" in body, (
"D5: created CLOSED — the pure-render convention (explicit for the pin)"
)
assert "body.insertBefore(cont, bubble)" in body, (
"inserted before the bubble (the shared page's existing convention)"
)
assert body.count("body.insertBefore(cont, bubble)") == 1, "inserted ONCE"
assert 'cont.querySelector(".reasoning-body")' in body, (
"the .reasoning-body window is the per-run append target"
)
def test_shared_add_segments_per_run_blocks_append_into_the_reasoning_body() -> None:
"""D1 (the documented anchor supersession): the per-run blocks
append into the container's ``.reasoning-body`` — the per-run
``insertBefore(…, bubble)`` anchor is GONE from ``addSegments``
(the container sits before the bubble; the runs nest inside it,
arrival order preserved). The LINE BYTES mirror ``addToolLines``
— UNCHANGED (the four label literals, the phase-95 truncation
note, the phase-139/146 suffixes)."""
body = _fn(_js_shared(), "addSegments")
assert "runsEl.appendChild(block)" in body, (
"the thinking block appends into the container's body"
)
assert "runsEl.appendChild(toolDisc)" in body, (
"the tool disclosure appends into the container's body"
)
assert "insertBefore(block" not in body, (
"the per-run bubble anchor is GONE from addSegments (thinking)"
)
assert "insertBefore(toolDisc" not in body, (
"the per-run bubble anchor is GONE from addSegments (tool)"
)
# the line bytes mirror addToolLines — the pinned label literals.
for label in PINNED_LABELS_SHARED:
assert label in body, f"pinned label literal changed: {label!r}"
assert "truncated-note" in body, "the phase-95 note is unchanged"
assert "readRangeSuffix(entry.offset, entry.limit)" in body, (
"the phase-139 suffix is unchanged"
)
def test_shared_add_segments_computes_the_step_count_parity() -> None:
"""D6 (the SAME incremental step count as ``restoreTimeline`` —
a parity copy, not an import): a ``thinking`` entry after a tool
group starts a NEW run (count+1); two ADJACENT thinking entries
are TWO runs (the increment is NOT kind-gated); a tool group of
N entries is ONE run (count+1 only at the group's first entry).
After the loop, ``.reasoning-status`` is set to the UNIFORM
``"${runs} steps"`` literal."""
body = _fn(_js_shared(), "addSegments")
assert "runs = 0" in body, "the step counter starts at zero"
think_i = body.find('entry.kind === "thinking"')
tool_i = body.find('entry.kind === "tool"')
assert -1 < think_i < tool_i
think_branch = body[think_i:tool_i]
assert "runs += 1" in think_branch, (
"a thinking entry (after a tool group) starts a NEW run"
)
inc_i = think_branch.find("runs += 1")
inc_line = think_branch.splitlines()[think_branch.count("\n", 0, inc_i)]
assert not inc_line.lstrip().startswith("if ("), (
"two adjacent thinking entries are TWO runs (no kind gate)"
)
tool_branch = body[tool_i:]
assert tool_branch.count("runs += 1") == 1, (
"exactly ONE tool increment — at the group's first entry "
"(a group of N entries is ONE run)"
)
assert "if (!toolDisc)" in tool_branch, "the contiguous tool-group guard"
assert 'textContent = `${runs} steps`' in body, (
"the D6 literal is UNIFORM — parity with app.js"
)
def test_the_shared_dispatch_keeps_the_legacy_path_untouched() -> None:
"""D4: the ``renderSharedMessage`` dispatch is UNCHANGED — a
record WITHOUT ``segments`` renders the legacy
``addThinkingBlock`` + ``addToolLines`` path (byte-identical —
no container); a record WITH segments renders ``addSegments``
(which wraps in the collapsed container)."""
body = _fn(_js_shared(), "renderSharedMessage")
assert "Array.isArray(m.segments) && m.segments.length" in body, (
"the dispatch guard is unchanged"
)
assert "addSegments(wrap, m.segments)" in body, (
"the segments path renders addSegments"
)
else_i = body.find("} else {")
legacy = body[else_i:]
assert "createReasoningWrap" not in legacy, (
"the legacy path (no segments) creates NO container (D4)"
)
assert "addThinkingBlock(wrap, m.thinking)" in body
assert "addToolLines(wrap, m.tools)" in body
def test_inner_html_is_absent_from_both_wrappers() -> None:
"""The house rule: ``innerHTML`` is ABSENT from BOTH the
restore wrapper's and the shared wrapper's container builders
(createElement/textContent only — the ``Reasoning`` literal is a
static text node; the ONLY HTML write in each timeline function
remains the escape-first renderMarkdown into
``.thinking-text``)."""
app_builder = _fn(_js(), "createReasoningWrap")
assert "innerHTML" not in app_builder, (
"innerHTML ABSENT from the app.js builder (the house rule)"
)
shared_builder = _fn(_js_shared(), "createReasoningWrap")
assert "innerHTML" not in shared_builder, (
"innerHTML ABSENT from the shared.js builder (the house rule)"
)
# the ONLY HTML write in each timeline function is the
# escape-first renderMarkdown into .thinking-text.
for fn_name, js in (("restoreTimeline", _js()), ("addSegments", _js_shared())):
body = _fn(js, fn_name)
writes = re.findall(r"\b(\w+)\.innerHTML\s*=\s*([^;\n]+)", body)
assert len(writes) == 1, (
f"{fn_name}: exactly ONE innerHTML write (the structure is "
f"createElement): {writes}"
)
assert writes[0][0] == "textEl", "the single write is into .thinking-text"
assert writes[0][1].strip() == "renderMarkdown(entry.text)", (
"the single write is the escape-first renderMarkdown"
)
# ---------- task 03: the container's CSS (styles.css) ----------
def test_the_reasoning_container_rule_is_the_family_treatment() -> None:
"""Task 03 item 1: ``details.reasoning`` carries the
``details.thinking`` (phase-17) family treatment — the SAME
surface / line / brand-soft / radius / margin tokens +
``overflow: hidden`` (the corner radius clips the scrollable
body). The comment block cites this phase + D3, and the block
sits ADJACENT to the ``details.thinking`` family — after the
``.thinking-text`` margin rule, before the phase-117
``.tool-calls-disclosure`` comment."""
css = _css()
body = _rule(css, "details.reasoning")
assert "background: var(--surface)" in body
assert "border: 1px solid var(--line)" in body
assert "border-left: 3px solid var(--brand-soft)" in body
assert "border-radius: var(--radius-sm)" in body
assert "margin: 0 0 0.5rem" in body
assert "overflow: hidden" in body
comment = _comment_before(css, "details.reasoning {")
assert "Phase 161" in comment, "the comment cites this phase"
assert "D3" in comment, "the comment cites the D3 window decision"
# adjacency: after the family's last rule, before phase 117.
margins_i = css.find("details.thinking .thinking-text ul")
reason_i = css.find("details.reasoning {")
p117_i = css.find("/* Phase 117: the tool-call record")
assert -1 < margins_i < reason_i < p117_i, (
"the family blocks stay adjacent"
)
def test_the_reasoning_summary_rule_is_the_family_convention() -> None:
"""Task 03 item 2: ``summary.reasoning-summary`` — the
``details.thinking`` summary convention: the flex row, the
WCAG ≥44px target (``min-height: 44px``), the family
``--brand-ink`` (8.7:1 on ``--surface`` — AA), the webkit
marker suppressed, and the house 3px ``:focus-visible`` ring.
"""
css = _css()
body = _rule(css, "summary.reasoning-summary")
assert "display: flex" in body
assert "align-items: center" in body
assert "gap: 0.5rem" in body
assert "padding: 0.5rem 0.75rem" in body
assert "min-height: 44px" in body, "the WCAG ≥44px target"
assert "color: var(--brand-ink)" in body, ("the family AA token")
assert "font-size: 0.9rem" in body
assert "cursor: pointer" in body
assert "list-style: none" in body
# the webkit marker suppression.
assert (
"summary.reasoning-summary::-webkit-details-marker {"
" display: none; }" in css
)
# the house 3px :focus-visible ring.
focus = _rule(css, "summary.reasoning-summary:focus-visible")
assert "outline: 3px solid var(--brand)" in focus
assert "outline-offset: 2px" in focus
def test_the_reasoning_chevron_rotates_when_open() -> None:
"""Task 03 item 2: the CSS chevron ``▸`` (``::before``,
``display: inline-block``) with the 0.15s ease transform
transition, rotating 90° when the container is ``[open]`` —
the ``details.thinking`` convention."""
css = _css()
chevron = _rule(css, "summary.reasoning-summary::before")
assert 'content: "▸"' in chevron
assert "display: inline-block" in chevron
assert "transition: transform 0.15s ease" in chevron
open_rule = _rule(css, "details.reasoning[open] summary.reasoning-summary::before")
assert "transform: rotate(90deg)" in open_rule
def test_the_reasoning_status_rule_uses_the_aa_token() -> None:
"""Task 03 item 3: ``.reasoning-status`` (the D6 live suffix
span, inside the summary row) — ``--ink-soft`` (6.9:1 on
``--surface`` — AA), ``font-size: 0.8rem``,
``line-height: 1.4``, ``margin-left: 0.25rem`` (text + color,
never color alone — B5)."""
body = _rule(_css(), ".reasoning-status")
assert "color: var(--ink-soft)" in body, ("the AA token")
assert "font-size: 0.8rem" in body
assert "line-height: 1.4" in body
assert "margin-left: 0.25rem" in body
def test_the_reasoning_body_is_the_one_bounded_live_window() -> None:
"""Task 03 item 4: ``.reasoning-body`` — the ONE bounded live
window (D3): ``max-height: 320px`` (the phase-17 window
convention) + ``overflow-y: auto`` (user-scrollable — the
pinned-tail follow in app.js) + the family padding. The comment
cites D3 + the pinned-tail follow."""
css = _css()
body = _rule(css, ".reasoning-body")
assert "max-height: 320px" in body, ("the phase-17 320px window")
assert "overflow-y: auto" in body, ("user-scrollable (D3)")
assert "padding: 0 0.75rem 0.75rem" in body
comment = _comment_before(css, ".reasoning-body {")
assert "D3" in comment, ("the comment cites the D3 window")
assert "pinned-tail" in comment, (
"the comment cites the pinned-tail follow"
)
def test_the_inner_runs_are_compacted_to_one_scroll_surface() -> None:
"""Task 03 item 5: the inner-run compacting (D3 — ONE scroll
surface, the nested border weight removed):
``details.reasoning details.thinking`` is de-bordered (a 2px
``--line`` indent keeps the family rhythm, transparent
background, tight margins); the inner ``.thinking-text``
320px window is DISABLED (``max-height: none`` +
``overflow-y: visible``) so the container's window is the single
scroll surface; the nested ``.tool-calls-disclosure`` keeps its
compact margin (the documented-intent rule)."""
css = _css()
run = _rule(css, "details.reasoning details.thinking")
assert "border: 0" in run, ("the nested border weight is removed")
assert "border-left: 2px solid var(--line)" in run
assert "border-radius: 0" in run
assert "margin: 0.25rem 0" in run
assert "background: transparent" in run
inner_text = _rule(css, "details.reasoning details.thinking .thinking-text")
assert "max-height: none" in inner_text, (
"the inner 320px window is disabled"
)
assert "overflow-y: visible" in inner_text
disc = _rule(css, "details.reasoning .tool-calls-disclosure")
assert "margin: 0.25rem 0" in disc
def test_the_reasoning_still_is_in_the_house_reduced_motion_block() -> None:
"""Task 03 item 6: the chevron rotation stills under
``prefers-reduced-motion`` — the still is in the SAME media
block as the phase-17 / phase-117 stills (the house chat/stream
block, the one that stills ``details.thinking``)."""
css = _css()
i = css.find("details.thinking summary::before { transition: none; }")
assert i != -1, ("the phase-17 still must exist (the reference)")
start = css.rfind("@media (prefers-reduced-motion: reduce) {", 0, i)
end = css.find("\n}", start)
assert -1 < start < end, ("the stills must be in a media block")
block = css[start:end]
assert (
"summary.reasoning-summary::before { transition: none; }" in block
), ("the reasoning chevron stills with the family stills")
def test_the_legacy_no_container_rules_are_untouched() -> None:
"""The completion gate: the ``details.thinking`` /
``.tool-calls-disclosure`` rules OUTSIDE the container are
unchanged — the legacy no-container path renders byte-identically
(the family block, the 320px ``.thinking-text`` window,
the phase-117 disclosure + its chevron)."""
css = _css()
fam = _rule(css, "details.thinking")
assert "background: var(--surface)" in fam
assert "border-left: 3px solid var(--brand-soft)" in fam
assert "overflow: hidden" in fam
text = _rule(css, "details.thinking .thinking-text")
assert "max-height: 320px" in text, ("the legacy window is unchanged")
assert "overflow-y: auto" in text
# the phase-117 disclosure rules are unchanged (one base rule —
# line-anchored: the nested details.reasoning selector is a
# different rule).
assert len(re.findall(r"(?m)^\.tool-calls-disclosure \{", css)) == 1
disc = _rule(css, ".tool-calls-disclosure")
assert "margin: 0.25rem 0" in disc
chevron = _rule(css, ".tool-calls-summary::before")
assert 'content: "▸"' in chevron
assert "margin-right: 0.5rem" in chevron
+91 -24
View File
@@ -78,12 +78,15 @@ def _branch(js: str, open_type: str, close_type: str) -> str:
# ---------- the run builders (D1/D2/D3/D5) ----------
def test_ensure_thinking_run_builds_a_new_open_block_before_the_bubble() -> None:
"""D1/D2/D3: ``ensureThinkingRun`` returns the active thinking
def test_ensure_thinking_run_builds_a_new_open_block_in_the_container() -> None:
"""D1/D3/D5: ``ensureThinkingRun`` returns the active thinking
run's block or — a NEW ``details.thinking`` (the phase-17 static
structure via ``createThinkingBlock``) created OPEN (D5),
inserted BEFORE the ``.bubble`` (D2 — the anchor is the bubble,
in arrival order). The plain ``Thinking`` literal (D3 — the
appended into the phase-161 reasoning container's
``.reasoning-body`` (D1 — the container sits before the bubble,
a new run appends after the previous run's block, so arrival
order is preserved; the per-run bubble anchor is superseded). The
plain ``Thinking`` literal (D3 — the
phase-17 literal, not a numbered variant) rides the static
template; the fresh run joins segRuns + currentRun."""
js = _js()
@@ -96,8 +99,15 @@ def test_ensure_thinking_run_builds_a_new_open_block_before_the_bubble() -> None
)
assert "createThinkingBlock()" in body, "the shared builder is reused"
assert "block.open = true" in body, "D5: created OPEN while its frames stream"
assert 'insertBefore(block, body.querySelector(".bubble"))' in body, (
"D2: the anchor is the BUBBLE — above it, in arrival order"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in body
), (
"phase 161 (D1): appended into the container's body — the "
"container sits above the bubble, arrival order preserved"
)
assert "insertBefore" not in body, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert 'kind: "thinking"' in body
assert "segRuns.push(run)" in body
@@ -129,22 +139,33 @@ def test_create_thinking_block_is_the_phase_17_static_template_without_data() ->
)
def test_ensure_tool_run_builds_a_new_open_disclosure_before_the_bubble() -> None:
"""D1/D2/D5: ``ensureToolRun`` returns the active tool run's
def test_ensure_tool_run_builds_a_new_open_disclosure_in_the_container() -> None:
"""D1/D5: ``ensureToolRun`` returns the active tool run's
disclosure or — a NEW ``details.tool-calls-disclosure`` (the
phase-117 structure via ``createToolDisclosure`` —
summary.tool-calls-summary + the .tool-calls list, role=list +
aria-label "Tool calls") created OPEN (D5), inserted BEFORE the
bubble (D2, in arrival order). One disclosure per CONTIGUOUS
tool-call run; the per-run "Tool call(s) (N)" count rides
appendToolLine's summary update."""
aria-label "Tool calls") created OPEN (D5), appended into the
phase-161 reasoning container's ``.reasoning-body`` (D1 — the
container sits before the bubble, arrival order preserved; the
per-run bubble anchor is superseded). One disclosure per
CONTIGUOUS tool-call run; the per-run "Tool call(s) (N)" count
rides appendToolLine's summary update."""
js = _js()
body = _fn(js, "ensureToolRun")
assert 'currentRun?.kind === "tool"' in body
assert "closeCurrentRun()" in body
assert "createToolDisclosure()" in body
assert "disc.open = true" in body, "D5: created OPEN while its frames stream"
assert 'insertBefore(disc, body.querySelector(".bubble"))' in body
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in body
), (
"phase 161 (D1): appended into the container's body — the "
"container sits above the bubble, arrival order preserved"
)
assert "insertBefore" not in body, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert 'kind: "tool"' in body
assert "segRuns.push(run)" in body
disc = _fn(js, "createToolDisclosure")
@@ -566,8 +587,11 @@ def test_restore_timeline_builds_the_collapsed_per_run_structure() -> None:
created closed), and CONSECUTIVE ``kind:"tool"`` entries group
into ONE ``details.tool-calls-disclosure`` (the shared
phase-117 builder — ``createToolDisclosure``, innerHTML ABSENT —
created closed), each inserted BEFORE the bubble (D2, arrival
order). The per-run count rides ``appendToolLine``'s summary
created closed). Phase 161 (task 02, D5/D6): the per-run blocks
nest inside ONE details.reasoning container — the shared
task-01 builder, created CLOSED and inserted ONCE before the
bubble (the per-run bubble anchor is superseded; arrival order
preserved). The per-run count rides ``appendToolLine``'s summary
update; a kind change seals the tool run (one disclosure per
run, D1)."""
body = _fn(_js(), "restoreTimeline")
@@ -588,8 +612,29 @@ def test_restore_timeline_builds_the_collapsed_per_run_structure() -> None:
assert "if (toolDisc) toolDisc = null" in body, (
"a thinking entry seals the tool run — ONE disclosure per run (D1)"
)
assert "body.insertBefore(block, bubble)" in body, "D2: above the bubble"
assert "body.insertBefore(toolDisc, bubble)" in body, "D2: above the bubble"
# Phase 161 (task 02, D5/D6): the per-run timeline nests inside
# ONE details.reasoning container — the shared task-01 builder,
# created CLOSED and inserted ONCE before the bubble (the
# per-run bubble anchor is superseded; arrival order preserved).
assert "createReasoningWrap()" in body, "the shared task-01 builder is reused"
assert "cont.open = false" in body, (
"D5: the restore renders collapsed (explicit for the pin)"
)
assert "body.insertBefore(cont, bubble)" in body, (
"the container sits above the bubble — the ONE anchor"
)
assert "runsEl.appendChild(block)" in body, (
"D1: the per-run block nests into the container's body"
)
assert "runsEl.appendChild(toolDisc)" in body, (
"D1: the per-run disclosure nests into the container's body"
)
assert "insertBefore(block" not in body, (
"the per-run bubble anchor is GONE (phase 161 D5)"
)
assert "insertBefore(toolDisc" not in body, (
"the per-run bubble anchor is GONE (phase 161 D5)"
)
assert "for (const entry of segments" in body, "iterate the runs in order"
@@ -718,10 +763,12 @@ def test_add_segments_builds_the_collapsed_parity_timeline() -> None:
LINE BYTES as ``addToolLines`` (the four pinned label literals
+ the phase-70 legacy names + the phase-139 readRangeSuffix +
the phase-95 truncation note), the per-run
``Tool call(s) (N)`` count, and insertion BEFORE the bubble
(D2, arrival order) — all created CLOSED (no ``open = true``
anywhere; the addThinkingBlock / addToolLines collapsed
convention)."""
``Tool call(s) (N)`` count, and nesting inside the phase-161
details.reasoning container (the local parity builder, created
CLOSED and inserted ONCE before the bubble — the per-run bubble
anchor is superseded, arrival order preserved) — all created
CLOSED (no ``open = true`` anywhere; the addThinkingBlock /
addToolLines collapsed convention)."""
body = _fn(_js_shared(), "addSegments")
assert "document.createElement(\"details\")" in body, "the structure is createElement"
assert 'block.className = "thinking"' in body
@@ -746,9 +793,29 @@ def test_add_segments_builds_the_collapsed_parity_timeline() -> None:
assert '"truncated-note"' in body, "the phase-95 note parity"
# the per-run count template.
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body
# D2: above the bubble, in arrival order.
assert "body.insertBefore(block, bubble)" in body
assert "body.insertBefore(toolDisc, bubble)" in body
# Phase 161 (task 02, D5): the per-run timeline nests inside ONE
# details.reasoning container — the local parity builder, created
# CLOSED and inserted ONCE before the bubble (the per-run bubble
# anchor is superseded; arrival order preserved).
assert "createReasoningWrap()" in body, "the local parity builder is reused"
assert "cont.open = false" in body, (
"D5: collapsed (the pure-render convention)"
)
assert "body.insertBefore(cont, bubble)" in body, (
"inserted ONCE before the bubble"
)
assert "runsEl.appendChild(block)" in body, (
"D1: the per-run block nests into the container's body"
)
assert "runsEl.appendChild(toolDisc)" in body, (
"D1: the per-run disclosure nests into the container's body"
)
assert "insertBefore(block" not in body, (
"the per-run bubble anchor is GONE (phase 161 D5)"
)
assert "insertBefore(toolDisc" not in body, (
"the per-run bubble anchor is GONE (phase 161 D5)"
)
# the consecutive-tool grouping (one disclosure per run, D1).
assert "if (toolDisc) toolDisc = null" in body, (
"a thinking entry seals the tool run — ONE disclosure per run"
+37 -18
View File
@@ -63,14 +63,17 @@ def _fn(src: str, name: str) -> str:
def test_tool_run_builds_an_open_details_disclosure() -> None:
"""Phase 158 (D1/D2/D5): ``ensureToolRun`` creates a native
``details.tool-calls-disclosure`` via ``createToolDisclosure`` —
a ``summary.tool-calls-summary`` (first child) + the existing
``.tool-calls`` list (role=list + aria-label "Tool calls",
second child) — inserted before the bubble in arrival order (D2).
It starts OPEN while the run is live; every append re-renders the
plain-text per-run "Tool call(s) (N)" count on the summary
through textContent (no innerHTML anywhere)."""
"""Phase 158 (D1/D5) + 161 (D1): ``ensureToolRun`` creates a
native ``details.tool-calls-disclosure`` via
``createToolDisclosure`` — a ``summary.tool-calls-summary``
(first child) + the existing ``.tool-calls`` list (role=list +
aria-label "Tool calls", second child) — appended into the
phase-161 reasoning container's ``.reasoning-body`` in arrival
order (D1 — the container sits above the bubble; the per-run
bubble anchor is superseded). It starts OPEN while the run is
live; every append re-renders the plain-text per-run "Tool
call(s) (N)" count on the summary through textContent (no
innerHTML anywhere)."""
js = _js()
disc = _fn(js, "createToolDisclosure")
assert "document.createElement(\"details\")" in disc
@@ -92,8 +95,15 @@ def test_tool_run_builds_an_open_details_disclosure() -> None:
"calls and the phase-87 .tool-elapsed suffix stay visible during "
"the turn"
)
assert 'insertBefore(disc, body.querySelector(".bubble"))' in run, (
"D2: above the answer, in arrival order"
assert 'insertBefore(disc, body.querySelector(".bubble"))' not in run, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in run
), (
"phase 161 (D1): appended into the container's body — the "
"container sits above the answer, arrival order preserved"
)
# D5: the count rides the summary — the template fragment itself
# (singular for one call, plural otherwise), textContent-built.
@@ -169,19 +179,28 @@ def test_restore_path_folds_the_disclosure() -> None:
), "the fold sits AFTER the tool-line restore loop"
def test_run_anchors_are_the_bubble_in_arrival_order() -> None:
"""Phase 158 (D2): the anchor is the BUBBLE for both run kinds —
the old ``.tool-calls-disclosure ?? .tool-calls ?? .bubble``
anchor chain is GONE (with per-run disclosures, "above the
tool lines" was the wrong order — arrival order is the
contract), and the runs still open while active (D5)."""
def test_run_anchors_are_the_container_in_arrival_order() -> None:
"""Phase 158 (D2) + 161 (D1): the anchor moved to the REASONING
CONTAINER for both run kinds (the phase-161 documented
supersession — the container sits before the bubble; the runs
append into its ``.reasoning-body``) — the old
``.tool-calls-disclosure ?? .tool-calls ?? .bubble`` anchor chain
is GONE (with per-run disclosures, "above the tool lines" was the
wrong order — arrival order is the contract), and the runs still
open while active (D5)."""
js = _js()
run = _fn(js, "ensureThinkingRun")
assert 'querySelector(".bubble")' in run
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in run
), "phase 161 (D1): the thinking run anchors on the container"
assert "block.open = true" in run
assert "??" not in run, "the old anchor chain is GONE"
d = _fn(js, "ensureToolRun")
assert 'querySelector(".bubble")' in d
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in d
), "phase 161 (D1): the tool run anchors on the container"
assert "disc.open = true" in d