Files
brain-of-reese/tests/unit/test_frontend_feedback.py
T
ducoterra 5abe8871e3
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 15s
fix(chat): raise pre-token guard from 120 s to 300 s
The client-side TURN_TIMEOUT_MS was the binding constraint: turns with
slow prompt processing (no first SSE frame within 120 s of visible time)
errored with the 'stuck' copy even though nginx (300 s) and
BOR_LLM_TIMEOUT (300 s) would have let them run. Raise the guard to
300 s so the upstream timeouts are reachable, and re-pin the tests:
the unit constant pins and the fake-clock E2E timeline (295 s hidden
+ 290 s after the re-arm = 585 s: past the original 300 s deadline,
short of the re-armed 595 s deadline).

Verified: tests/unit (full, 100% pass), tests/e2e/test_hidden_tab_stream.py
and tests/e2e/test_loading_feedback.py in isolation.
2026-09-08 09:37:33 -04:00

661 lines
32 KiB
Python

"""Unit: the loading-feedback contract in the static frontend (phase 06).
The JS behavior itself is E2E-covered (tests/e2e/test_loading_feedback.py);
here we pin the exported constants and state-machine markers that the
story depends on, so a silent regression in app.js/styles.css is caught
without a browser.
"""
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")
def _html() -> str:
return (FRONTEND / "index.html").read_text(encoding="utf-8")
def test_turn_timeout_constant_exported_at_300s() -> None:
"""The 300s client-side guard (PLAN §7.4) must be an *exported*
constant — testable, and the single value the E2E timeout story keys
off."""
js = _js()
match = re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*300_?000\s*;", js)
assert match, "app.js must export `const TURN_TIMEOUT_MS = 300000`"
def test_state_machine_has_all_four_states() -> None:
"""idle → thinking → streaming → done | error → idle (PLAN §7.4).
`done` is not a UI state: it settles into `idle` in the turn handler's
finally block, so the state machine itself has exactly four states.
"""
js = _js()
for state in ("idle", "thinking", "streaming", "error"):
assert re.search(rf'{state}:\s*"{state}"', js), f"state {state!r} missing"
assert "function setUiState" in js, "setUiState must exist as the single entry point"
assert "export function setUiState" in js, "setUiState must be exported (testable)"
def test_typing_indicator_contract_strings() -> None:
"""The indicator's accessible label and the 10s elapsed-seconds hint
are the strings screen readers (and the E2E) rely on.
Phase 39: the label is BUILT at call time from window.BOR_BRAND
(the classic brand.js layer) — `TYPING_LABEL()` — with the default
name as the only fallback, so the default path renders the same
bytes as before."""
js = _js()
assert "TYPING_LABEL = () =>" in js, "the label must be built at call time (phase 39)"
assert "`${brand()} is thinking`" in js, "the label must resolve the name via brand()"
assert 'window.BOR_BRAND || "Brain of Reese"' in js, (
"the no-config fallback must keep the default name"
)
assert "still thinking" in js, "elapsed-seconds hint must update the aria label"
assert "secs < 10" in js, "the hint must only appear after 10s of silence"
assert "role=\"status\"" in js, "typing bubble must be role=status"
def test_error_banner_has_actionable_hint() -> None:
"""Every error path (SSE error event, non-2xx, 300s timeout) must
surface the same actionable retry hint (story AC4)."""
js = _js()
assert "check the LLM is reachable" in js
assert '"role", "alert"' in js, "error banner must be role=alert"
# the banner must be the red error variant
assert "is-error" in js
def test_reduced_motion_calm_not_removed() -> None:
"""prefers-reduced-motion: feedback is calmed, never removed (story
AC7). Dots go static; the spinner only slows down."""
css = _css()
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
)
assert blocks, "styles.css must contain prefers-reduced-motion rules"
assert any(".typing span" in b and "animation: none" in b for b in blocks), (
"typing dots must have a static fallback under reduced motion"
)
assert any(".spinner" in b and "animation-duration" in b for b in blocks), (
"spinner must slow down (not vanish) under reduced motion"
)
def test_busy_button_style_tokens() -> None:
"""Phase 48 (revised contract, owner-locked 2026-08-29): in flight
the button is the enabled Stop control — "Stop" label, .is-stop
class (rose treatment, 6.3:1 with the #fff label), spinner hidden;
idle/error keep the brand Send button (dark ink on brand 5.2:1).
The spinner element stays in the markup + CSS (16px dark arc — the
reduced-motion pin below) but the state machine never shows it: the
Stop label + treatment carry the in-flight state."""
css = _css()
js = _js()
assert ".send-btn.is-stop" in css
assert "#be123c" in css, "the stop background: rose-700 (6.3:1 with #fff)"
assert ".send-btn.is-stop:hover" in css, "the darker hover step"
assert re.search(r"\.spinner \{[^}]*width: 16px", css)
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
assert 'sendBtn.classList.toggle("is-stop", inFlight)' in js
assert "sendBtn.disabled = false" in js, "the button is a control, never disabled"
# ---------- thinking display (phase 17) ----------
def test_thinking_event_is_a_first_class_turn_branch() -> None:
"""Phase 17: `thinking` SSE frames stream live into the collapsible
Thinking block — the typing dots make way, the 300s pre-token guard
clears (the stream is alive), and the text renders through the
escape-first markdown renderer (XSS-safe). While open, the stream is
pinned to the bottom of the block."""
js = _js()
thinking_idx = js.find('ev.type === "thinking"')
delta_idx = js.find('ev.type === "delta"')
assert -1 < thinking_idx < delta_idx, "the turn handler must branch on thinking frames"
branch = js[thinking_idx:delta_idx]
assert "thinkingAcc += ev.text" in branch
assert "sawThinking = true" in branch
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 300s guard"
assert "removeTyping()" in branch, "the live block replaces the typing dots"
assert "ensureThinkingBlock(wrap)" in branch
assert "renderMarkdown(thinkingAcc)" in branch, "escape-first renderer (XSS-safe)"
assert "textEl.scrollTop = textEl.scrollHeight" in branch, "bottom-pinned while open"
def test_thinking_block_helpers_are_idempotent() -> None:
"""ensureThinkingBlock returns the existing `.thinking` details or
creates it OPEN above the .bubble; closeThinkingBlock is a no-op
without a block and never reopens one once the answer started."""
js = _js()
fn = js.find("function ensureThinkingBlock")
assert fn != -1, "ensureThinkingBlock must exist (near addTyping/removeTyping)"
body = js[fn : js.find("\n}\n", fn)]
assert "block.open = true" in body, "created open — the stream is the show"
assert "insertBefore" in body
assert 'querySelector(".bubble")' in body, "the block sits ABOVE the bubble"
fn2 = js.find("function closeThinkingBlock")
assert fn2 != -1, "closeThinkingBlock must exist"
body2 = js[fn2 : js.find("\n}\n", fn2)]
assert "block.open = false" in body2
def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None:
"""The first answer delta transitions thinking → streaming (even when
thinking created the wrap first) and auto-collapses the block —
idempotent, and it never reopens once the answer started."""
js = _js()
delta_idx = js.find('ev.type === "delta"')
done_idx = js.find('ev.type === "done"')
assert -1 < delta_idx < done_idx
branch = js[delta_idx:done_idx]
assert "uiState === UI_STATE.thinking" in branch
assert "setUiState(UI_STATE.streaming)" in branch
assert "closeThinkingBlock(wrap)" in branch
def test_done_branch_sets_sawdone_and_closes_block() -> None:
"""On `done` the turn marks itself complete (sawDone — the stream-drop
guard keys off it) and settles the thinking block closed."""
js = _js()
done_idx = js.find('ev.type === "done"')
error_idx = js.find('ev.type === "error"')
assert -1 < done_idx < error_idx
branch = js[done_idx:error_idx]
assert "sawDone = true" in branch
assert "closeThinkingBlock(wrap)" in branch
def test_stream_drop_guard_reports_severed_stream() -> None:
"""A stream that delivered frames but no `done` event ends in the error
state (never a silent idle with a half bubble); the zero-frame case
falls through to the existing empty-answer fallback. The guard runs
after readSSE, before that fallback."""
js = _js()
assert "let sawDone = false" in js
assert re.search(r"if \(!sawDone && !aborted && \(acc \|\| thinkingAcc\)\)", js), (
"sawDone stream-drop guard missing after readSSE"
)
assert "The stream ended before my answer finished" in js
sse_idx = js.find("await readSSE(res,")
guard_idx = js.find("!sawDone && !aborted")
fallback_idx = js.find("!aborted && !wrap")
assert -1 < sse_idx < guard_idx < fallback_idx, (
"guard must sit between readSSE and the zero-frame fallback"
)
def test_thinking_chevron_stills_under_reduced_motion() -> None:
"""Phase 17: the only motion in the thinking block (the summary
chevron rotation) is disabled under prefers-reduced-motion."""
css = _css()
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
)
assert any(
"details.thinking summary::before" in b and "transition: none" in b
for b in blocks
), "chevron transition must still under reduced motion"
# ---------- stop generation (phase 48, task 02) ----------
def test_in_flight_button_is_the_stop_control() -> None:
"""Phase 48 (owner-locked 2026-08-29): in flight the button is the
enabled Stop control — "Stop" label, .is-stop class, spinner hidden
(the label + the rose treatment carry the state); idle/error keep
the Send label with the class removed. The state machine otherwise
stays unchanged (same four states, same single entry point)."""
js = _js()
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
assert 'sendBtn.classList.toggle("is-stop", inFlight)' in js
assert 'sendBtn.querySelector(".spinner").hidden = true' in js, (
"the spinner never shows — the Stop label carries the state"
)
assert "sendBtn.disabled = false" in js, "enabled in every state"
def test_abort_plumbing_owns_the_fetch() -> None:
"""The in-flight fetch is owned by an AbortController created at
turn start (module scope, cleared in the finally), passed to the
fetch as its signal; the 300s guard aborts the same controller as
its backstop — with `aborted = true` FIRST, so the catch never reads
the guard's abort as a user stop (one owner, same outcome)."""
js = _js()
assert "let turnAbort = null" in js, "module-scope abort owner"
assert "turnAbort = new AbortController()" in js, "fresh controller per turn"
assert "signal: turnAbort.signal" in js, "the fetch carries the signal"
guard_start = js.find("armTurnTimeout(() => {")
guard = js[guard_start : js.find("});", guard_start)]
assert "aborted = true" in guard and "turnAbort?.abort()" in guard, (
"the guard keeps cancelStream + the abort as backstops"
)
assert guard.index("aborted = true") < guard.index("turnAbort?.abort()"), (
"aborted must be set before the guard's abort"
)
handle = js.find("async function handleSend")
finally_idx = js.find("} finally {", handle)
finally_block = js[finally_idx : finally_idx + 700]
assert "turnAbort = null" in finally_block, "the abort owner is spent after the turn"
def test_stop_turn_is_the_user_abort() -> None:
"""stopTurn: a no-op unless a turn is in flight (thinking/streaming);
it marks the turn as user-stopped and aborts. The in-flight guard at
the top of handleSend routes a click / Enter-to-submit to it BEFORE
the !text guard — the enabled in-flight button can never start a
second turn."""
js = _js()
fn = js.find("function stopTurn")
assert fn != -1, "stopTurn must exist"
body = js[fn : js.find("\n}\n", fn)]
assert "uiState !== UI_STATE.thinking" in body
assert "uiState !== UI_STATE.streaming" in body
assert "stoppedByUser = true" in body
assert "turnAbort?.abort()" in body
handle = js.find("async function handleSend")
guard_idx = js.find("stopTurn();", handle)
text_idx = js.find("const text = input.value.trim()", handle)
assert handle < guard_idx < text_idx, (
"the in-flight guard (→ stopTurn) must precede the !text guard"
)
def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
"""The stop path in handleSend's catch: no error state, no error
banner; when answer text streamed the partial is kept on screen
(thinking block closed, Tune + Stopped note appended — admin parity
with the restore path) and persisted with the owner-locked optional
`stopped: true` marker (+ optional thinking/tools); a pre-token stop
persists nothing brain-side (phase-20 convention). The "Answer
stopped." live-region confirmation is set in the finally, AFTER the
single settle, so setUiState(idle) can't overwrite it."""
js = _js()
handle = js.find("async function handleSend")
catch_idx = js.find("} catch (err) {", handle)
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch_idx)
finally_idx = js.find("} finally {", catch_idx)
assert catch_idx < stop_idx < finally_idx, "the stop branch must live in the catch"
# The stop branch only (the error `else` follows it and is not pinned here).
branch = js[stop_idx : js.find("} else {", stop_idx)]
assert "setUiState(UI_STATE.error" not in branch, "no error state on the stop path"
assert "showErrorBanner" not in branch, "no error banner on the stop path"
assert "if (wrap && acc && !persistedOnLeave)" in branch, (
"only a partial WITH answer text is persisted (phase-20 dedupe)"
)
assert "closeThinkingBlock(wrap)" in branch
assert "appendTuneButton(wrap)" in branch, "admin parity with the restore path"
assert "appendStoppedNote(wrap)" in branch
assert "stopped: true" in branch, "the owner-locked optional marker"
assert "thinking: thinkingAcc || undefined" in branch
assert "tools: toolAcc.length ? toolAcc : undefined" in branch
# The confirmation rides the single settle in the finally.
finally_block = js[finally_idx : finally_idx + 900]
assert 'if (stoppedByUser) sendStatus.textContent = "Answer stopped."' in finally_block
def test_stopped_note_helper_and_restore_path() -> None:
"""appendStoppedNote: reuses/creates the .msg-meta row exactly like
appendTuneButton (role=list → the span joins as a listitem), one
.stopped-note per bubble — the aria-hidden stop-glyph SVG + the
"Stopped" text (the accessible meaning). The restore path renders it
for records with `m.stopped` (phase-14 optional-field convention —
no version bump)."""
js = _js()
fn = js.find("function appendStoppedNote")
assert fn != -1, "appendStoppedNote must exist"
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
assert 'className = "msg-meta"' in body, "creates it otherwise"
assert 'className = "stopped-note"' in body
assert 'querySelector(".stopped-note")' in body, "one note per bubble"
assert 'note.setAttribute("role", "listitem")' in body
assert 'aria-hidden="true"' in body, "the glyph is decoration"
assert '"Stopped"' in body, "the text carries the accessible meaning"
# Restore path: the same helper, gated on the stored marker.
rfn = js.find("function renderStoredMessage")
rbody = js[rfn : js.find("\n}\n", rfn)]
assert "if (m.stopped) appendStoppedNote(wrap)" in rbody
def test_tool_branch_no_longer_writes_the_button_label() -> None:
"""Phase 48 (owner-locked): the `tool` frame no longer relabels the
button — it stays "Stop" for the whole in-flight turn; the
calling-tool status lives in #send-status + the typing indicator's
aria-label only (exactly where the phase-37 state used to write)."""
js = _js()
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert "sendLabel" not in branch, "the button keeps its Stop label"
assert "sendStatus.textContent = toolStatus" in branch
assert 'setAttribute("aria-label", toolStatus)' in branch
def test_composer_form_is_novalidate() -> None:
"""Phase 48 (latent-defect fix, 2026-08-29): the composer form must
skip browser constraint validation. The input is cleared after every
send, so a `required` textarea would fail validation on the Stop
click/Enter — the `submit` event never fires and handleSend's
in-flight guard never runs, so the Stop control is dead. The `!text`
guard in app.js is the real empty-input check (same precedent as the
tuning form's noValidate)."""
html = _html()
composer = html.find('id="composer"')
assert composer != -1, "index.html must contain #composer"
form_tag = html[html.rfind("<form", 0, composer) : html.find(">", composer) + 1]
assert "novalidate" in form_tag.lower(), (
"the composer form must carry novalidate — a `required` input that "
"is empty in flight would silently block the Stop submit"
)
textarea = html[composer: html.find("</textarea>", composer)]
assert not re.search(r"\brequired\b", textarea), (
"the composer textarea must not carry `required` (see novalidate)"
)
# ---------- retry answer (phase 49, task 01) ----------
def test_run_turn_is_the_extracted_turn_handler() -> None:
"""Phase 49 (owner-locked 2026-08-29, TODO.md L4): the turn
machinery is extracted from handleSend into
`runTurn(text, { reask = false })`. handleSend keeps only the
form-level pre-work (the in-flight stop guard, the !text guard, the
composer pre-work) and delegates; the user append + persistence
save point 1 (push + save) sit in runTurn's `!reask` block — the
redo-in-place retry path skips both, because the question is
already in the DOM and in `conversation`."""
js = _js()
assert re.search(r"async function runTurn\(text, \{ reask = false \} = \{\}\)", js), (
"runTurn(text, { reask = false }) must be the extracted turn handler"
)
handle = js.find("async function handleSend")
turn = js.find("async function runTurn")
assert -1 < handle < turn, "runTurn follows handleSend (the extracted body)"
handle_body = js[handle:turn]
assert "stopTurn();" in handle_body, "the in-flight guard stays in handleSend"
assert "const text = input.value.trim()" in handle_body, "the !text guard stays"
assert 'input.value = ""' in handle_body
assert "autoGrow()" in handle_body
assert "clearErrorBanner()" in handle_body
assert "runTurn(text, { reask: false })" in handle_body, ("handleSend delegates the turn")
assert 'addMessage("user"' not in handle_body, (
"the user append moved with the turn into runTurn"
)
assert "conversation.push" not in handle_body, (
"persistence save point 1 moved with the turn into runTurn"
)
reask_idx = js.find("if (!reask) {", turn)
wrap_idx = js.find("let wrap = null", turn)
assert -1 < reask_idx < wrap_idx, (
"the reask gate must precede the turn machinery (the append happens "
"before the turn starts, exactly like the pre-extraction order)"
)
turn_top = js[turn:wrap_idx]
assert "if (!reask) {" in turn_top, "the reask gate guards the append + push"
assert 'addMessage("user", renderMarkdown(text), true)' in turn_top
assert 'conversation.push({ who: "user", text })' in turn_top
assert "saveConversation()" in turn_top
def test_retry_button_is_not_admin_gated_and_one_per_bubble() -> None:
"""appendRetryButton: the house appendTuneButton pattern — reuses the
.msg-meta row when it exists (role=list → the button joins as a
listitem so ARIA stays valid), creates it otherwise, one .retry-btn
per bubble, the aria-hidden redo glyph + the "Retry" text (the
accessible name). NOT admin-gated — unlike appendTuneButton, chat is
public and every visitor gets the redo (owner-locked)."""
js = _js()
fn = js.find("function appendRetryButton")
assert fn != -1, "appendRetryButton must exist"
body = js[fn: js.find("\n}\n", fn)]
assert "isAdmin" not in body, "Retry is NOT admin-gated (owner-locked: all visitors)"
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
assert 'className = "msg-meta"' in body, "creates it otherwise"
assert 'className = "retry-btn"' in body
assert 'querySelector(".retry-btn")' in body, "one Retry button per bubble"
assert 'btn.setAttribute("role", "listitem")' in body, ("role=list → listitem")
assert "<span>Retry</span>" in body, "the text carries the accessible name"
assert "RETRY_ICON" in body, "the button leads with the redo glyph"
icon = js[js.find("const RETRY_ICON") : js.find(";", js.find("const RETRY_ICON"))]
assert 'aria-hidden="true"' in icon, "the redo glyph is decoration"
assert "retryLastTurn(wrap)" in body, "the click handler re-asks in place"
def test_mark_last_retryable_is_remove_then_append() -> None:
"""markLastRetryable: last-bubble-only management — remove every
rendered .retry-btn FIRST (an earlier bubble's button is stale the
moment a newer answer lands), then append the button to the last
brain bubble (only when its preceding user record exists to re-ask
— the invariant: every brain record follows its user record)."""
js = _js()
fn = js.find("function markLastRetryable")
assert fn != -1, "markLastRetryable must exist"
body = js[fn: js.find("\n}\n", fn)]
assert 'querySelectorAll(".retry-btn")' in body, "finds every rendered Retry button"
assert ".remove()" in body
assert "appendRetryButton(lastBrainWrap)" in body
assert body.index(".remove()") < body.index("appendRetryButton(lastBrainWrap)"), (
"the existing buttons must be removed before the new one is appended"
)
assert 'who !== "user"' in body, "needs the preceding user record to re-ask"
def test_mark_last_retryable_call_sites() -> None:
"""The four call sites (owner-locked): on `done` (after the Tune
append), the empty-answer fallback, the stop finalize (the stopped
partial is the prime retry candidate — after the partial is
persisted), and once at the end of the phase-14 restore.
startNewChat needs none: its list reset removes the buttons along
with the list."""
js = _js()
# done branch: after appendTuneButton.
done = js.find('ev.type === "done"')
done_branch = js[done: js.find('ev.type === "error"', done)]
assert "appendTuneButton(wrap)" in done_branch
assert "markLastRetryable()" in done_branch
assert done_branch.index("appendTuneButton(wrap)") < done_branch.index(
"markLastRetryable()"
), "the Retry append rides the done save point, after Tune"
# empty-answer fallback.
fallback = js.find("!aborted && !wrap")
fallback_block = js[fallback: js.find(")} catch (err) {", fallback)]
assert "appendTuneButton(fwrap)" in fallback_block
assert "markLastRetryable()" in fallback_block
# stop finalize: after the stopped partial is persisted.
catch = js.find("} catch (err) {")
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch)
stop_branch = js[stop_idx: js.find("} else {", stop_idx)]
assert "markLastRetryable()" in stop_branch
assert stop_branch.index("rememberBrainTurn") < stop_branch.index("markLastRetryable()"), (
"the Retry button lands only after the stopped partial is persisted"
)
# restore: once, at the end.
rfn = js.find("function restoreConversation")
rbody = js[rfn: js.find("\n}\n", rfn)]
assert rbody.count("markLastRetryable()") == 1
# startNewChat: no call — the list reset removes the buttons anyway.
nfn = js.find("function startNewChat")
nbody = js[nfn: js.find("\n}\n", nfn)]
assert "markLastRetryable" not in nbody
def test_retry_last_turn_redo_in_place_order() -> None:
"""retryLastTurn: the in-flight no-op (one turn at a time), the
stale-click guard (the click's wrap must still be the last brain
bubble's rendered wrap), and the redo-in-place order — pop the brain
record → saveConversation() BEFORE the rerun (a crash between the
pop and the fresh `done` never resurrects the replaced answer; the
question remains) → remove the wrap → runTurn(text, { reask: true }).
No banner, no scroll (phase 42)."""
js = _js()
fn = js.find("function retryLastTurn")
assert fn != -1, "retryLastTurn must exist"
body = js[fn: js.find("\n}\n", fn)]
assert "uiState === UI_STATE.thinking" in body, "in-flight no-op (thinking)"
assert "uiState === UI_STATE.streaming" in body, "in-flight no-op (streaming)"
assert "wrap !== lastBrainWrap" in body, "the stale-click guard"
assert "conversation.splice" in body, "the brain record is popped in place"
assert "saveConversation()" in body, "the pop is saved immediately"
assert "wrap.remove()" in body, "the old bubble leaves the DOM"
assert "runTurn(text, { reask: true })" in body, "re-ask without re-adding"
i_splice = body.index("conversation.splice")
i_save = body.index("saveConversation()")
i_remove = body.index("wrap.remove()")
i_rerun = body.index("runTurn(text, { reask: true })")
assert i_splice < i_save < i_remove < i_rerun, (
"pop → save → remove → rerun — the save must precede the rerun"
)
assert "showErrorBanner" not in body, "no error banner on the retry path"
assert "scrollReveal" not in body, "no scroll (phase 42: the bubble lands in place)"
def test_last_brain_wrap_tracking_and_restore() -> None:
"""lastBrainWrap is the rendered wrap of the current last brain
record: set on the `done` save point, the empty-answer fallback, and
the stop finalize (each before markLastRetryable), set for every
restored brain bubble (the LAST one wins), cleared by the retry
pop. The restore path marks the restored last brain bubble
retryable at the end."""
js = _js()
assert "let lastBrainWrap = null" in js, "module-scope last-brain wrap"
done = js.find('ev.type === "done"')
done_branch = js[done: js.find('ev.type === "error"', done)]
assert "lastBrainWrap = wrap" in done_branch
assert done_branch.index("lastBrainWrap = wrap") < done_branch.index("markLastRetryable()")
fallback = js.find("!aborted && !wrap")
fallback_block = js[fallback: js.find(")} catch (err) {", fallback)]
assert "lastBrainWrap = fwrap" in fallback_block
catch = js.find("} catch (err) {")
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch)
stop_branch = js[stop_idx: js.find("} else {", stop_idx)]
assert "lastBrainWrap = wrap" in stop_branch
rfn = js.find("function renderStoredMessage")
rbody = js[rfn: js.find("\n}\n", rfn)]
assert "lastBrainWrap = wrap" in rbody, "every restored brain bubble updates it"
fn = js.find("function retryLastTurn")
body = js[fn: js.find("\n}\n", fn)]
assert "lastBrainWrap = null" in body, "the retry pop clears it"
def test_retry_btn_css_is_the_tune_family() -> None:
"""Phase 49 styling: the .retry-btn pill is the exact .tune-btn
visual family (same size/spacing/min-height, right-aligned after
the source chips, 14px glyph) so the two meta actions read as a
pair — with the neutral ink-soft → ink hover (Tune keeps the brand
pair). Contrast: ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on
--brand-soft ~15.7:1 — all AA. The mobile squeeze keeps the >=44px
floor."""
css = _css()
block = re.search(r"\.retry-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .retry-btn"
body = block.group(1)
for prop in (
"display: inline-flex",
"min-height: 44px",
"margin-left: auto",
"padding: 0.35rem 0.8rem",
"border-radius: 999px",
"border: 1px solid var(--line)",
"color: var(--ink-soft)",
"font-size: 0.82rem",
"cursor: pointer",
):
assert prop in body, f".retry-btn must keep the .tune-btn family ({prop})"
assert re.search(r"\.retry-btn svg \{ width: 14px; height: 14px", css), (
"the redo glyph rides the 14px meta-row size"
)
hover = re.search(r"\.retry-btn:hover \{([\s\S]*?)\n\}", css)
assert hover, ".retry-btn must have the hover step"
assert "color: var(--ink)" in hover.group(1), "ink-soft → ink on hover (neutral)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
assert ".retry-btn { min-height: 44px; }" in mobile.group(1), (
"the >=44px touch floor holds in the mobile squeeze"
)
def test_index_messages_comment_documents_the_meta_actions() -> None:
"""The #messages section comment documents the JS-injected meta-row
actions: Tune (admin only) and Retry (every visitor, last brain
bubble only) — no static markup for either."""
html = _html()
section = html.find('<section class="messages"')
assert section != -1, "index.html must contain the #messages section"
comment = html[max(0, section - 900):section]
assert "Retry" in comment and "Tune" in comment, (
"the messages-section comment must mention the meta-row actions"
)
assert "admin" in comment, "Tune is documented as admin-only"
assert "every visitor" in comment.lower() or "everyone" in comment.lower(), (
"Retry is documented as available to all visitors"
)
# ---------- llm retry status (phase 67, task 04) ----------
def test_retry_frame_is_a_first_class_branch_between_tool_and_delta() -> None:
"""Phase 67 (owner-locked A4, task 02's contract): a `retry` SSE
frame (the server restarted the LLM request before its first piece
— locked A2) is a first-class branch in runTurn's handler, ordered
BETWEEN the `tool` and `delta` branches. It clears the 300s guard
(a frame arrived), resolves n/N from the frame, and writes the
owner-locked copy literal onto the EXISTING channels only — the
#send-status live region + the typing-indicator aria-label. No DOM
of its own: no addMessage, no appendToolLine, no error banner, no
UI-state change. The gate covers BOTH live states: a later agent
round may restart while the UI already streams."""
js = _js()
tool_idx = js.find('ev.type === "tool"')
retry_idx = js.find('ev.type === "retry"')
delta_idx = js.find('ev.type === "delta"')
assert -1 < tool_idx < retry_idx < delta_idx, (
"the turn handler must branch on retry frames, between tool and delta"
)
assert js.count('ev.type === "retry"') == 1, "exactly one retry branch"
branch = js[retry_idx:delta_idx]
assert "clearTurnTimeout()" in branch, "a frame arrived — the 300s guard clears"
assert "Number(ev.attempt)" in branch, "n = the attempt about to be tried"
assert "Number(ev.max_attempts)" in branch, "N = the configured total"
assert (
"`Communication interrupted — retrying (${attempt} of ${max})…`" in branch
), "the owner-locked copy literal (A4)"
assert "sendStatus.textContent = retryStatus" in branch, (
"the existing #send-status live region carries the status"
)
assert "#typing-indicator .bubble" in branch, ("the typing-indicator is reused")
assert 'setAttribute("aria-label", retryStatus)' in branch
assert "UI_STATE.thinking" in branch and "UI_STATE.streaming" in branch, (
"the gate covers BOTH live states (a later round may restart mid-stream)"
)
for forbidden in ("addMessage", "appendToolLine", "showErrorBanner", "setUiState"):
assert forbidden not in branch, f"transient status only — no {forbidden}"
def test_header_inventory_documents_the_retry_frame() -> None:
"""The app.js file-header doc comment inventories every SSE frame
type (house convention); phase 67 adds the `retry` frame there, with
the owner-locked copy quoted, so the A4 literal has exactly two
homes: the header doc and the handler branch."""
js = _js()
header = js[: js.find("import {")]
assert "`retry`" in header, "the header must list the retry frame"
assert "Communication interrupted — retrying" in header
assert header.count("Communication interrupted — retrying") == 1