phase: 120_failed_turn_retry
Build and Push Containers / build-and-push-app (push) Successful in 3m5s
Build and Push Containers / build-and-push-db (push) Failing after 2m13s

All verification complete. Final report:

**Phase 120 — Failed-turn retry: verification pass (all 3 tasks were done; final verification + 1 regression fix)**

**Verified:** `ChatMessage.failed`/`error` (≤500, `extra="forbid"` intact); `finalizeFailedTurn` funnel on the 3 failure paths (catch-else, stream-drop guard, zero-frame fallback) with `failed: true` + capped detail + `markLastRetryable`; `appendFailedNote` restore branch (Save-as-doc/Tune excluded); `showErrorBanner`/`retryLastTurn` byte-pinned untouched; only the three paths persist `failed: true` (grep + unit pin); no test asserts the old broken behavior.

**Defect found & fixed (rule 7):** a real navigate-away mid-turn let the browser's teardown fetch rejection (TypeError, not AbortError) leak into the failed funnel, persisting a phantom failed brain record — `test_sources_midstream_bug.py::test_no_orphan_brain_message_when_navigated_before_first_token` failed (2 `.msg` after reload) and violated the phase-20 navigate-away convention. Fixed: turn-scoped `leftThePage` flag (set unconditionally on `pagehide`, reset in `runTurn`) skips the funnel in the catch-else branch; pinned by new unit test `test_navigate_away_is_not_a_failed_turn`. No phase-overview/PLAN/todo/complete files touched; no commits made.

**Gates (exact):**
- `uv run pytest` → 2577 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 4271 stmts, 99% (>90%)
- `uv run pytest tests/e2e/test_failed_turn_retry.py -v --no-cov` → 4 passed (isolated)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- Regression E2E, isolated: `test_sources_midstream_bug.py` 6/6 (was 5/6); `test_llm_retry`/`test_tool_scaffolding_guardrails`/`test_stop_generation`/`test_navbar_refresh` 17/17

**Completion criteria:** (1) network error → banner + in-bubble Retry, re-ask without re-typing ✅ (E2E A); (2) refresh restores failed bubble + working Retry, no "new chat" ✅ (E2E C); (3) stopped/successful turns byte-identical ✅ (negative E2E, stop suite, byte-identity units); (4) pytest/coverage/lint/types ✅; (5) commit + phase move — left to the harness per pass rules.

