feat(chat): retry the last answer — redo-in-place Retry button on the latest brain bubble

This commit is contained in:
2026-08-29 18:32:15 -04:00
parent 1a60ecbd8b
commit 6832957ab0
7 changed files with 891 additions and 15 deletions
+155 -6
View File
@@ -110,6 +110,20 @@
* below — the header.js single-evaluation design (no direct <script>
* tag; esbuild inlines it into the page bundle).
*
* Retry the last answer (phase 49, owner-locked 2026-08-29, TODO.md L4):
* a "Retry" button in the meta row of the LAST brain bubble re-asks the
* preceding question IN PLACE — the old answer is removed from the DOM
* and from the persisted record (saved immediately after the pop, so a
* crash between the pop and the fresh `done` can never resurrect the
* replaced answer; the question stays), and the fresh answer streams
* into its place without re-adding the question. This is what
* `runTurn(text, { reask })` is for: the turn extracted from handleSend
* skips the user append + persistence save point 1 when `reask` is set.
* Only the last brain bubble carries the button (markLastRetryable), it
* is NOT admin-gated (chat is public — unlike Tune), and it is inert
* while a turn is in flight. No banner, no scroll (phase 42): the fresh
* bubble lands where the old one was.
*
* All DOM ids match frontend/index.html.
*/
@@ -315,6 +329,74 @@ function appendStoppedNote(wrap) {
meta.appendChild(note);
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the rendered wrap of
* the CURRENT last brain record. Set wherever a brain bubble becomes the
* latest persisted answer (the `done` branch, the empty-answer fallback,
* the stop finalize) and on the phase-14 restore (the LAST restored
* brain bubble wins); cleared when the retry redo pops it. Both
* retryLastTurn's stale-click guard and markLastRetryable's targeting
* key off it. */
let lastBrainWrap = null;
/* The Retry button's redo glyph (aria-hidden decoration — the "Retry"
* text carries the accessible name), currentColor so the CSS themes the
* stroke (ink-soft → ink on hover, the phase-08 palette). */
const RETRY_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
/* "Retry" button in the meta row of the last brain bubble — the redo
* action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
* appendTuneButton pattern: reuses the .msg-meta row when it exists
* (role=list → the button joins as a listitem so ARIA stays valid),
* otherwise creates a plain meta row; one button per bubble. NOT
* admin-gated, unlike appendTuneButton — chat is public, so every
* visitor gets the redo (the meta-row actions read as a pair: Tune for
* the admin, Retry for everyone). Click → retryLastTurn(wrap). */
function appendRetryButton(wrap) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".retry-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "retry-btn";
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = RETRY_ICON + "<span>Retry</span>";
btn.addEventListener("click", () => retryLastTurn(wrap));
meta.appendChild(btn);
}
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry
* button lives on exactly ONE bubble — the last brain answer. Remove
* every rendered .retry-btn FIRST (an earlier bubble's button is stale
* the moment a newer answer lands), then re-append it to the last brain
* bubble — but only when that record has its preceding user record to
* re-ask (the invariant holds in practice: every brain record follows
* its user record). Call sites: on `done`, on the empty-answer
* fallback, on the stop finalize (a stopped partial is the prime retry
* candidate), and once at the end of the phase-14 restore.
* startNewChat needs no call: its list reset removes the buttons along
* with the list. */
function markLastRetryable() {
messagesEl.querySelectorAll(".retry-btn").forEach((b) => b.remove());
if (!lastBrainWrap) return;
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap);
}
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
+ Save / Cancel. Success replaces the form with the .tune-saved status
(role=status); failure keeps the form and shows an inline error
@@ -887,6 +969,7 @@ function renderStoredMessage(m) {
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
}
/* On load: re-render the stored conversation (markdown, source chips,
@@ -895,6 +978,7 @@ function renderStoredMessage(m) {
function restoreConversation() {
conversation = loadStoredConversation();
for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
}
/* Brain message save point (on `done`): raw accumulated text + metadata.
@@ -994,6 +1078,46 @@ function stopTurn() {
turnAbort?.abort();
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the redo-in-place
* retry — the click handler of the Retry button, which only ever sits
* on the LAST brain bubble. It re-asks the question preceding that
* bubble: the old answer is replaced in the DOM AND in the persisted
* record (the pop is saved immediately — a crash between the pop and
* the fresh `done` must never resurrect the replaced answer; the
* question remains), and the fresh answer streams into its place via
* runTurn(text, { reask: true }) — no user append, no push, no banner,
* no scroll (phase 42: the fresh bubble lands where the old one was).
* Guards: inert while a turn is in flight (one turn at a time), and the
* click's wrap must still be the last brain bubble's rendered wrap — a
* stale click on a superseded bubble is harmless by construction. */
function retryLastTurn(wrap) {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
if (wrap !== lastBrainWrap) return; // stale click — the button moved on
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
if (lastIdx === -1) return;
// Invariant: every brain record follows its user record — the
// question to re-ask is the record immediately before the popped one.
const prev = conversation[lastIdx - 1];
if (!prev || prev.who !== "user") return;
const text = prev.text;
conversation.splice(lastIdx, 1); // redo in place: the old answer is gone
// Save BEFORE the rerun: what the user saw — the removed answer — is
// what is stored from this point on (the question stays, the replaced
// answer never comes back).
saveConversation();
wrap.remove();
lastBrainWrap = null;
// Re-ask without re-adding: the reask turn skips the user append and
// persistence save point 1 (the question is already in both).
void runTurn(text, { reask: true });
}
async function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
@@ -1005,15 +1129,34 @@ async function handleSend(e) {
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
saveConversation();
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
// conversation); handleSend keeps only the form-level pre-work.
input.value = "";
autoGrow();
clearErrorBanner();
await runTurn(text, { reask: false });
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn —
* extracted from handleSend so the retry redo can re-run a question
* without re-adding it. `reask` skips (a) the user-bubble append and
* (b) persistence save point 1 (the conversation push + save) — the
* question is already in the DOM and in `conversation`. A plain send
* (`reask = false`) is byte-identical to the pre-extraction path:
* everything from setUiState(thinking) / armTurnTimeout through the
* finally settle moved here verbatim, and the turn-local resets (acc,
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
* stay turn-scoped exactly as phase 48 left them. */
async function runTurn(text, { reask = false } = {}) {
if (!reask) {
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
saveConversation();
}
let wrap = null;
let res = null;
@@ -1160,6 +1303,8 @@ async function handleSend(e) {
sources: ev.sources,
suggestions: ev.suggestions,
});
lastBrainWrap = wrap; // this bubble is now the last brain answer
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
} else if (ev.type === "error") {
throw new Error(ev.detail || "Something went wrong on my side.");
}
@@ -1179,6 +1324,8 @@ async function handleSend(e) {
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
rememberBrainTurn(fallback, {}); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
}
} catch (err) {
if (aborted) {
@@ -1201,6 +1348,8 @@ async function handleSend(e) {
tools: toolAcc.length ? toolAcc : undefined,
stopped: true,
});
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
markLastRetryable(); // phase 49: Retry on the stopped partial
}
// Pre-token / thinking-only stop: persist NOTHING brain-side
// (phase-20 convention — the question is already saved on send).
+30
View File
@@ -623,6 +623,35 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.tune-btn svg { width: 14px; height: 14px; display: block; }
.tune-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the "Retry" button —
the redo-in-place action in the meta row of the LAST brain bubble.
The exact visual family of .tune-btn (same pill size/spacing, the
global :focus-visible ring, >=44px via min-height) so the two meta
actions read as a pair — but neutral: ink-soft -> ink on hover (Tune
keeps the brand pair). The redo glyph rides currentColor. Contrast:
ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft
~15.7:1 — all AA. */
.retry-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
margin-left: auto;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.retry-btn svg { width: 14px; height: 14px; display: block; }
.retry-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 48: the "Stopped" note in a stopped brain bubble's meta row:
ink-soft on the surface bubble ≈6.9:1, the 10px filled-square glyph
centered with the row (the Tune button shares the row), and
@@ -2322,6 +2351,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
.tune-btn { min-height: 44px; }
.retry-btn { min-height: 44px; }
/* Phase 27: the tuning page squeezes like the other cards — the row
padding tightens; the icon+label actions keep their 44px floor and
the note text ellipsizes (min-width: 0). */
+5
View File
@@ -109,6 +109,11 @@
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
bubble can carry JS-injected actions (app.js) — Tune (admin
only, phase 15) and Retry (every visitor; the LAST brain
bubble only, redo-in-place). No static markup: both are
injected like the source chips. -->
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
<div class="empty-state" id="empty-state">
<div class="empty-state-glyph" aria-hidden="true">
+446
View File
@@ -0,0 +1,446 @@
"""Phase 49 E2E (Playwright): retry the last answer (redo in place).
Source: ``TODO.md`` L4 — "Need a retry button to retry the last answer,
like a redo button" (no user story file — TODO-derived phase).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_retry_answer.py -v --no-cov
Mock-only, no admin login (chat is public — Retry is NOT admin-gated,
unlike Tune). The owner-locked contract (2026-08-29) under test:
* the LAST brain bubble (completed, deflected, or a phase-48 stopped
partial) carries the Retry button in its meta row; earlier brain
bubbles do not (``markLastRetryable`` is last-bubble-only);
* clicking Retry redoes the turn IN PLACE — the old answer leaves the
DOM and the persisted ``bor.chat.v1`` record (the pop is saved before
the rerun, so a crash never resurrects the replaced answer), the
preceding question is re-asked WITHOUT being duplicated, and the
fresh answer streams into the old bubble's place; the new bubble is
the new last and carries the Retry button;
* a stopped partial is the prime retry candidate: the redo replaces it
with a fresh ``done`` answer (no ``stopped`` marker, the full text —
the long answer's unique final line arrives);
* Retry is inert while a turn is in flight — no second turn starts, no
bubble or record duplication, the in-flight turn completes to its own
``done``.
Determinism: the mock quotes the asked question into its grounded
answer (ending in the ``Deterministic mock answer for E2E`` marker), the
deflection answer is a fixed honest phrase, and the long answer ("write
a long answer" trigger) streams ~900 words over ~8 s (12 chars / 0.02 s)
with a unique final line (``LONG-ANSWER-END``) absent from any partial.
OLD-vs-fresh bubble identity is proved with a test-only
``data-retry-marker`` attribute set on the old wrap before the click —
the mock answers are byte-stable, so a redo of the same question is
textually indistinguishable from the original and only the DOM element
(and the storage record) can prove the replacement.
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: Two grounded (on-topic) questions — the phase-03 phrasing and the
#: phase-14 follow-up, both retrieve the kubernetes fixture doc.
Q1 = "How is my Kubernetes cluster set up?"
Q2 = "What about the nodes?"
#: Phase-04 phrasing: no token overlap with the fixtures → the honesty
#: gate is LOW → the deterministic deflection answer.
OFF_TOPIC = "How do I bake sourdough bread?"
#: On-topic + the mock's long-answer trigger (~900 words, ~8 s at the
#: mock's pacing — the phase-48 stop window).
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: Phase-06 phrasing: the mock's 3 s warm-up before the first token —
#: a comfortable in-flight window to click the stale Retry button.
SLOW_QUESTION = "pretend to think slowly then tell me about kubernetes"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
DEFLECT_PHRASE = r"haven't done anything like that"
LONG_ANSWER_END = "LONG-ANSWER-END"
STORAGE_KEY = "bor.chat.v1"
#: The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
#: The brain message wraps, in rendered order (the typing indicator is
#: absent in every settled state these tests read at).
BRAIN_WRAPS = "#messages > .msg.brain"
# ---------------------------------------------------------------------------
# KB seeding (same pattern as the phase 02/03/04/14/48 story suites)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int) -> ImportSummary:
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _stored_parsed(page: Page) -> dict[str, Any]:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
def _assert_no_error_banner(page: Page) -> None:
"""A retry settles through the normal done path — never the red
role=alert error banner (the KB-offline banner is a separate,
health-driven state the db_ready fixture keeps away)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_attribute("role", "alert")
expect(banner).not_to_have_class(re.compile(r"is-error"))
def _tag_last_brain_wrap(page: Page, marker: str) -> None:
"""Tag the rendered wrap of the LAST brain bubble (settled state —
no typing indicator present) so the test can prove the OLD element
leaves the DOM: the mock answers are byte-stable, so a redo of the
same question is textually indistinguishable from the original."""
page.evaluate(
"""(marker) => {
const wraps = document.querySelectorAll("#messages > .msg.brain");
wraps[wraps.length - 1].setAttribute("data-retry-marker", marker);
}""",
marker,
)
# ---------------------------------------------------------------------------
# Shared flows
# ---------------------------------------------------------------------------
def _ask(page: Page, question: str) -> None:
"""Send one grounded turn and wait until the answer has fully landed
(marker = last chunk streamed; "Send" = the done save point settled)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _ask_deflected(page: Page, question: str) -> None:
"""Send an off-topic turn and wait until the deflected answer landed."""
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain.is-deflected .bubble").first
bubble.wait_for(state="visible", timeout=30_000)
expect(bubble).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE), timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _stop_long_answer_mid_stream(page: Page) -> str:
"""Ask the long on-topic question and Stop it once a few words of
answer have streamed (the phase-48 flow). Returns the rendered
partial text after the stop settles."""
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
answer = page.locator(ANSWER)
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the stop"
btn = page.locator("#send-btn")
expect(btn).to_have_class(re.compile(r"is-stop"))
btn.click()
expect(page.locator("#send-label")).to_have_text("Send", timeout=5_000)
_assert_no_error_banner(page)
stopped_text = answer.inner_text()
assert "Step 1:" in stopped_text
assert LONG_ANSWER_END not in stopped_text
return stopped_text
# ---------------------------------------------------------------------------
# 1. Grounded redo: last-bubble-only button, redo in place, no duplicate
# ---------------------------------------------------------------------------
def test_retry_redoes_in_place(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
page.goto(app_url)
# Two grounded turns: the conversation is [u1, b1, u2, b2].
_ask(page, Q1)
_ask(page, Q2)
# Last-bubble-only: exactly ONE Retry button, on the LAST brain
# bubble (the "Retry" text is the accessible name); the first brain
# bubble carries none.
expect(page.locator(".retry-btn")).to_have_count(1)
expect(page.locator(".retry-btn").first).to_contain_text("Retry")
first_wrap = page.locator(BRAIN_WRAPS).first
last_wrap = page.locator(BRAIN_WRAPS).last
expect(first_wrap.locator(".retry-btn")).to_have_count(0)
expect(last_wrap.locator(".retry-btn")).to_have_count(1)
# Tag the old last wrap: the redo must remove THIS element (the
# fresh answer is textually identical — the mock is byte-stable).
_tag_last_brain_wrap(page, "old-b2")
page.locator(".retry-btn").click()
# The redo is in flight immediately (the button is the Stop control),
# then settles to the fresh answer.
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
_assert_no_error_banner(page)
# The old answer left the DOM (the tagged wrap is gone) and the fresh
# answer streamed into its place: two brain bubbles, both grounded,
# the first one intact (it still quotes Q1).
expect(page.locator("[data-retry-marker='old-b2']")).to_have_count(0)
expect(page.locator(ANSWER)).to_have_count(2)
expect(page.locator(ANSWER).first).to_contain_text(Q1)
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
# The conversation never duplicated the question: exactly one user
# bubble for the retried question, two in total.
expect(page.locator(".msg.user .bubble")).to_have_count(2)
expect(page.locator(".msg.user .bubble", has_text=Q2)).to_have_count(1)
# Storage: [u1, b1, u2, b2'] — the retried question occurs once, the
# last brain record is the fresh answer.
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain", "user", "brain"]
assert msgs[0]["text"] == Q1
assert msgs[2]["text"] == Q2
assert [m["text"] for m in msgs if m["who"] == "user"].count(Q2) == 1
assert MOCK_ANSWER_MARKER in msgs[1]["text"]
assert MOCK_ANSWER_MARKER in msgs[3]["text"]
# The new answer is the new last — it carries the Retry button, the
# first bubble still does not.
expect(page.locator(".retry-btn")).to_have_count(1)
expect(page.locator(BRAIN_WRAPS).first.locator(".retry-btn")).to_have_count(0)
expect(page.locator(BRAIN_WRAPS).last.locator(".retry-btn")).to_have_count(1)
# ---------------------------------------------------------------------------
# 2. A stopped partial (phase 48) redoes to a fresh full done answer
# ---------------------------------------------------------------------------
def test_retry_on_stopped_partial(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
_stop_long_answer_mid_stream(page)
# The stopped partial carries the "Stopped" note AND the Retry
# button (the prime retry candidate — owner-locked).
expect(page.locator(".msg.brain .stopped-note")).to_have_count(1)
expect(page.locator(".retry-btn")).to_have_count(1)
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert msgs[-1]["stopped"] is True
assert LONG_ANSWER_END not in msgs[-1]["text"]
_tag_last_brain_wrap(page, "old-partial")
page.locator(".retry-btn").click()
# The turn re-runs to a fresh `done` answer (the full long answer —
# ~8 s of streaming at the mock's pacing).
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000)
_assert_no_error_banner(page)
# The partial is gone — DOM, "Stopped" note, and stored marker — and
# the fresh full answer stands in its place.
expect(page.locator("[data-retry-marker='old-partial']")).to_have_count(0)
expect(page.locator(".msg.brain .stopped-note")).to_have_count(0)
bubble = page.locator(ANSWER)
expect(bubble).to_have_count(1)
text = bubble.inner_text()
assert "Step 1:" in text
assert LONG_ANSWER_END in text, "the redo ran to a fresh full done answer"
# Storage: the stored partial is replaced — the last brain record
# has no `stopped` flag and carries the full answer.
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert msgs[-1].get("stopped") is not True
assert LONG_ANSWER_END in msgs[-1]["text"]
# The fresh answer is the new last brain bubble — it carries Retry.
expect(page.locator(".retry-btn")).to_have_count(1)
expect(page.locator(BRAIN_WRAPS).last.locator(".retry-btn")).to_have_count(1)
# ---------------------------------------------------------------------------
# 3. A deflected answer redoes: still deflected, chips re-rendered
# ---------------------------------------------------------------------------
def test_retry_deflected(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
_ask_deflected(page, OFF_TOPIC)
# The deflected bubble (with its Maybe-try chips; weak-hit source
# chips ride along per A8) carries the Retry button.
chips_before = page.locator(".msg.brain.is-deflected .maybe-try .suggestion-chip")
expect(chips_before.first).to_be_visible()
assert chips_before.count() >= 2
expect(page.locator(".retry-btn")).to_have_count(1)
_tag_last_brain_wrap(page, "old-deflect")
page.locator(".retry-btn").click()
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
_assert_no_error_banner(page)
# Redo mechanics: the old bubble is gone, the fresh deflected bubble
# stands in its place (still deflected — the mock is deterministic),
# the question was not duplicated.
expect(page.locator("[data-retry-marker='old-deflect']")).to_have_count(0)
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble")).to_contain_text(OFF_TOPIC)
fresh = page.locator(".msg.brain.is-deflected .bubble")
expect(fresh).to_have_count(1)
expect(fresh.first).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
# The "Maybe try" chips are re-rendered on the fresh bubble.
chips = page.locator(".msg.brain.is-deflected .maybe-try .suggestion-chip")
assert chips.count() >= 2, "the redo must re-render the maybe-try chips"
group = page.locator(".msg.brain.is-deflected .maybe-try")
expect(group).to_have_count(1)
expect(group.first).to_have_attribute("aria-label", "Maybe try")
# The fresh bubble is the new last — it carries the Retry button.
expect(page.locator(".retry-btn")).to_have_count(1)
expect(page.locator(BRAIN_WRAPS).last.locator(".retry-btn")).to_have_count(1)
# Storage: one question, one (still deflected) answer — no duplicate.
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert msgs[0]["text"] == OFF_TOPIC
assert msgs[-1]["deflected"] is True
assert len(msgs[-1]["suggestions"]) >= 2
# ---------------------------------------------------------------------------
# 4. Retry is inert while a turn is in flight
# ---------------------------------------------------------------------------
def test_retry_inert_while_in_flight(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
# One completed turn: the last (only) brain bubble carries Retry.
_ask(page, Q1)
expect(page.locator(".retry-btn")).to_have_count(1)
_tag_last_brain_wrap(page, "first-turn")
first_answer = page.locator(ANSWER).inner_text()
# Start the slow second turn (3 s pre-token warm-up = the window).
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
# Retry on the previous last bubble while in flight: a no-op — no
# second turn, the previous bubble (answer + button) stays put.
page.locator(".retry-btn").click()
expect(page.locator("[data-retry-marker='first-turn']")).to_have_count(1)
expect(page.locator("[data-retry-marker='first-turn'] .bubble")).to_have_text(
first_answer
)
# Still in flight — the in-flight turn, not a retry, owns the button.
expect(page.locator("#send-label")).to_have_text("Stop")
_assert_no_error_banner(page)
# The in-flight turn completes to its own done answer.
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
_assert_no_error_banner(page)
# No second turn started, no duplication: exactly two user bubbles —
# the in-flight question occurs once; the first turn is intact.
expect(page.locator(".msg.user .bubble")).to_have_count(2)
expect(page.locator(".msg.user .bubble", has_text=SLOW_QUESTION)).to_have_count(1)
expect(page.locator(ANSWER)).to_have_count(2)
expect(page.locator("[data-retry-marker='first-turn'] .bubble")).to_have_text(
first_answer
)
msgs = _stored_parsed(page)["messages"]
assert [m["text"] for m in msgs if m["who"] == "user"] == [Q1, SLOW_QUESTION]
assert [m["who"] for m in msgs] == ["user", "brain", "user", "brain"]
# The settled conversation is again last-bubble-only retryable.
expect(page.locator(".retry-btn")).to_have_count(1)
expect(page.locator(BRAIN_WRAPS).last.locator(".retry-btn")).to_have_count(1)
+239
View File
@@ -366,3 +366,242 @@ def test_composer_form_is_novalidate() -> None:
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"
)
+10 -4
View File
@@ -108,11 +108,17 @@ def test_submit_reveals_user_message() -> None:
"""User intent kept by the owner: submitting scrolls the viewport down
so the user's own message is visible — the submit addMessage passes
the scroll intent; the streaming brain-bubble creations in the same
function never do."""
turn handler never do.
Phase 49: the turn handler is runTurn (extracted from handleSend) —
the user append + persistence save point 1 sit in its `!reask`
block (the redo-in-place retry path skips both: the question is
already in the DOM + conversation)."""
js = _js()
send = js.find("async function handleSend")
assert send != -1, "handleSend must exist"
body = js[send : js.find("\n}\n", send)]
turn = js.find("async function runTurn")
assert turn != -1, "runTurn must exist (phase 49 extraction)"
body = js[turn : js.find("\n}\n", turn)]
assert "if (!reask) {" in body, "the user append is gated on !reask"
assert 'addMessage("user", renderMarkdown(text), true)' in body, (
"the submit must reveal the user message (scroll intent true)"
)
+6 -5
View File
@@ -99,11 +99,12 @@ def test_persisted_on_leave_flag_is_module_scoped_and_turn_reset() -> None:
"flag check first, set immediately before the persist call"
)
# Reset at the top of the turn handler (handleSend) — before the
# turn's fetch, where the other turn locals are initialized.
send = js.find("async function handleSend")
assert send != -1
top = js[send : send + 1500]
# Reset at the top of the turn handler (runTurn — phase 49 extracted
# the turn from handleSend) — before the turn's fetch, where the
# other turn locals are initialized.
turn = js.find("async function runTurn")
assert turn != -1
top = js[turn : turn + 1500]
assert "persistedOnLeave = false;" in top, (
"persistedOnLeave must be reset per turn, at the top of the turn handler"
)