All criteria verified — no defects found, nothing to fix. Final report: **Phase 87 — big read progress: final verification pass (all tasks already complete in `complete/`)** - Verified implementation vs. overview: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, single live `armToolLineClock` site, settle on thinking/retry/delta, `stopToolLineClock` in `setUiState`, visible `.typing-elapsed` (aria kept byte-identical), CSS AA pairing — all match design; `app/` byte-identical (0 changes) - **Tests/lint (exact outcomes):** - `uv run pytest --cov=app --cov-report=term` → 1732 passed, coverage **99%** (>90% ✓) - `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` → **4 passed** (ticking suffix, visible hint, settle, no-timer restore) - `test_thinking_display.py` → 5 passed · `test_agent_document_tools.py` → 4 passed · `test_smoke.py` → 3 passed (all isolated) - 3 pinned frontend suites + new unit pins → 62 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors - **Completion criteria:** E2E pins 1–4 ✓ · guard/state-machine byte-identical ✓ (diff is additive only) · diff scope limited to `app.js`, `styles.css`, 2 new test files, phase files; nothing in `app/` ✓ - **Notable:** no deviations; commit + `00_phase.md` move left to the harness per executor rules (task files already in `complete/`) - **Next pending phase:** none — `todo/` contains only this phase (87 is the last)
334 lines
15 KiB
Python
334 lines
15 KiB
Python
"""Unit: the phase-87 big-read progress contract (source-level pins).
|
|
|
|
Phase 87 (TODO.md L5 — "Need indication that prompt processing is
|
|
happening during a big read, it can look frozen."): during any
|
|
frameless gap of an in-flight turn the UI must show, to sighted AND
|
|
screen-reader users, that processing is ongoing — a ticking
|
|
elapsed-seconds suffix on the latest tool line (after 5s of silence)
|
|
and a VISIBLE elapsed hint on the typing indicator (the existing 10s
|
|
pre-token clock promoted from aria-only to visible text). Both settle
|
|
the instant content resumes; persisted/restored turns never show
|
|
timers (A6 — the arming call lives only in the live frame branches).
|
|
|
|
Locked decisions (phase overview): frontend-only (A4 — the server, the
|
|
SSE event set, and the 120s guard are byte-identical), named
|
|
thresholds owned by the state machine (A5 — ``TOOL_LINE_ELAPSED_AFTER_MS``),
|
|
and the exact tool-line template literals stay byte-identical (the
|
|
suffix is a separate element the clock appends; ``appendToolLine``
|
|
renders exactly as before, which is also what makes A6 fall out for
|
|
free on restore).
|
|
|
|
This module reads ``frontend/assets/app.js`` + ``styles.css`` as text
|
|
(no browser — house pattern, cf. test_frontend_feedback.py); the live
|
|
behavior is E2E-gated by ``tests/e2e/test_big_read_progress.py``
|
|
(task 03). Task 01 pins the visible typing-indicator hint below;
|
|
task 02 extends the module with the tool-line clock pins.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
|
|
def _js() -> str:
|
|
return APP_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _css() -> str:
|
|
return STYLES_CSS.read_text(encoding="utf-8")
|
|
|
|
|
|
# ---------- task 01: the visible typing-indicator elapsed hint ----------
|
|
|
|
|
|
def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> None:
|
|
"""Phase 87 task 01: after 10s of pre-token silence the typing
|
|
bubble gains a visible ``.typing-elapsed`` "Ns" hint — ensured
|
|
(created at most once, appended as the bubble's LAST child, after
|
|
the three dot spans) and written with ``textContent`` only. No
|
|
``innerHTML`` anywhere on the typing bubble: the ``addTyping``
|
|
template writes ``wrap.innerHTML`` (the static skeleton), and the
|
|
clock must never rewrite turn data into the DOM as HTML."""
|
|
js = _js()
|
|
assert ".typing-elapsed" in js, "the hint class must exist in app.js"
|
|
# The ensure pattern: find-or-create, then append to the bubble.
|
|
assert 'bubble.querySelector(".typing-elapsed")' in js
|
|
assert 'el.className = "typing-elapsed"' in js
|
|
assert "document.createElement(\"span\")" in js
|
|
assert "bubble.appendChild(el)" in js, "the hint is the bubble's last child"
|
|
# textContent-only write (the bubble is role="status" — announced).
|
|
assert 'el.textContent = secs + "s"' in js, (
|
|
"the hint text is the plain ticking \"Ns\" (A5 — no added wording)"
|
|
)
|
|
# No innerHTML on the typing bubble — the skeleton template
|
|
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
|
|
# write, and it must not gain a second one.
|
|
assert js.count("bubble.innerHTML") == 0, (
|
|
"the clock must never assign innerHTML to the typing bubble"
|
|
)
|
|
|
|
|
|
def test_typing_elapsed_hint_lives_in_start_thinking_clock() -> None:
|
|
"""The hint belongs to the EXISTING 10s pre-token clock (A5 — the
|
|
typing hint reuses its ``secs < 10`` gate): the ensure/write logic
|
|
sits inside ``startThinkingClock``'s 1s interval, right after the
|
|
aria-label update. ``addTyping``/``removeTyping`` stay untouched —
|
|
the hint lives and dies with the indicator the state machine owns."""
|
|
js = _js()
|
|
fn = js.find("function startThinkingClock")
|
|
assert fn != -1, "startThinkingClock must exist"
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert ".typing-elapsed" in body, "the hint is ensured inside the clock"
|
|
assert 'el.textContent = secs + "s"' in body
|
|
# The 10s gate is kept (aria + visible share it).
|
|
assert "secs < 10" in body
|
|
# addTyping/removeTyping never touch the hint (state-machine-owned).
|
|
for name in ("function addTyping", "function removeTyping"):
|
|
f = js.find(name)
|
|
assert f != -1, f"{name} must exist"
|
|
b = js[f : js.find("\n}\n", f)]
|
|
assert ".typing-elapsed" not in b, f"{name} must stay untouched"
|
|
|
|
|
|
def test_typing_aria_label_contract_stays_byte_identical() -> None:
|
|
"""The pinned aria channel survives the promotion to visible text —
|
|
both users, same clock (mirrors the phase-39 brand pin so this
|
|
module is self-documenting): the exact template literal, built at
|
|
call time via brand(), after the 10s gate."""
|
|
js = _js()
|
|
assert "`${brand()} is still thinking (${secs}s)`" in js
|
|
assert "secs < 10" in js, "the hint must only appear after 10s of silence"
|
|
assert "role=\"status\"" in js, "the typing bubble stays role=status"
|
|
|
|
|
|
def test_typing_elapsed_css_rule_is_the_aa_pairing() -> None:
|
|
"""The ``.typing-elapsed`` rule: small mono in ink-soft on the
|
|
bubble's --surface (the documented ≥4.5:1 AA pairing), the same
|
|
language as every status line. Plain text — ``animation: none`` +
|
|
no background, and the dot-geometry reset (the span is a sibling of
|
|
the dots inside the .typing bubble, so without the reset it would
|
|
render as an 8px bouncing dot, not a hint)."""
|
|
css = _css()
|
|
block = re.search(r"\.typing-elapsed \{([\s\S]*?)\n\}", css)
|
|
assert block, "styles.css must style .typing-elapsed"
|
|
body = block.group(1)
|
|
for prop in (
|
|
"font-family: var(--mono)",
|
|
"font-size: 0.75rem",
|
|
"color: var(--ink-soft)",
|
|
"margin-left: 0.5rem",
|
|
# the dot-geometry reset (see the rule's comment):
|
|
"width: auto",
|
|
"background: none",
|
|
"opacity: 1",
|
|
"animation: none",
|
|
):
|
|
assert prop in body, f".typing-elapsed must keep {prop}"
|
|
# Reduced motion: the hint is plain text, not motion — it keeps full
|
|
# opacity (the AA pairing) while the dots calm to 0.7.
|
|
blocks = re.findall(
|
|
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
|
|
)
|
|
assert any(".typing span.typing-elapsed" in b and "opacity: 1" in b for b in blocks), (
|
|
"the hint must keep full contrast under reduced motion"
|
|
)
|
|
# The existing reduced-motion dot fallback is untouched (phase 06 pin).
|
|
assert any(".typing span" in b and "animation: none" in b for b in blocks)
|
|
|
|
|
|
# ---------- task 02: the per-tool-line elapsed clock ----------
|
|
|
|
|
|
def test_tool_line_elapsed_constant_is_named_and_five_seconds() -> None:
|
|
"""A5: the visible "processing" threshold is the NAMED module constant
|
|
``TOOL_LINE_ELAPSED_AFTER_MS = 5_000`` (below it a frameless gap reads
|
|
as normal latency; at/above it the latest line proves it is still
|
|
processing) — a pinned constant, not a magic number in the tick."""
|
|
js = _js()
|
|
assert re.search(r"TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000", js), (
|
|
"the 5s threshold must be the named constant TOOL_LINE_ELAPSED_AFTER_MS"
|
|
)
|
|
|
|
|
|
def test_tool_line_clock_state_is_turn_scoped_module_state() -> None:
|
|
"""One clock per turn: the three state vars live at module scope next
|
|
to the existing ``thinkingClock`` / ``turnTimeout`` state (re-armed
|
|
per `tool` frame, so each line counts its OWN silence)."""
|
|
js = _js()
|
|
for decl in (
|
|
"let toolLineTimer = 0",
|
|
"let toolLineStart = 0",
|
|
"let toolLineWrap = null",
|
|
):
|
|
assert decl in js, f"{decl} must be module-scope state"
|
|
anchor = js.find("let turnTimeoutCb = null")
|
|
assert anchor != -1, "the existing timer state must exist"
|
|
assert (
|
|
js.find("let toolLineTimer = 0") - anchor < 1500
|
|
), "the clock state sits next to the existing timer state"
|
|
|
|
|
|
def test_arm_has_exactly_one_live_call_site() -> None:
|
|
"""A6 (live-only): ``armToolLineClock(`` appears EXACTLY twice in
|
|
app.js — the definition + the single live call site in the `tool`
|
|
frame branch (right after the line's append). The restore path
|
|
(phase 14, ``renderStoredMessage``) never arms: a restored line reads
|
|
exactly as it did pre-phase (the permanent record, no stale timer)."""
|
|
js = _js()
|
|
assert js.count("armToolLineClock(") == 2, (
|
|
"the arm must be the definition + exactly one live call site"
|
|
)
|
|
tool_idx = js.find('ev.type === "tool"')
|
|
delta_idx = js.find('ev.type === "delta"')
|
|
branch = js[tool_idx:delta_idx]
|
|
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
|
|
append = branch.find("appendToolLine(wrap, name, argument)")
|
|
arm = branch.find("armToolLineClock(wrap)")
|
|
assert -1 < append < arm, (
|
|
"the arm follows the line's append — the baseline resets per line"
|
|
)
|
|
restore_fn = js.find("function renderStoredMessage")
|
|
restore_end = js.find("function restoreConversation")
|
|
assert -1 < restore_fn < restore_end
|
|
assert "armToolLineClock" not in js[restore_fn:restore_end], (
|
|
"the restore path must never arm the clock (A6)"
|
|
)
|
|
|
|
|
|
def test_settle_covers_the_three_live_frame_branches() -> None:
|
|
"""Settle = REMOVE the suffix: the `thinking`, `retry`, and `delta`
|
|
branches each call ``settleToolLine()`` at the TOP of the branch
|
|
(a frame arrived — the line is no longer "processing"), and the
|
|
settle clears the interval + removes every ``.tool-elapsed`` from the
|
|
wrap. Definition + three branches → at least 4 occurrences."""
|
|
js = _js()
|
|
assert js.count("settleToolLine(") >= 4, (
|
|
"the definition + the three frame branches must settle"
|
|
)
|
|
for branch_open, branch_close, first_work in (
|
|
('ev.type === "thinking"', 'ev.type === "tool"', "thinkingAcc += ev.text"),
|
|
('ev.type === "retry"', 'ev.type === "delta"', "const attempt"),
|
|
('ev.type === "delta"', 'ev.type === "done"', "acc += ev.text"),
|
|
):
|
|
start = js.find(branch_open)
|
|
end = js.find(branch_close, start)
|
|
assert -1 < start < end, f"the {branch_open!r} branch must exist"
|
|
seg = js[start:end]
|
|
assert "settleToolLine();" in seg, f"the {branch_open!r} branch must settle"
|
|
assert seg.index("settleToolLine();") < seg.index(first_work), (
|
|
f"the settle sits at the TOP of the {branch_open!r} branch, before its "
|
|
"content work (the line is no longer 'processing' the instant the "
|
|
"frame arrives)"
|
|
)
|
|
fn = js.find("function settleToolLine")
|
|
assert fn != -1
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert "clearInterval(toolLineTimer)" in body, "the settle drops the interval"
|
|
assert 'querySelectorAll?.(".tool-elapsed")' in body and "el.remove()" in body, (
|
|
"the settle REMOVES every suffix (a frozen timestamp is noise)"
|
|
)
|
|
|
|
|
|
def test_stop_lives_in_setui_state_next_to_the_other_stops() -> None:
|
|
"""House invariant — "a stuck button is impossible" applied to a
|
|
stuck timer: ``stopToolLineClock()`` is called inside
|
|
``setUiState`` so EVERY transition stops/clears the clock. Documented
|
|
pin: the call sits in the function body AFTER the
|
|
``stopThinkingClock();`` line and BEFORE the first button write
|
|
(``sendBtn.disabled``) — stable under comment churn, and it forces
|
|
the call into the timer-clear block rather than a later branch.
|
|
``stopToolLineClock`` itself settles + forgets the wrap (no residue
|
|
across turns)."""
|
|
js = _js()
|
|
fn = js.find("export function setUiState")
|
|
assert fn != -1
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert "stopToolLineClock();" in body, "setUiState must stop the clock"
|
|
assert (
|
|
body.index("stopThinkingClock();")
|
|
< body.index("stopToolLineClock();")
|
|
< body.index("sendBtn.disabled")
|
|
), (
|
|
"the stop sits in setUiState's clear block (after stopThinkingClock, "
|
|
"before the button writes)"
|
|
)
|
|
sf = js.find("function stopToolLineClock")
|
|
assert sf != -1
|
|
sbody = js[sf : js.find("\n}\n", sf)]
|
|
assert "settleToolLine();" in sbody, "stop settles (clear interval + remove suffixes)"
|
|
assert "toolLineWrap = null" in sbody, "stop forgets the wrap — no residue"
|
|
|
|
|
|
def test_suffix_is_textcontent_only_on_the_latest_line() -> None:
|
|
"""The suffix is a parenthesized "(Ns)" status suffix (e.g. "📄
|
|
Reading src/app.py (12s)" — the typing hint stays bare "Ns") written
|
|
with ``textContent`` on a ``createElement`` span — never innerHTML —
|
|
appended as a SIBLING after the line's existing children (the pinned
|
|
template text + the <code> argument). It targets the LATEST line only
|
|
(`.tool-call:last-child` — older lines keep their permanent record),
|
|
and a missing container (New-Chat click mid-gap) makes the tick a
|
|
no-op (the null-safe chain is the guard)."""
|
|
js = _js()
|
|
fn = js.find("function armToolLineClock")
|
|
assert fn != -1, "armToolLineClock must exist"
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert 'querySelector?.(".tool-calls .tool-call:last-child")' in body, (
|
|
"the suffix targets the LATEST line only"
|
|
)
|
|
assert 'line.querySelector(".tool-elapsed")' in body, "find-or-create the suffix span"
|
|
assert 'el.className = "tool-elapsed"' in body
|
|
assert 'document.createElement("span")' in body, "the span is createElement'd"
|
|
assert "line.appendChild(el)" in body, (
|
|
"the suffix is a SIBLING appended AFTER the line's existing children"
|
|
)
|
|
assert "`(${secs}s)`" in body, ("the parenthesized '(Ns)' suffix, textContent-built")
|
|
assert "innerHTML" not in body, "no HTML write in the clock — textContent only"
|
|
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body, "the tick gates on the named constant"
|
|
assert "toolLineWrap?.querySelector?" in body, (
|
|
"null-safe: a wrap reset mid-gap (New Chat) makes the tick a no-op"
|
|
)
|
|
|
|
|
|
def test_tool_line_template_literals_stay_byte_identical() -> None:
|
|
"""The exact ``line.textContent = "…"`` template literals survive —
|
|
mirrors test_frontend_tool_states.py so this module is
|
|
self-documenting: the suffix is a separate element the clock appends,
|
|
and a rewrite of the line text would break the emoji-guard strip set
|
|
(it strips precisely those) plus the phase-37 pins. The helper stays
|
|
clock-free — that is also what makes A6 fall out for free on restore."""
|
|
js = _js()
|
|
fn = js.find("function appendToolLine")
|
|
assert fn != -1, "appendToolLine must exist"
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
for lit in (
|
|
'line.textContent = "📄 Reading "',
|
|
'line.textContent = "🔎 Searching for "',
|
|
'line.textContent = "🔎 Listing documents in "',
|
|
'line.textContent = "🔎 Listing documents"',
|
|
):
|
|
assert lit in body, f"the pinned tool-line literal {lit!r} must stay byte-identical"
|
|
assert "tool-elapsed" not in body, "appendToolLine stays clock-free (A6)"
|
|
assert "armToolLineClock" not in body
|
|
|
|
|
|
def test_tool_elapsed_css_rule_is_the_aa_pairing() -> None:
|
|
"""The ``.tool-elapsed`` rule (next to the .tool-call rules): small
|
|
mono in ink-soft — the same AA pairing as the task-01 hint — with
|
|
``white-space: nowrap`` so "(12s)" never wraps in the row."""
|
|
css = _css()
|
|
block = re.search(r"\.tool-elapsed \{([\s\S]*?)\n\}", css)
|
|
assert block, "styles.css must style .tool-elapsed"
|
|
body = block.group(1)
|
|
for prop in (
|
|
"font-family: var(--mono)",
|
|
"font-size: 0.75rem",
|
|
"color: var(--ink-soft)",
|
|
"margin-left: 0.5rem",
|
|
"white-space: nowrap",
|
|
):
|
|
assert prop in body, f".tool-elapsed must keep {prop}"
|