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
+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