All gates green. Report: - **Verified (phase 164, all 4 tasks already in `complete/`)**: `HistoryTurn`/`ChatMessage`/`ChatThinkingSegment` caps → 600 000, `history`/saved-messages lists → 500, new `SavedChatCreate/Update` total-8 000 000-char validator; exported `errorDetailText` at the two previously unguarded chat paths (pre-stream `!res.ok` + SSE `error` frame); `FAILED_REQUEST_TEXT` + `bubbleText` threading + phase-114 banner hint; dedicated E2E `tests/e2e/test_chat_error_honesty.py`. - **Final-pass additions**: `tests/integration/test_chat_api.py::test_history_rejects_one_char_over_the_text_cap` (endpoint-level >600k → 422 with the pydantic list-of-objects `msg` — the criterion's upper half was only unit-pinned); README env rows for `BOR_APP_VERSION` + `BOR_SESSION_COOKIE`; restored `test_document_viewer.py`'s tracked screenshot artifact dirtied by the E2E run (via read-only `git show`, no index/history touched). - **Tests/lint (exact commands)**: `uv run pytest --cov=app --cov-report=term-missing` → **3837 passed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov` → **4 passed in isolation**; per-file loop over **all 148** E2E files (`uv run pytest tests/e2e/<f> -q --no-cov`) → **148 × exit 0**, incl. `test_failed_turn_retry`, `test_chat_save_payload_limits`, `test_edit_and_resend`, `test_llm_history`, `test_smoke`; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - **Completion criteria**: pre-stream 422 shows the distinct line + verbatim `msg`, `[object Object]` unrepresentable + no `input` in DOM — **met** (unit totality/source pins + E2E asserts 1–2); 525k history accepted (200 settles) / >600k → 422 / saved chat 201 + 8M bound — **met**; transport + SSE-frame settles keep `FAILED_TURN_TEXT`, stop/abort untouched, §7.4 re-enables — **met**; budget trim, `message` 4000, the 7 guarded detail-readers and all SSE shapes byte-identical (diff touches only caps + the two chat paths) — **met**; README/AGENTS full review + proxy deploy note — **met**; commit/`complete/` move = pipeline gate. - **Docs**: README reviewed in full (env tables cross-checked programmatically: 74 `BOR_*` rows vs `Settings` defaults — no mismatches; every `.env.example` var documented; referenced scripts/pages/endpoints/`query_log` columns exist) — found & fixed the 2 undocumented real knobs above; the phase's payload-limits section, failed-turn copy and `client_max_body_size ≥ 16 MB` deploy note verified accurate. `AGENTS.md` verified consistent (rules, gitignore list, quick-reference commands, referenced paths all correct) — no changes needed. - **Deviations**: `ChatThinkingSegment.text` raised 32 000 → 600 000 too (not itemized in A1, but its own docstring mirrors `HistoryTurn.thinking`; without it a long-thinking turn 422s its own auto-save — same mirror invariant). No live-infra changes made (the proxy limit lives in the separate deployment repo; README note only). - **Next pending phase**: none — `164_chat_error_honesty` was the only entry in `todo/`.
636 lines
28 KiB
Python
636 lines
28 KiB
Python
"""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 joins the bubble's ``.msg-actions`` row
|
|
(getActionsRow — phase 130 task 02, ASSUMPTION A7: the notes are
|
|
answer-status markers, the actions row is their home) and adds at
|
|
most ONE ``.failed-note`` per bubble (the duplicate guard)."""
|
|
js = _js()
|
|
body = _fn_body(js, "appendFailedNote")
|
|
assert "getActionsRow(wrap)" in body, (
|
|
"the note joins the dedicated .msg-actions row (getActionsRow — "
|
|
"phase 130 task 02), never the first .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 as its DEFAULT (phase 164
|
|
# D3: ``bubbleText || FAILED_TURN_TEXT`` — only the pre-stream HTTP
|
|
# rejection overrides it), never a copy of the fallback.
|
|
assert "bubbleText || 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 bubble (phase 164 D3:
|
|
# ``bubbleText || FAILED_TURN_TEXT`` — the SAME value feeds the
|
|
# bubble AND the record: stored-what-you-saw)…
|
|
assert "const fwrapText = bubbleText || FAILED_TURN_TEXT;" in body
|
|
assert 'addMessage("brain", fwrapText);' in body
|
|
assert "rememberBrainTurn(\n fwrapText," in body
|
|
assert "appendFailedNote(fwrap, error);" in body
|
|
# …and the partial shape settles every open run closed (phase 158,
|
|
# D5 — the stop-finalize pattern) and adds the note.
|
|
assert "closeAllRuns(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()`` immediately
|
|
followed by ``markLastEditable()`` (phase 159 task 01 — the
|
|
failed turn's question is editable too: the trim-and-re-ask
|
|
remedy) — the in-bubble Retry + Edit buttons land on the failed
|
|
bubble (the last brain wrap / the last user wrap)."""
|
|
js = _js()
|
|
body = _fn_body(js, "finalizeFailedTurn").rstrip()
|
|
lines = [line for line in body.splitlines() if line.strip()]
|
|
assert lines[-1].strip().startswith("markLastEditable();"), (
|
|
"finalizeFailedTurn must end with the markLastEditable() call "
|
|
"(phase 159 — the failed turn's question stays editable)"
|
|
)
|
|
assert lines[-2].strip().startswith("markLastRetryable();"), (
|
|
"the markLastEditable() call must sit immediately after "
|
|
"markLastRetryable() (the paired settle)"
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 164 task 03 — the distinct PRE-STREAM failure line (locked A3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: The owner-locked bubble copy (D3) — pinned VERBATIM, exactly once.
|
|
FAILED_REQUEST_TEXT_COPY = (
|
|
"I couldn't take that — my API rejected the request. Use Retry to ask again."
|
|
)
|
|
|
|
#: The phase-114 banner hint the pre-stream throw carries (D3).
|
|
PRESTREAM_HINT_COPY = (
|
|
"The app answered — it rejected the request before the model ran. "
|
|
"Nothing was lost but this send."
|
|
)
|
|
|
|
|
|
def _prestream_block(js: str) -> str:
|
|
"""The ``if (!res.ok || !res.body) { … }`` pre-stream handler block."""
|
|
site = js.index("if (!res.ok || !res.body) {")
|
|
return js[site : js.index("await readSSE(", site)]
|
|
|
|
|
|
def _catch_else_body(js: str) -> str:
|
|
"""``runTurn``'s catch: the plain ``else`` branch (non-abort,
|
|
non-stop — the failed-turn funnel site)."""
|
|
stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {')
|
|
else_branch = js.index("} else {", stop_branch)
|
|
return js[else_branch : js.index("} finally {", else_branch)]
|
|
|
|
|
|
def test_failed_request_text_is_owner_locked_and_distinct() -> None:
|
|
"""``FAILED_REQUEST_TEXT`` is its OWN string literal with the exact
|
|
owner-locked copy (D3), declared exactly once, DISTINCT from both
|
|
``FAILED_TURN_TEXT`` (the connection line — a lie for a 4xx/5xx the
|
|
server answered deliberately) and ``EMPTY_ANSWER_FALLBACK``."""
|
|
js = _js()
|
|
m_req = re.search(r'const FAILED_REQUEST_TEXT\s*=\s*\n?\s*"([^"]*)"', js)
|
|
assert m_req, "const FAILED_REQUEST_TEXT must be a string literal"
|
|
assert m_req.group(1) == FAILED_REQUEST_TEXT_COPY, (
|
|
"the pre-stream copy is owner-locked — verbatim or not at all"
|
|
)
|
|
assert js.count("const FAILED_REQUEST_TEXT") == 1
|
|
m_turn = re.search(r'const FAILED_TURN_TEXT\s*=\s*\n?\s*"([^"]*)"', js)
|
|
m_empty = re.search(r'const EMPTY_ANSWER_FALLBACK\s*=\s*\n?\s*"([^"]*)"', js)
|
|
assert m_turn and m_empty
|
|
assert m_req.group(1) != m_turn.group(1)
|
|
assert m_req.group(1) != m_empty.group(1)
|
|
# The docstring names the finding + the not-for-transport boundary.
|
|
doc = js[: js.index("const FAILED_REQUEST_TEXT")][-1200:]
|
|
assert "2026-10-07" in doc and "PRE-STREAM" in doc
|
|
|
|
|
|
def test_prestream_throw_carries_bubble_text_and_hint() -> None:
|
|
"""The pre-stream ``!res.ok`` throw decorates the Error with
|
|
``bubbleText = FAILED_REQUEST_TEXT`` AND the phase-114 ``hint``
|
|
before throwing — the thrown message stays the serialized detail
|
|
(task 02), so the note + banner keep the real reason while the
|
|
bubble reads the honest pre-stream line."""
|
|
block = _prestream_block(_js())
|
|
assert "const err = new Error(detail);" in block
|
|
assert "err.bubbleText = FAILED_REQUEST_TEXT;" in block
|
|
assert f'"{PRESTREAM_HINT_COPY}"' in block, (
|
|
"the banner hint copy is locked (D3) — verbatim"
|
|
)
|
|
# Both decorations ride the SAME thrown Error (set before the throw).
|
|
throw = block.index("throw err;")
|
|
assert 0 < block.index("err.bubbleText = FAILED_REQUEST_TEXT;") < throw
|
|
assert 0 < block.index("err.hint") < throw
|
|
assert "throw new Error(detail);" not in block
|
|
|
|
|
|
def test_run_turn_catch_forwards_bubble_text_to_the_funnel() -> None:
|
|
"""``runTurn``'s catch forwards ``bubbleText: err?.bubbleText``
|
|
into ``finalizeFailedTurn`` (the ONE option addition) — optional
|
|
chaining so a transport ``TypeError`` (no key, not even a custom
|
|
Error) reads ``undefined`` and the settle keeps ``FAILED_TURN_TEXT``.
|
|
The detail/hint/banner sequencing after the call is untouched."""
|
|
else_body = _catch_else_body(_js())
|
|
assert "bubbleText: err?.bubbleText," in else_body
|
|
funnel = else_body.index("finalizeFailedTurn(detail, {")
|
|
bubble = else_body.index("bubbleText: err?.bubbleText,")
|
|
banner = else_body.index("setUiState(UI_STATE.error, detail,")
|
|
assert funnel < bubble < banner, (
|
|
"the key rides the funnel call, before the error-state settle "
|
|
"(the phase-120 ordering contract)"
|
|
)
|
|
|
|
|
|
def test_finalize_reads_bubble_text_only_in_the_zero_frame_branch() -> None:
|
|
"""``finalizeFailedTurn`` destructures ``bubbleText`` and reads it
|
|
ONLY in the zero-frame branch (the partial-stream settle ignores
|
|
the key — a pre-stream throw has no ``wrap`` by construction). The
|
|
zero-frame settle puts the SAME ``text`` value into the bubble AND
|
|
the persisted record (stored-what-you-saw, phase-120 contract
|
|
extended), and touches no ``innerHTML``."""
|
|
js = _js()
|
|
src = _fn_source(js, "finalizeFailedTurn")
|
|
assert "{ acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }" in src
|
|
body = _fn_body(js, "finalizeFailedTurn")
|
|
zero = body.index("} else {")
|
|
assert "bubbleText" not in body[:zero], (
|
|
"the partial branch must never read bubbleText"
|
|
)
|
|
# Code-level (comment-stripped) reads of the key: exactly one.
|
|
code = "\n".join(
|
|
line
|
|
for line in body.splitlines()
|
|
if not line.strip().startswith(("//", "*", "/*"))
|
|
)
|
|
assert code.count("bubbleText") == 1
|
|
zero_part = body[zero:]
|
|
assert "const fwrapText = bubbleText || FAILED_TURN_TEXT;" in zero_part
|
|
assert 'addMessage("brain", fwrapText);' in zero_part
|
|
assert "rememberBrainTurn(\n fwrapText," in zero_part, (
|
|
"the bubble text and the record text are the SAME value"
|
|
)
|
|
assert "innerHTML" not in zero_part, "the failed bubble is text, never HTML"
|
|
|
|
|
|
def test_sse_error_frame_throw_sets_no_bubble_text() -> None:
|
|
"""The SSE ``error``-frame throw (a model-side failure AFTER the
|
|
stream opened) decorates ``err.hint`` only — NO ``bubbleText``: a
|
|
zero-frame error-frame settle keeps ``FAILED_TURN_TEXT``
|
|
byte-identically (locked A3)."""
|
|
js = _js()
|
|
site = js.index('} else if (ev.type === "error") {')
|
|
frame = js[site : js.index("throw err;", site) + len("throw err;")]
|
|
assert "const err = new Error(errorDetailText(ev.detail," in frame, (
|
|
"the frame throw reads through the serializer (task 02)"
|
|
)
|
|
assert '"Something went wrong on my side."));' in frame
|
|
assert "err.hint = ev.hint;" in frame
|
|
assert "bubbleText" not in frame
|
|
|
|
|
|
def test_hint_reaches_banner_opts_for_both_throw_kinds() -> None:
|
|
"""The phase-114 hint channel reaches the banner the SAME way for
|
|
both throw kinds: the catch passes ``{ hint: err.hint }`` into
|
|
``setUiState(UI_STATE.error, …)`` — the pre-stream throw sets a
|
|
literal hint, the SSE frame throw copies ``ev.hint`` (undefined →
|
|
showErrorBanner's default). The stream-drop guard's banner call
|
|
keeps its optionless shape."""
|
|
js = _js()
|
|
assert (
|
|
"setUiState(UI_STATE.error, detail, err instanceof Error ? { hint: err.hint } : {});"
|
|
in _catch_else_body(js)
|
|
)
|
|
guard = js.index("if (!sawDone && !aborted && (acc || thinkingAcc)) {")
|
|
zero_frame = js.index("if (!aborted && !wrap) {", guard)
|
|
assert "setUiState(UI_STATE.error, detail);" in js[guard:zero_frame]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|