phase: 87_big_read_progress
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)
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user