**Notable:** deviation = the regression fix above (a navigation is not a failed turn; phase-20 partial-persist convention restored). Next pending phase: `121_git_source_tokens`.
This commit is contained in:
2026-09-24 18:50:36 -04:00
parent 0ff1f8c4d6
commit 3a0fc3db05
29 changed files with 2298 additions and 28 deletions
+196
View File
@@ -0,0 +1,196 @@
"""Unit: the failed-turn schema boundary (phase 120, task 03).
Phase 120 (task 01, locked A1) added two OPTIONAL keys to
``ChatMessage`` (``app/schemas.py``) — ``failed`` (a bool marker) and
``error`` (the persisted error detail, capped at 500) — the phase-48
``stopped`` precedent: a FAILED chat turn (network error, SSE ``error``
frame, stream drop) persists as a BRAIN record
``{who: "brain", text: <detail or fallback>, failed: true, error:
<detail>}`` — no separate error table, no new API (``retryLastTurn``'s
pop-the-last-brain-record logic works on a failed record UNCHANGED).
This module pins the boundary the phase plan names:
* a failed record (``failed: true`` + ``error``) validates and
round-trips losslessly;
* ``error`` of 501 chars 422s at the boundary (the phase-83
value-bounds style; exactly 500 passes);
* an unknown key still 422s (``extra="forbid"`` intact — the new keys
are DECLARED, they did not loosen the boundary);
* a record WITHOUT the new keys validates, serializes with the new keys
as explicit nulls, and — on every pre-phase key — is byte-identical
to the pre-phase-120 stored shape (the phase-50 contract: the server
stores ``model_dump()`` without ``exclude_none``, so a pre-phase
round-trip is untouched apart from the two added nulls).
House convention: pure schema tests (no DB, no client) — the API-level
round-trip pins live in ``tests/integration/test_chats_api.py``.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.schemas import ChatMessage
#: The phase-120 failed record shape (locked A1) — the zero-frame
#: network-error case exactly as ``finalizeFailedTurn`` persists it.
FAILED_RECORD: dict = {
"who": "brain",
"text": "My answer didn't make it — the connection dropped. Use Retry to ask again.",
"failed": True,
"error": "The chat model dropped the connection — try again?",
}
# ---------- acceptance: the failed record validates + round-trips ----------
def test_failed_record_validates() -> None:
"""A failed brain record (``failed: true`` + the capped ``error``
detail) crosses the boundary and the fields survive the trip."""
msg = ChatMessage.model_validate(FAILED_RECORD)
assert msg.who == "brain"
assert msg.text == FAILED_RECORD["text"]
assert msg.failed is True
assert msg.error == FAILED_RECORD["error"]
def test_failed_record_round_trips_losslessly() -> None:
"""The stored shape (``model_dump`` — plain, no ``exclude_none``,
the phase-50 storage convention) keeps the marker + detail and
fills the remaining optional keys with explicit nulls (the
restore path is null-safe)."""
dumped = ChatMessage.model_validate(FAILED_RECORD).model_dump()
assert dumped["failed"] is True
assert dumped["error"] == FAILED_RECORD["error"]
for key in ("sources", "related", "deflected", "suggestions", "thinking", "tools", "stopped"):
assert dumped[key] is None, f"{key} must be an explicit null, got {dumped[key]!r}"
# Re-validate the stored shape — the round-trip is lossless.
assert ChatMessage.model_validate(dumped).model_dump() == dumped
def test_failed_false_is_an_explicit_marker() -> None:
"""``failed: false`` is a legal value (a full ``bor.chat.v1`` brain
record carries the key explicitly — the integration FULL_BRAIN
round-trip relies on it); it must not be coerced to absent."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "hi", "failed": False, "error": None}
)
assert msg.failed is False
assert msg.error is None
assert msg.model_dump()["failed"] is False
# ---------- the value bounds: error ≤ 500 (phase-83 style) ----------
def test_error_at_500_chars_passes() -> None:
"""The bound is inclusive: exactly 500 chars validate."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "hi", "failed": True, "error": "e" * 500}
)
assert len(msg.error or "") == 500
def test_error_over_500_chars_is_rejected() -> None:
"""501 chars 422s at the boundary (the API surfaces this as a 422 —
the hostile detail string is capped at the schema, the
``finalizeFailedTurn`` 500-char slice is the UI-side first cut)."""
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "hi", "failed": True, "error": "e" * 501}
)
# ---------- the boundary stays strict: extra="forbid" intact ----------
def test_unknown_key_is_still_rejected() -> None:
"""``extra="forbid"`` was NOT loosened by the new keys: a stray
key still rejects at the boundary (the corrupted / HTML-shaped
payload defense, unchanged)."""
with pytest.raises(ValidationError):
ChatMessage.model_validate({"who": "brain", "text": "hi", "foo": 1})
def test_unknown_key_is_rejected_even_with_the_new_keys_present() -> None:
"""A record carrying the new keys AND an unknown key still
rejects — the new declarations did not widen the accepted key set
beyond ``failed``/``error``."""
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "hi", "failed": True, "error": "x", "foo": 1}
)
# ---------- backward compatibility: the pre-phase-120 shape ----------
def test_record_without_the_new_keys_validates_with_none() -> None:
"""A pre-phase record (no ``failed``/``error`` keys) still
validates; both new fields default to ``None`` (they round-trip as
nulls — absent/None, exactly like ``stopped`` today)."""
msg = ChatMessage.model_validate({"who": "brain", "text": "hi"})
assert msg.failed is None
assert msg.error is None
dumped = msg.model_dump()
assert dumped["failed"] is None
assert dumped["error"] is None
def test_pre_phase_record_round_trips_byte_identical_on_existing_keys() -> None:
"""The phase-50 contract through the new schema: a pre-phase-120
STORED record (every phase-50 key present, explicit nulls where an
optional key does not apply) validates, and re-serializes
byte-identically on EVERY pre-phase key — the only diff is the two
added keys as explicit nulls. Old saved chats and shared links
therefore render/restore exactly as before."""
pre_phase: dict = {
"who": "brain",
"text": "Your k3s cluster runs on three nodes — you've got this.",
"sources": [{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes"}],
"related": [{"source": "Homelab", "path": "traefik.md", "title": "Traefik"}],
"deflected": False,
"suggestions": ["What ports does Traefik expose?"],
"thinking": "scratchpad",
"tools": [
{
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
}
],
"stopped": None,
}
dumped = ChatMessage.model_validate(pre_phase).model_dump()
# Every pre-phase key survives byte-identical…
for key, value in pre_phase.items():
assert dumped[key] == value, f"pre-phase key {key!r} changed: {dumped[key]!r}"
# …and the ONLY additions are the two new keys as explicit nulls.
assert set(dumped) == set(pre_phase) | {"failed", "error"}
assert dumped["failed"] is None
assert dumped["error"] is None
# The stored shape re-validates (round-trip through the DB JSONB).
assert ChatMessage.model_validate(dumped).model_dump() == dumped
def test_minimal_user_record_unchanged() -> None:
"""A user record (no brain metadata at all) is untouched by the
phase: it validates and carries the new keys as nulls only."""
dumped = ChatMessage.model_validate({"who": "user", "text": "hi"}).model_dump()
assert dumped == {
"who": "user",
"text": "hi",
"sources": None,
"related": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
"failed": None,
"error": None,
}
+468
View File
@@ -0,0 +1,468 @@
"""Unit: the failed-turn frontend contract (phase 120, task 03).
Pins the phase-120 client contract as source assertions (the house
``test_frontend_*`` style — no browser): the three live failure paths
route through the single ``finalizeFailedTurn`` funnel (persist
``failed: true`` + the capped detail, end retryable), the zero-frame
fallback bubble persists the marker too, ``appendFailedNote`` mirrors
the stopped note (one per bubble, textContent-only detail),
``FAILED_TURN_TEXT`` is a distinct constant (NOT
``EMPTY_ANSWER_FALLBACK``), the restore branch renders the failed note
and excludes the Save-as-doc / Tune buttons, and — the phase's explicit
"NOT touched" contract — ``showErrorBanner`` and ``retryLastTurn`` are
byte-unchanged (their full sources are pinned below).
"""
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 _find_body_brace(js: str, start: int) -> int:
"""The index of the REAL opening brace of a function at *start* —
the first ``{`` OUTSIDE the parameter list (paren depth 0), so
empty object defaults (``opts = {}``) and destructured parameters
(``{ acc, thinking }``) are skipped (the phase-111 banner-test
convention, extended)."""
i = start
paren = 0
while i < len(js):
c = js[i]
if c == "(":
paren += 1
elif c == ")":
paren -= 1
elif c == "{" and paren == 0:
return i
i += 1
return -1
def _fn_source(js: str, name: str) -> str:
"""The full source of ``function name(…){…}`` (signature + body)."""
start = js.index(f"function {name}(")
brace = _find_body_brace(js, start)
depth = 0
i = brace
while True:
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
i += 1
return js[start : i + 1]
def _fn_body(js: str, name: str) -> str:
"""Just the body of ``function name(…){…}`` (between the braces)."""
src = _fn_source(js, name)
start = js.index(f"function {name}(")
brace = _find_body_brace(js, start)
rel = brace - start
return src[rel + 1 : len(src) - 1]
# ---------------------------------------------------------------------------
# appendFailedNote — the in-bubble error note (the stopped-note mirror)
# ---------------------------------------------------------------------------
def test_append_failed_note_exists_and_guards_dedup() -> None:
"""``appendFailedNote(wrap, detail)`` exists and mirrors
``appendStoppedNote``: it reuses the ``.msg-meta`` row and adds at
most ONE ``.failed-note`` per bubble (the duplicate guard)."""
js = _js()
body = _fn_body(js, "appendFailedNote")
assert '.msg-body' in body, "the note must land in the bubble's .msg-body"
assert ".msg-meta" in body, "the note rides the existing .msg-meta row"
assert '.failed-note' in body
assert (
'if (meta.querySelector(".failed-note")) return;' in body
), "one .failed-note per bubble (the duplicate guard, the stopped-note way)"
assert 'note.className = "failed-note";' in body
def test_append_failed_note_is_text_and_color_never_color_alone() -> None:
"""The note is TEXT + color (B5): the "Failed" label is set through
``textContent``, the icon is aria-hidden decoration, and the detail
goes through ``textContent`` too — the error string is NEVER
innerHTML (no HTML from an error, ever)."""
js = _js()
body = _fn_body(js, "appendFailedNote")
assert 'label.textContent = "Failed";' in body
assert 'd.className = "failed-detail"' in body
assert "d.textContent = detail" in body, "the detail is textContent, never innerHTML"
# The icon itself is the only innerHTML — the static SVG constant,
# aria-hidden decoration (the accessible meaning is the label +
# detail text — B5: text + color, never color alone).
assert "note.innerHTML = FAILED_ICON;" in body
m_icon = re.search(r"const FAILED_ICON\s*=\s*\n?\s*'([^']*)'", js)
assert m_icon, "const FAILED_ICON must exist"
assert 'aria-hidden="true"' in m_icon.group(1), "the icon is decoration"
def test_failed_turn_text_is_a_distinct_constant() -> None:
"""``FAILED_TURN_TEXT`` is its OWN string literal — NOT
``EMPTY_ANSWER_FALLBACK`` (that constant stays the
zero-frame-but-COMPLETED case's answer text) and a short honest
"my answer didn't make it" line (not the answer text, not the raw
detail)."""
js = _js()
m_fail = re.search(r'const FAILED_TURN_TEXT\s*=\s*\n?\s*"([^"]*)"', js)
assert m_fail, "const FAILED_TURN_TEXT must be a string literal"
failed_text = m_fail.group(1)
assert failed_text, "FAILED_TURN_TEXT must be non-empty"
m_empty = re.search(r'const EMPTY_ANSWER_FALLBACK\s*=\s*\n?\s*"([^"]*)"', js)
assert m_empty, "const EMPTY_ANSWER_FALLBACK must still be a string literal"
assert failed_text != m_empty.group(1), (
"FAILED_TURN_TEXT must be DISTINCT from EMPTY_ANSWER_FALLBACK"
)
# The zero-frame branch uses the constant, not a copy of the
# fallback.
assert 'addMessage("brain", FAILED_TURN_TEXT);' in js
# ---------------------------------------------------------------------------
# finalizeFailedTurn — the single funnel for the live failure paths
# ---------------------------------------------------------------------------
def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
"""The funnel persists ``failed: true`` in BOTH shapes (partial
wrap + the zero-frame bubble) with the capped detail, and the
partial keeps the streamed text (``acc``)."""
js = _js()
body = _fn_body(js, "finalizeFailedTurn")
# Both branches persist the marker…
assert body.count("failed: true") == 2, (
"both funnel shapes must persist failed: true"
)
# …the detail trimmed + capped at 500 before persistence (the
# schema's ChatMessage.error bound is the backstop)…
assert '(detail || "").trim().slice(0, 500)' in body
# …the zero-frame shape creates the FAILED_TURN_TEXT bubble…
assert 'addMessage("brain", FAILED_TURN_TEXT);' in body
assert "appendFailedNote(fwrap, error);" in body
# …and the partial shape settles the block + calls closed (the
# stop-finalize pattern) and adds the note.
assert "closeThinkingBlock(wrap);" in body
assert "closeToolCalls(wrap);" in body
assert "appendFailedNote(wrap, error);" in body
# Both shapes land the record through rememberBrainTurn (local
# storage + the phase-55 auto-save ride) and set lastBrainWrap
# BEFORE the caller's setUiState(error, …).
assert body.count("rememberBrainTurn(") == 2
assert body.count("lastBrainWrap =") == 2
def test_finalize_failed_turn_ends_retryable() -> None:
"""The funnel ENDS with ``markLastRetryable()`` (the last statement
— a trailing comment is fine) — the in-bubble Retry button lands
on the failed bubble (the last brain wrap)."""
js = _js()
body = _fn_body(js, "finalizeFailedTurn").rstrip()
last_line = body.splitlines()[-1].strip()
assert last_line.startswith("markLastRetryable();"), (
"finalizeFailedTurn must end with the markLastRetryable() call"
)
def test_error_catch_else_routes_through_the_funnel() -> None:
"""The error catch's ``else`` branch (non-abort, non-stop — network
error, pre-stream HTTP error, the SSE ``error`` frame's throw)
calls ``finalizeFailedTurn`` BEFORE ``setUiState(UI_STATE.error, …)``
— the funnel sets lastBrainWrap, so the banner's EXISTING
``opts.retryable && lastBrainWrap`` condition reveals the Retry."""
js = _js()
# The stop branch ends where the plain else begins.
stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {')
else_branch = js.index("} else {", stop_branch)
finally_branch = js.index("} finally {", else_branch)
else_body = js[else_branch:finally_branch]
assert "finalizeFailedTurn(detail, {" in else_body
# The persistence (the funnel) precedes the error state — the
# banner's Retry precondition is set before the banner shows.
assert else_body.index("finalizeFailedTurn(detail, {") < else_body.index(
"setUiState(UI_STATE.error, detail,"
)
def test_navigate_away_is_not_a_failed_turn() -> None:
"""A REAL departure is not a failed turn (phase-120 verification
fix): the pagehide handler sets a turn-scoped ``leftThePage`` flag
(module scope, like ``persistedOnLeave``), and the error catch's
``else`` branch skips the failed funnel when it is set — the
browser's teardown rejection of the cancelled in-flight fetch (a
TypeError, NOT an AbortError) must not persist a failed brain
record: the phase-20 convention stands (thinking-only
navigate-away persists nothing brain-side; a partial navigate-away
persists the pagehide partial as a plain record). The flag is set
unconditionally on pagehide (a merely-hidden tab in some browsers
also fires it — the stream keeps arriving, so no rejection
follows and it stays inert there) and reset per turn at the top of
``runTurn`` with the other turn locals."""
js = _js()
# Module-scope declaration (column 0), exactly once.
assert re.search(r"^let leftThePage = false", js, re.M), (
"leftThePage must be a module-scope flag (the pagehide handler "
"reads it), like persistedOnLeave"
)
assert js.count("let leftThePage = false;") == 1
# Set as the FIRST statement of the pagehide handler — before the
# persistedOnLeave early return (a thinking-only navigate-away
# returns early, but the flag must be set for the funnel skip).
ph = js.index('window.addEventListener("pagehide", () => {')
ph_end = js.index("\n});", ph)
ph_body = js[ph:ph_end]
idx_flag = ph_body.find("leftThePage = true;")
idx_return = ph_body.find("if (persistedOnLeave) return;")
assert 0 <= idx_flag < idx_return, (
"leftThePage must be set BEFORE the pagehide early returns"
)
# Reset per turn at the top of runTurn (the persistedOnLeave group).
turn = js.index("async function runTurn")
abort_idx = js.index("turnAbort = new AbortController()", turn)
turn_top = js[turn:abort_idx]
assert "leftThePage = false;" in turn_top, (
"leftThePage must be reset per turn at the top of the turn handler"
)
assert turn_top.index("persistedOnLeave = false;") < turn_top.index(
"leftThePage = false;"
)
# The catch's else branch (non-abort, non-stop) skips the funnel
# when the flag is set — the funnel call itself stays intact (the
# real-network-error path, no pagehide).
stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {')
else_branch = js.index("} else {", stop_branch)
finally_branch = js.index("} finally {", else_branch)
else_body = js[else_branch:finally_branch]
idx_guard = else_body.find("if (!leftThePage) {")
idx_funnel = else_body.find("finalizeFailedTurn(detail, {")
assert 0 <= idx_guard < idx_funnel, (
"the failed funnel must be skipped after a real page departure "
"(the teardown rejection is not a failed turn)"
)
def test_stream_drop_guard_routes_through_the_funnel() -> None:
"""The stream-drop guard (frames arrived, no ``done`` — the
connection died mid-turn) routes through the SAME funnel: the
half-answer persists as failed (its text + the error note + a
working Retry) BEFORE the error state."""
js = _js()
guard = js.index("if (!sawDone && !aborted && (acc || thinkingAcc)) {")
zero_frame = js.index("if (!aborted && !wrap) {", guard)
guard_body = js[guard:zero_frame]
assert "finalizeFailedTurn(detail, {" in guard_body
assert guard_body.index("finalizeFailedTurn(detail, {") < guard_body.index(
"setUiState(UI_STATE.error, detail);"
)
def test_zero_frame_fallback_persists_failed_marker() -> None:
"""The zero-frame-but-COMPLETED fallback bubble (the stream settled
with no events) is a failed turn too (task 01 ASSUMPTION): the
bubble text stays ``EMPTY_ANSWER_FALLBACK`` (a meaningful record
text) but the record gains ``failed: true`` + the error note, and
the bubble ends retryable."""
js = _js()
zero_frame = js.index("if (!aborted && !wrap) {")
catch = js.index("} catch (err) {", zero_frame)
block = js[zero_frame:catch]
assert "const fallback = EMPTY_ANSWER_FALLBACK;" in block, (
"the bubble text stays the EMPTY_ANSWER_FALLBACK answer text"
)
assert "appendFailedNote(fwrap, nothing);" in block
assert "failed: true," in block
assert "error: nothing," in block
assert "markLastRetryable();" in block
def test_only_the_three_failed_paths_persist_failed() -> None:
"""No call site OUTSIDE the three failed paths persists
``failed: true`` (task 01 completion criterion, grep-level):
exactly three CODE sites — two in ``finalizeFailedTurn`` (the
partial + zero-frame shapes) and one in the zero-frame-but-
completed fallback — the done, stop, and restore paths never set
the marker (they read it, or don't touch it)."""
js = _js()
fn = _fn_body(js, "finalizeFailedTurn")
zero_frame = js.index("if (!aborted && !wrap) {")
catch = js.index("} catch (err) {", zero_frame)
fallback_block = js[zero_frame:catch]
fn_start = js.index("function finalizeFailedTurn(")
fn_src = _fn_source(js, "finalizeFailedTurn")
code_outside = js[:fn_start] + js[fn_start + len(fn_src) :]
code_outside = code_outside.replace(fallback_block, "")
# Comments may mention the marker; code must not (the file's
# block-comment lines start with * or /* after stripping).
code_lines = [
line
for line in code_outside.splitlines()
if not line.strip().startswith(("//", "*", "/*"))
]
assert "failed: true" not in "\n".join(code_lines), (
"only the three failed paths may persist failed: true"
)
assert fn.count("failed: true") == 2
assert fallback_block.count("failed: true") == 1
# ---------------------------------------------------------------------------
# restore — a failed record renders as an error bubble with the note
# ---------------------------------------------------------------------------
def test_restore_renders_the_failed_note() -> None:
"""The restore branch re-renders the in-bubble error note from the
persisted ``error`` detail — and only when it is present (a record
whose ``error`` is null has the detail in its ``text`` already)."""
js = _js()
body = _fn_body(js, "renderStoredMessage")
assert "if (m.failed && m.error) appendFailedNote(wrap, m.error);" in body, (
"the failed note restores from the persisted error detail"
)
def test_restore_excludes_save_as_doc_and_tune_for_failed() -> None:
"""A failed turn is a NOTE, not an answer: the restore excludes
both the Save-as-doc button (the ``m.stopped`` exclusion extended
with ``!m.failed``) and the Tune button (``!m.failed``). Stopped
and successful records keep their buttons — the pre-phase-120
behavior for them is byte-identical."""
js = _js()
body = _fn_body(js, "renderStoredMessage")
assert "if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);" in body
assert "if (!m.failed) appendTuneButton(wrap);" in body
# ---------------------------------------------------------------------------
# NOT touched — the phase's explicit contract (byte-pinned sources)
# ---------------------------------------------------------------------------
#: The FULL source of ``showErrorBanner`` as of phase 120 — the
#: phase-111 button + the phase-114 hint, byte-unchanged by this phase
#: (the phase makes ``lastBrainWrap`` EXIST on the error paths instead
#: of changing the condition). A diff here is a contract violation.
PINNED_SHOW_ERROR_BANNER = """function showErrorBanner(detail, opts = {}) {
banner.hidden = false;
banner.classList.add("is-error");
banner.setAttribute("role", "alert");
// Phase 114 (TODO L6): a frame-carried hint (the "question too long"
// case — reachability is fine, only the length is the problem) replaces
// the default reachability hint when present.
bannerText.textContent = detail
? `${detail} ${opts.hint ?? ERROR_HINT}`
: (opts.hint ?? ERROR_HINT);
// Phase 111 (task 01): reveal the banner Retry button only for failed
// chat turns (opts.retryable) AND when a retryable bubble exists.
if (opts.retryable) {
const btn = document.querySelector("#banner-retry");
if (btn && lastBrainWrap) {
btn.hidden = false;
// Bind click once per reveal — the old listener is removed after
// the first click, so re-binding on every reveal is safe.
btn.addEventListener("click", () => retryLastTurn(lastBrainWrap));
}
}
}"""
def test_show_error_banner_is_byte_unchanged() -> None:
"""``showErrorBanner`` is byte-unchanged by phase 120 (the
"NOT touched" contract): its full source must match the pin —
the ``opts.retryable && lastBrainWrap`` condition, the phase-114
hint merge, and the once-per-reveal binding included."""
assert _fn_source(_js(), "showErrorBanner") == PINNED_SHOW_ERROR_BANNER
#: The FULL source of ``retryLastTurn`` as of phase 120 — the phase-49
#: redo-in-place (pop the last brain record, re-ask the preceding
#: question). It works on a failed record UNCHANGED (locked A1): the
#: question's user record immediately precedes the failed record.
PINNED_RETRY_LAST_TURN = """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).
// Phase 53: the promise is returned (the Regenerate await above);
// runTurn never rejects — a failure surfaces as the error banner.
return runTurn(text, { reask: true });
}"""
def test_retry_last_turn_is_byte_unchanged() -> None:
"""``retryLastTurn`` is byte-unchanged by phase 120 (locked A1 —
the redo-in-place is REUSED, not extended): the full source must
match the pin — the pop-the-last-brain-record + re-ask logic, the
in-flight guard, and the stale-click guard included."""
assert _fn_source(_js(), "retryLastTurn") == PINNED_RETRY_LAST_TURN
# ---------------------------------------------------------------------------
# CSS — the in-bubble error line (the .stopped-note family, error color)
# ---------------------------------------------------------------------------
def test_failed_note_css_uses_the_error_token() -> None:
"""``.failed-note`` exists in styles.css, colored by the theme's
error TOKEN (``--err-ink`` — the monochrome theme grays it
automatically; the contrast floor is the stopped-note family's) —
never a literal color (the phase-92 zero-literal convention)."""
css = _css()
m = re.search(r"\.failed-note \{([\s\S]*?)\n\}", css)
assert m, "styles.css must define .failed-note"
body = m.group(1)
assert "color: var(--err-ink);" in body, (
".failed-note must use the theme's error token"
)
assert "pointer-events: none;" in body, "the note is non-interactive (the stopped-note way)"
def test_failed_detail_wraps_long_details() -> None:
"""The detail span WRAPS (a 500-char error detail must not blow
out the 46rem chat column — the stopped note's nowrap fits a
one-word label, not a detail)."""
css = _css()
m = re.search(r"\.failed-detail \{([\s\S]*?)\n\}", css)
assert m, "styles.css must define .failed-detail"
assert "overflow-wrap: anywhere;" in m.group(1)
+8 -5
View File
@@ -172,9 +172,10 @@ def test_app_js_call_sites_pass_the_raw_markdown() -> None:
the rendered HTML): the live `done` branch (exactly the string
rememberBrainTurn stores, so a reload offers the identical draft),
the empty-answer fallback bubble (parity with the done path), and
the restore path (m.text). A stopped partial is a note, not an
answer — the restore gates on !m.stopped; the live stop path and
the pagehide partial never call the helper at all."""
the restore path (m.text). A stopped partial — or a failed turn
(phase 120: a note, not an answer either) — is excluded: the
restore gates on !m.stopped && !m.failed; the live stop/failure
paths and the pagehide partial never call the helper at all."""
js = _text(APP_JS)
assert 'appendSaveAsDocButton(wrap, finalText || acc || "…");' in js, (
"the live done branch must pass the raw persisted text"
@@ -182,8 +183,10 @@ def test_app_js_call_sites_pass_the_raw_markdown() -> None:
assert "appendSaveAsDocButton(fwrap, fallback);" in js, (
"the empty-answer fallback bubble must get the button too"
)
assert "if (!m.stopped) appendSaveAsDocButton(wrap, m.text);" in js, (
"the restore path must pass m.text and skip stopped records"
assert (
"if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);" in js
), (
"the restore path must pass m.text and skip stopped + failed records"
)
# The live call sits next to the Tune button (same meta row scope).
tune_idx = js.find("appendTuneButton(wrap); // every completed brain bubble is tunable")
+14
View File
@@ -339,6 +339,8 @@ def test_minimal_message_still_validates() -> None:
msg.thinking,
msg.tools,
msg.stopped,
msg.failed,
msg.error,
) == (
None,
None,
@@ -347,6 +349,8 @@ def test_minimal_message_still_validates() -> None:
None,
None,
None,
None,
None,
)
@@ -373,6 +377,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"thinking": None,
"tools": None,
"stopped": None,
"failed": None,
"error": None,
},
{
"who": "brain",
@@ -411,6 +417,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
},
],
"stopped": None,
"failed": None,
"error": None,
},
{
"who": "user",
@@ -422,6 +430,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"thinking": None,
"tools": None,
"stopped": None,
"failed": None,
"error": None,
},
{
"who": "brain",
@@ -433,6 +443,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"thinking": None,
"tools": None,
"stopped": True, # the owner stopped the generation mid-answer
"failed": None,
"error": None,
},
]
@@ -459,6 +471,8 @@ def test_realistic_payload_round_trips_through_update_model() -> None:
"thinking": "scratchpad",
"tools": [_tool_call()],
"stopped": None,
"failed": None,
"error": None,
}
payload = SavedChatUpdate.model_validate({"messages": [msg]})
assert payload.model_dump()["messages"] == [msg]