All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
391 lines
18 KiB
Python
391 lines
18 KiB
Python
"""Unit: the phase-109 never-frozen-turn frontend contract (tasks
|
|
01-02), updated by phase 158 (task 02).
|
|
|
|
No new Python app logic exists for this task — the behavior lives in
|
|
``frontend/assets/app.js`` and is E2E-gated by the story suite (task 03).
|
|
Like the other frontend-adjacent unit files (the house
|
|
read-the-assets-as-text pattern, cf. ``test_frontend_tool_states.py``),
|
|
this module pins the JS markers the phase depends on: the phase-109
|
|
D15 re-open pin is REPLACED by the phase-158 per-run contract (owner
|
|
direction 2026-10-04 — ``thinking`` frames open a NEW block per
|
|
contiguous run, ``delta`` frames fold the active run, a later
|
|
thinking frame opens a NEW run, not a re-open) — while the
|
|
phase-14 restore path and the phase-17 follow-the-tail pin logic stay
|
|
byte-for-byte untouched (the capture moves verbatim, per block).
|
|
Task 02 (phase 109) pins the persistent in-turn loader (D16): the
|
|
static ``#turn-loader`` markup, ``setUiState`` as its SOLE visibility
|
|
owner (the structural never-stale guarantee), and the CSS contract
|
|
(reused typing-dot animation + reduced-motion + provenance).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
INDEX_HTML = FRONTEND / "index.html"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
|
|
def _js() -> str:
|
|
return APP_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _html() -> str:
|
|
return INDEX_HTML.read_text(encoding="utf-8")
|
|
|
|
|
|
def _css() -> str:
|
|
return STYLES_CSS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _thinking_branch() -> str:
|
|
"""The `thinking` handler branch (between the thinking and tool
|
|
branches of the turn dispatch)."""
|
|
js = _js()
|
|
thinking_idx = js.find('ev.type === "thinking"')
|
|
tool_idx = js.find('ev.type === "tool"')
|
|
assert -1 < thinking_idx < tool_idx, (
|
|
"the turn handler must branch on thinking frames"
|
|
)
|
|
return js[thinking_idx:tool_idx]
|
|
|
|
|
|
def test_thinking_handler_opens_a_new_block_per_run() -> None:
|
|
"""Phase 158 (D1 — the phase-109 D15 re-open pin, replaced by
|
|
owner direction 2026-10-04): the `thinking` handler opens a NEW
|
|
block per contiguous run — ``ensureThinkingRun(wrap)`` sits in the
|
|
handler (a frame of a new kind closes the previous run and opens
|
|
a new block), the RUN's text accumulates + re-renders into THAT
|
|
block, and the D15 re-open assignment (``block.open = true``) is
|
|
GONE from the branch — the open state is owned by the builder
|
|
(a new run opens; the active run stays open)."""
|
|
branch = _thinking_branch()
|
|
ensure_idx = branch.find("ensureThinkingRun(wrap)")
|
|
assert ensure_idx != -1, (
|
|
"the handler must open/extend the run's block (D1: per-run — "
|
|
"a new kind opens a NEW block)"
|
|
)
|
|
assert "currentRun.run.text += ev.text || \"\"" in branch, (
|
|
"the RUN's text accumulates (the block renders the run, not "
|
|
"the whole-turn accumulator)"
|
|
)
|
|
assert "block.open = true" not in branch, (
|
|
"the D15 re-open assignment is GONE — ensureThinkingRun owns "
|
|
"the open state"
|
|
)
|
|
|
|
|
|
def test_delta_handler_folds_the_active_run() -> None:
|
|
"""Phase 158 (D5 — the phase-109 D15 close-side pin, updated):
|
|
the close side of the per-run contract — the `delta` handler calls
|
|
``closeCurrentRun()`` (folds the active run, idempotent); a later
|
|
thinking frame opens a NEW run above the bubble (the D15 re-open
|
|
of the shared block is gone)."""
|
|
js = _js()
|
|
delta_idx = js.find('ev.type === "delta"')
|
|
done_idx = js.find('ev.type === "done"')
|
|
assert -1 < delta_idx < done_idx, "the turn handler must branch on delta"
|
|
branch = js[delta_idx:done_idx]
|
|
assert "closeCurrentRun()" in branch, (
|
|
"the `delta` handler must fold the active run (D5: "
|
|
"folded-while-answering)"
|
|
)
|
|
|
|
|
|
def test_section_comment_says_per_run_not_reopen() -> None:
|
|
"""Phase 158 (D1 — the phase-109 D15 narrative, replaced): the
|
|
section comment documenting the run builders no longer claims the
|
|
D15 toggle contract (the "re-opens" narrative is gone — nowhere
|
|
in app.js does "never reopens" survive either) — it names the
|
|
per-run timeline instead: each CONTIGUOUS run its own block,
|
|
arrival order, D2 placement, and the D15 contract as replaced."""
|
|
js = _js()
|
|
assert "never reopens" not in js, (
|
|
"the one-way-door narrative must not survive anywhere in app.js"
|
|
)
|
|
# The block comment that documents the builders: walk back from
|
|
# the first builder to its section comment start.
|
|
comment_start = js.rfind("/*", 0, js.find("function createThinkingBlock"))
|
|
assert comment_start != -1
|
|
section = js[comment_start : js.find("function createThinkingBlock")]
|
|
assert "re-opens" not in section, (
|
|
"the D15 re-open narrative is gone (phase 158, D1)"
|
|
)
|
|
assert "PER-RUN" in section or "per-run" in section, (
|
|
"the contract is named: per-run"
|
|
)
|
|
assert "CONTIGUOUS" in section, "a contiguous run per block is named"
|
|
assert "arrival order" in section, "D2: arrival order is named"
|
|
assert "REPLACED" in section or "superseded" in section.lower(), (
|
|
"the D15 contract is named as replaced"
|
|
)
|
|
|
|
|
|
def test_restore_path_still_collapses_stored_blocks() -> None:
|
|
"""Phase-14 contract regression: ``renderStoredMessage`` restores
|
|
stored thinking blocks COLLAPSED (``block.open = false`` — the
|
|
shared ``createThinkingBlock`` builder, phase 158 task 02) — the
|
|
phase-158 per-run live path never opens a restored block."""
|
|
js = _js()
|
|
fn = js.find("function renderStoredMessage")
|
|
assert fn != -1, "renderStoredMessage must exist"
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert "if (m.thinking)" in body
|
|
assert "createThinkingBlock()" in body
|
|
assert "block.open = false" in body, "stored blocks restore collapsed"
|
|
# The live re-open must NOT have leaked into the restore path:
|
|
# only the collapse assignment may appear in the function body.
|
|
assert "block.open = true" not in body, (
|
|
"the restore path must never open a stored block (phase-14)"
|
|
)
|
|
|
|
|
|
def test_follow_the_tail_pin_logic_is_untouched() -> None:
|
|
"""Cross-file regression (phase 17): the follow-the-tail pin is
|
|
unchanged — ``THINKING_NEAR_BOTTOM_PX`` (32px band) +
|
|
``isThinkingNearBottom`` and the pre-render capture
|
|
``const pinned = block.open && isThinkingNearBottom(textEl);``
|
|
(measured BEFORE the re-render) still sit in the `thinking`
|
|
handler — now operating per block (phase 158: the re-render is
|
|
the RUN's text, not the whole-turn accumulator). The capture
|
|
already keys off ``block.open``, so a live-open block resumes
|
|
pinned tail-following exactly like the phase-17 flow."""
|
|
js = _js()
|
|
assert "export const THINKING_NEAR_BOTTOM_PX = 32;" in js, (
|
|
"the 32px window band is unchanged"
|
|
)
|
|
fn = re.search(
|
|
r"function isThinkingNearBottom\(textEl\) \{([\s\S]*?)\n\}", js
|
|
)
|
|
assert fn, "isThinkingNearBottom must still exist"
|
|
assert "THINKING_NEAR_BOTTOM_PX" in fn.group(1)
|
|
branch = _thinking_branch()
|
|
capture = "const pinned = block.open && isThinkingNearBottom(textEl);"
|
|
assert capture in branch, (
|
|
"the pre-render `block.open &&` guard line is unchanged — the "
|
|
"pin logic moves verbatim into the per-run branch (phase 17 "
|
|
"regression)"
|
|
)
|
|
capture_idx = branch.find(capture)
|
|
render_idx = branch.find("textEl.innerHTML = renderMarkdown(currentRun.run.text)")
|
|
assert -1 < capture_idx < render_idx, (
|
|
"pin state is still measured BEFORE the re-render (phase-17 "
|
|
"regression — the 2-newline-gap fix)"
|
|
)
|
|
assert "textEl.scrollTop = textEl.scrollHeight" in branch, (
|
|
"pinned tail-following is intact"
|
|
)
|
|
|
|
|
|
# ---------- task 02: the persistent in-turn loader (D16) ----------
|
|
|
|
|
|
def test_index_html_carries_exactly_one_turn_loader() -> None:
|
|
"""Phase 109 (D16) + phase 117 redesign: ``index.html`` carries
|
|
exactly ONE static ``#turn-loader`` — the equalizer shell holding
|
|
the FIVE static ``.eq`` square spans (never constructed in JS: the
|
|
createElement/textContent house rule), ``aria-hidden="true"``
|
|
(decorative — ``#send-status`` carries the meaning), ``hidden`` by
|
|
default (idle on load), sitting INSIDE the composer form and LEFT
|
|
of the send button (phase 117, owner requirement: the loader's
|
|
appearance/disappearance must never shift the button — it eats the
|
|
flex:1 textarea's space instead)."""
|
|
html = _html()
|
|
assert html.count('id="turn-loader"') == 1, (
|
|
"exactly one #turn-loader — static markup, never JS-built"
|
|
)
|
|
match = re.search(r'<div\s+id="turn-loader"[^>]*>', html)
|
|
assert match, "the loader must be a static <div>"
|
|
tag = match.group(0)
|
|
assert 'class="turn-loader"' in tag
|
|
assert 'aria-hidden="true"' in tag, "decorative — aria-hidden"
|
|
# the hidden attribute (a standalone word in the tag, not the
|
|
# substring of some other attribute value): ships hidden (idle).
|
|
assert re.search(r"\shidden\s*/?>$", tag), (
|
|
"the loader ships hidden (idle on load)"
|
|
)
|
|
# Phase 117: the static brain-wave SVG — EXACTLY the ghost trace +
|
|
# the pulse sweep (pathLength-normalized for the CSS dash math),
|
|
# and nothing else. No JS-built HTML anywhere near it.
|
|
close_idx = html.index("</div>", match.end())
|
|
block = html[match.end():close_idx]
|
|
assert '<svg class="bw"' in block, (
|
|
"the loader carries the static brain-wave SVG"
|
|
)
|
|
assert block.count("<path") == 2, (
|
|
"the SVG carries exactly two paths (ghost trace + pulse sweep)"
|
|
)
|
|
assert 'class="bw-ghost"' in block, "the dim ghost trace is pinned"
|
|
assert 'class="bw-pulse"' in block, "the brand sweep path is pinned"
|
|
assert 'pathLength="140"' in block, (
|
|
"pathLength normalizes the sweep's dash math (styles.css)"
|
|
)
|
|
assert '<span class="eq"' not in block, (
|
|
"the first-mock equalizer spans are gone (the owner picked the "
|
|
"brain wave)"
|
|
)
|
|
# inside the composer form, LEFT of the send button (phase 117):
|
|
# textarea → loader → button, so the button stays pinned at the
|
|
# row's right edge.
|
|
composer_idx = html.find('<form class="composer" id="composer"')
|
|
assert composer_idx != -1
|
|
form_end = html.find("</form>", composer_idx)
|
|
assert composer_idx < match.start() < form_end, (
|
|
"the loader sits in the composer"
|
|
)
|
|
input_idx = html.find('id="message-input"', composer_idx)
|
|
btn_idx = html.find('id="send-btn"', composer_idx)
|
|
assert input_idx < match.start() < btn_idx, (
|
|
"the loader sits LEFT of the send button (the button never "
|
|
"shifts when the loader appears/disappears)"
|
|
)
|
|
|
|
|
|
def test_set_ui_state_is_the_sole_owner_of_the_loader() -> None:
|
|
"""Phase 109 (D16) — the never-stale guarantee, structural: in
|
|
``app.js`` the string ``turnLoader.hidden`` appears EXACTLY ONCE,
|
|
inside ``setUiState`` (the cross-file single-owner check — any
|
|
second write site fails this test). The toggle is
|
|
``turnLoader.hidden = !inFlight`` next to the existing ``is-stop``
|
|
toggle: shown iff ``uiState`` is thinking|streaming. Every terminal
|
|
path funnels through ``setUiState`` (done → idle, error → error,
|
|
stop/timeout → their landings), so the loader hides in every
|
|
terminal state BY CONSTRUCTION — no per-handler cleanup."""
|
|
js = _js()
|
|
# The element lookup joins the other module-top lookups.
|
|
assert 'const turnLoader = document.querySelector("#turn-loader");' in js
|
|
# THE invariant: exactly one write site in the whole file.
|
|
assert js.count("turnLoader.hidden") == 1, (
|
|
"setUiState is the SOLE writer of the loader's hidden attribute — "
|
|
"a second write site breaks the §7.4 never-stale guarantee"
|
|
)
|
|
# ...and it sits INSIDE setUiState, next to the is-stop toggle.
|
|
fn = js.find("export function setUiState")
|
|
assert fn != -1
|
|
body_end = js.find("\n}\n", fn)
|
|
owner_idx = js.find("turnLoader.hidden")
|
|
assert fn < owner_idx < body_end, (
|
|
"the toggle must live inside setUiState (the single entry point)"
|
|
)
|
|
stop_idx = js.find('sendBtn.classList.toggle("is-stop", inFlight);', fn)
|
|
assert -1 < stop_idx < owner_idx, (
|
|
"the loader toggle joins the existing is-stop toggle"
|
|
)
|
|
assert "turnLoader.hidden = !inFlight;" in js, (
|
|
"shown iff inFlight (thinking|streaming)"
|
|
)
|
|
|
|
|
|
def test_loader_css_brain_wave_contract() -> None:
|
|
"""Phase 117 (the phase-109 D16 CSS pin, superseded by the owner's
|
|
brain-wave design — picked over the first equalizer mock): the
|
|
``.turn-loader`` rule sits NEXT TO the typing-dots rules (after
|
|
the ``@keyframes typing`` block); the sweep is a NEW ``bwdraw``
|
|
keyframe (a ``stroke-dashoffset`` travel — the 42/140 dash segment
|
|
loops the pathLength-normalized path seamlessly); the equalizer
|
|
machinery (``.eq`` squares + ``eqpulse``) is GONE; the ghost trace
|
|
stays decorative-dim; a ``prefers-reduced-motion`` block stills the
|
|
sweep (the full trace shows static — §7.2 house law); the
|
|
provenance comment names phase 117."""
|
|
css = _css()
|
|
keyframes_idx = css.find("@keyframes typing")
|
|
assert keyframes_idx != -1, (
|
|
"the typing-dot keyframes still exist (the chat-log typing bubble)"
|
|
)
|
|
# The MAIN rule (".turn-loader {" — the reduced-motion block's
|
|
# selector never starts that exact string) must sit next to
|
|
# (after) the typing-dots rules.
|
|
rule_idx = css.find(".turn-loader {")
|
|
assert rule_idx != -1, "styles.css must carry a .turn-loader rule"
|
|
assert keyframes_idx < rule_idx, (
|
|
"the .turn-loader rule sits next to (after) the typing-dots rules"
|
|
)
|
|
# The provenance comment: the /* ... */ block immediately preceding
|
|
# the rule names phase 117 (the brain-wave redesign).
|
|
comment_start = css.rfind("/*", 0, rule_idx)
|
|
assert comment_start != -1
|
|
comment = css[comment_start:rule_idx]
|
|
assert "Phase 117" in comment, "the provenance comment names phase 117"
|
|
# The sweep: the .bw-pulse path runs the NEW bwdraw keyframe (the
|
|
# phase-109 "reuse the typing animation" pin is superseded by the
|
|
# owner's redesign — an explicit decision, not a silent deviation).
|
|
pulse_idx = css.find(".turn-loader .bw-pulse {")
|
|
assert pulse_idx != -1, "styles.css must carry a .turn-loader .bw-pulse rule"
|
|
pulse_rule = css[pulse_idx : css.find("}", pulse_idx)]
|
|
assert re.search(r"animation:\s*bwdraw\b", pulse_rule), (
|
|
"the sweep runs the bwdraw keyframe"
|
|
)
|
|
assert "stroke-dasharray: 42 98" in pulse_rule, (
|
|
"the 42/140 bright segment is pinned (the owner-tuned sweep — "
|
|
"the dash cycle equals the pathLength-normalized path: seamless)"
|
|
)
|
|
draw_idx = css.find("@keyframes bwdraw")
|
|
assert draw_idx != -1, "the bwdraw keyframes must exist"
|
|
draw_block = css[draw_idx : css.find("\n}", draw_idx) + 2]
|
|
assert "stroke-dashoffset: 140" in draw_block, "the travel starts at 140"
|
|
assert "stroke-dashoffset: 0" in draw_block, (
|
|
"the travel ends at 0 (one full normalized loop — seamless)"
|
|
)
|
|
# The ghost trace stays decorative-dim (ink-soft at 30% — the shape
|
|
# must be readable between sweep peaks).
|
|
ghost_idx = css.find(".turn-loader .bw-ghost {")
|
|
assert ghost_idx != -1, "styles.css must carry a .turn-loader .bw-ghost rule"
|
|
ghost_rule = css[ghost_idx : css.find("}", ghost_idx)]
|
|
assert "stroke-opacity: 0.3" in ghost_rule, (
|
|
"the ghost trace stays dim (the sweep carries the attention)"
|
|
)
|
|
# The old machinery is GONE: the phase-109 pseudo-dots, the
|
|
# typing-animation reuse inside the loader's rules, and the first-
|
|
# mock equalizer (.eq squares + eqpulse).
|
|
assert ".turn-loader::before" not in css, (
|
|
"the phase-109 pseudo-dot rules are gone"
|
|
)
|
|
loader_region = css[rule_idx:draw_idx]
|
|
assert "animation: typing" not in loader_region, (
|
|
"the loader no longer reuses the typing animation (phase 117)"
|
|
)
|
|
assert ".turn-loader .eq" not in css, (
|
|
"the first-mock equalizer rules are gone (the owner picked the "
|
|
"brain wave)"
|
|
)
|
|
assert "@keyframes eqpulse" not in css, "the eqpulse keyframes are gone"
|
|
# The prefers-reduced-motion block stills the SWEEP: the full trace
|
|
# shows static — §7.2 (stilling never hides: visibility is the JS
|
|
# hidden attribute in setUiState, D16).
|
|
reduced_ok = False
|
|
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css):
|
|
if ".turn-loader .bw-pulse" in m.group(1) and "animation: none" in m.group(1):
|
|
reduced_ok = True
|
|
break
|
|
assert reduced_ok, (
|
|
"a prefers-reduced-motion block must still the sweep (static "
|
|
"trace, no motion — §7.2)"
|
|
)
|
|
|
|
|
|
def test_loader_is_aria_hidden_and_status_untouched() -> None:
|
|
"""Phase 109 (D16) — the a11y split is untouched: the loader is
|
|
``aria-hidden`` (decoration), and ``#send-status`` keeps its exact
|
|
attributes in ``index.html`` — still the sole ``aria-live``
|
|
announcer (the house a11y split: visual cues are aria-hidden, the
|
|
live region carries the state text)."""
|
|
html = _html()
|
|
match = re.search(r'<div\s+id="turn-loader"[^>]*>', html)
|
|
assert match and 'aria-hidden="true"' in match.group(0)
|
|
# #send-status is unchanged: one element, the exact same tag —
|
|
# the visually-hidden polite live region inside the send button,
|
|
# still the chat view's state announcer.
|
|
assert html.count('id="send-status"') == 1
|
|
status = re.search(r'<span[^>]*id="send-status"[^>]*></span>', html)
|
|
assert status, "the #send-status live region is intact"
|
|
tag = status.group(0)
|
|
assert tag == (
|
|
'<span class="visually-hidden" aria-live="polite" '
|
|
'id="send-status"></span>'
|
|
), "the #send-status attributes are byte-unchanged"
|
|
# the loader added no a11y surface: it is aria-hidden and carries
|
|
# no live region of its own.
|
|
assert 'aria-live' not in match.group(0)
|