All gates green. Verification complete. **Phase 123 — final verification pass (all 4 tasks already in `complete/`)** - Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer. - `uv run pytest` → **2796 passed**, exit 0 (unit + integration). - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%). - `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation. - `uv run ruff check . && uv run pyright` → clean (0 errors). **Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run). No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
690 lines
33 KiB
Python
690 lines
33 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 (--brand-stop treatment — the brand darkened toward --bg,
|
|
5.8:1 with the white label at the built-in default, phase 92),
|
|
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 "background: var(--brand-stop)" in css, (
|
|
"the stop background: the brand darkened toward --bg (5.8:1 with the white label)"
|
|
)
|
|
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, image = null })` (the `image`
|
|
argument is phase 123 task 02's optional attachment). handleSend
|
|
keeps the form-level pre-work (the in-flight stop guard, the
|
|
!text guard, the composer pre-work) plus — since phase 123 — the
|
|
locked-A8 upload step (an attached image uploads BEFORE the
|
|
input is cleared; a failed upload blocks the send) 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, image = null \} = \{\}\)", js
|
|
), (
|
|
"runTurn(text, { reask = false, image = null }) 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
|
|
# Phase 123 (task 02, locked A8): the delegation carries the
|
|
# upload step's `image` ({ path, src, alt } | null) — null for a
|
|
# text-only send (the pre-phase shape).
|
|
assert "runTurn(text, { reask: false, image })" in handle_body, (
|
|
"handleSend delegates the turn (with the attached image's path)"
|
|
)
|
|
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"
|
|
# Phase 123 (task 02): the user append + push carry the optional
|
|
# attachment — the bubble gets { src, alt } (the data URL live; the
|
|
# stored path is the fallback) and the record gains the `image`
|
|
# key (the STORED path — A5: never base64) only when one exists;
|
|
# a null image keeps the pre-phase shapes verbatim. The strip must
|
|
# not linger into the turn (cleared after the bubble renders).
|
|
assert re.search(
|
|
r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', turn_top
|
|
), "the submit must reveal the user message (scroll intent true)"
|
|
assert "image ? { src: image.src || image.path, alt: image.alt } : null" in turn_top
|
|
assert "{ who: \"user\", text, image: image.path }" in turn_top
|
|
assert "{ who: \"user\", text }" in turn_top
|
|
assert "clearAttachedImage()" in turn_top, (
|
|
"the preview strip must not linger into the turn"
|
|
)
|
|
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
|