All completion criteria verified. Final report:
**Phase 159 — Edit the last question and re-ask it: VERIFIED COMPLETE** (all 4 tasks already in `complete/`; this pass verified end-to-end)
**Verified (implementation landed + tests green):**
- `app.js`: `EDIT_ICON`, `lastUserWrap`, `appendEditButton`/`markLastEditable` (single-target discipline, in-flight guard), `openEditor` (labelled textarea, `maxlength=4000`, Escape/Ctrl+⌘+Enter, empty guard), save path: replace → splice tail → DOM removal → `lastBrainWrap=null` → `saveConversation`+`persistConversation` → `runTurn(text,{reask:true})` — matches README exactly
- `styles.css`: `.edit-btn`/`.edit-form`/`.edit-error`/`.is-editing` + mobile 44px; unit pins 40, E2E 7, integration `test_chats_api.py` extended
**Test/lint results (all green):**
- `uv run pytest` — 3,661 tests, exit 0
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90%)
- `uv run ruff check . && uv run pyright` — all checks passed; 0 errors
- `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` (DB up) — **7/7 passed**
- Regressions in isolation: `test_retry_answer.py` 4/4, `test_failed_turn_retry.py` 4/4, `test_chat_persistence.py` 4/4
**Completion criteria:** all 7 ✓ — Edit on last bubble + prefill + Escape/Cancel restore (E2E #3); replace+truncate+re-ask with no duplicated question (E2E #1); refresh survival (E2E #4); failed/“question too long” turn editable + trim re-asks (E2E #5); earlier bubbles un-editable + byte-identical Retry/Stop/persistence (E2E #2 + regressions + unit pins); suite/coverage/lint ✓; E2E + regressions isolated ✓. Commit + phase-dir move: harness's job (no git ops by me, per protocol).
**Documentation:** README.md — full review; phase 159 section cross-checked against `app.js` source (cap, keys, truncate, image survival) — consistent, no changes needed; all 30 `.env.example` vars documented. AGENTS.md — full review; all rules/quick-ref commands valid — verified consistent, no changes needed.
**Notable:** none — no defects found in prior work; no live-infrastructure changes required.
**Next pending phase:** `160_tool_budget_telemetry`
361 lines
17 KiB
Python
361 lines
17 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 of the typing indicator, and the clock must not gain a
|
||
# second one (the typing-clock context: addTyping / startThinking
|
||
# Clock / stopThinkingClock / removeTyping — the phase-159
|
||
# openEditor re-render of a USER bubble's .bubble is a different
|
||
# context and does not count).
|
||
for name in ("function addTyping", "function startThinkingClock",
|
||
"function stopThinkingClock", "function removeTyping"):
|
||
f = js.find(name)
|
||
assert f != -1, f"{name} must exist"
|
||
b = js[f : js.find("\n}\n", f)]
|
||
assert "bubble.innerHTML" not in b, (
|
||
f"{name} 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).
|
||
|
||
The reset's SELECTOR is pinned too (phase-87 regression, 2026-09):
|
||
a bare ``.typing-elapsed`` (specificity 0,1,0) loses every shared
|
||
declaration to the dot rule ``.typing span`` (0,1,1) — the hint
|
||
then rendered as an 8×8px bouncing dot with the "Ns" text wrapping
|
||
one character per line below the bubble. The reset must carry the
|
||
span context (``.typing span.typing-elapsed``, 0,2,1) to win."""
|
||
css = _css()
|
||
assert ".typing span.typing-elapsed {" in css, (
|
||
"the dot-geometry reset must target the span in .typing context "
|
||
"(bare .typing-elapsed loses the specificity war to .typing span)"
|
||
)
|
||
block = re.search(r"\.typing span\.typing-elapsed \{([\s\S]*?)\n\}", css)
|
||
assert block, "styles.css must style .typing span.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",
|
||
"let toolLineEl = 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, line)" in branch, (
|
||
"the live tool branch arms the clock with the tracked line (phase 158)"
|
||
)
|
||
append = branch.find("const line = appendToolLine(")
|
||
arm = branch.find("armToolLineClock(wrap, line)")
|
||
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). Phase 158 (D1): it targets
|
||
the TRACKED line only (the arm's second param — the
|
||
``:last-child`` query is gone; multiple disclosures make it the
|
||
wrong line), and a missing reference (New-Chat click mid-gap)
|
||
makes the tick a no-op (the null guard)."""
|
||
js = _js()
|
||
fn = js.find("function armToolLineClock")
|
||
assert fn != -1, "armToolLineClock must exist"
|
||
body = js[fn : js.find("\n}\n", fn)]
|
||
assert ".tool-calls .tool-call:last-child" not in body, (
|
||
"the :last-child query is GONE (multi-disclosure — phase 158)"
|
||
)
|
||
assert "toolLineEl = lineEl" in body, "the tracked line reference"
|
||
assert "if (!toolLineEl) return" in body, (
|
||
"null-guarded: a wrap reset mid-gap (New Chat) is a no-op"
|
||
)
|
||
assert 'toolLineEl.querySelector(".tool-elapsed")' in body, (
|
||
"find-or-create the suffix span on the tracked line"
|
||
)
|
||
assert 'el.className = "tool-elapsed"' in body
|
||
assert 'document.createElement("span")' in body, "the span is createElement'd"
|
||
assert "toolLineEl.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"
|
||
|
||
|
||
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}"
|