Files
brain-of-reese/tests/unit/test_frontend_actions_cluster.py
ducoterra 34d60efbc1
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 29s
phase: 164_chat_error_honesty
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/`.
2026-10-07 18:00:12 -04:00

430 lines
18 KiB
Python

"""Unit: the phase-130 (task 02, TODO L6) chat actions cluster.
The three answer actions (Tune / Retry / Save-as-doc) and the
answer-status notes (Stopped / Failed) stop joining whatever the first
``.msg-meta`` row happens to be (usually the source chips row). They
get-or-create a dedicated ``.msg-meta.msg-actions`` row — the bubble's
LAST row, right-aligned by CSS (task 03) — so chips and images can
never push the buttons around.
Pins (the house source-pin pattern — no browser):
* ``getActionsRow(wrap)`` — the shared get-or-create helper: scoped to
the ``msg-actions`` class so it NEVER grabs the chips row (or any
other meta row), ``role=list`` + ``aria-label="Answer actions"``,
null without a ``.msg-body``;
* all FIVE appenders/notes route through ``getActionsRow(wrap)`` —
none of them reuses the first ``.msg-meta`` row anymore;
* ``markLastRetryable`` re-appends the Retry button into the actions
row and the phase-59 save-button re-append hack is GONE (the row's
row-level right-align replaces the per-button auto margins);
* the call-order audit (task 02 item 5): at every completed-bubble
render site the order is answer figure → sources → related → the
action appenders, so ``.msg-actions`` is the bubble's LAST row, and
the done branch's insertion order inside the row is Tune → Retry →
Save-as-doc.
The browser-level assertions (the right-aligned cluster, the row
order in the rendered DOM, the restore parity) are E2E-gated by
tests/e2e/test_chat_answer_clusters.py (task 04).
"""
from __future__ import annotations
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _fn_body(js: str, header: str) -> str:
"""The full source of the function whose header is *header* —
brace-matched from the first ``{`` OUTSIDE the parameter list
(paren depth 0, the house convention), so destructured parameters
are skipped (the pinned functions carry no string literal with a
stray brace)."""
start = js.index(header)
i = start
paren = 0
while i < len(js):
c = js[i]
if c == "(":
paren += 1
elif c == ")":
paren -= 1
elif c == "{" and paren == 0:
break
i += 1
depth = 0
for j in range(i, len(js)):
c = js[j]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {header!r}")
def _done_branch(js: str) -> str:
"""The SSE ``done`` branch — from the branch test to the next
``else if`` (flat block, the slice is exact)."""
start = js.index('ev.type === "done"')
return js[start : js.index('ev.type === "tool_result"', start)]
# ---------------------------------------------------------------------------
# getActionsRow — the shared get-or-create helper
# ---------------------------------------------------------------------------
def test_get_actions_row_gets_or_creates_the_dedicated_row() -> None:
"""``getActionsRow(wrap)`` find-or-creates the bubble's
``.msg-meta.msg-actions`` row: the query is scoped to the
``msg-actions`` class (NEVER the bare first ``.msg-meta`` — that
would grab the chips row), the created row is ``role=list`` +
``aria-label="Answer actions"`` (the ARIA convention of the other
meta rows), and it lands in ``.msg-body``. A missing ``.msg-body``
(the defensive pre-phase convention) returns null — the appenders
no-op."""
js = _js()
body = _fn_body(js, "function getActionsRow(wrap) {")
assert 'body.querySelector(".msg-meta.msg-actions")' in body, (
"the reuse query is scoped to the msg-actions class — the "
"helper must NEVER grab the chips row (or any other meta row)"
)
assert "document.querySelector" not in body and "body.querySelector(\".msg-meta\")" not in body
assert 'row.className = "msg-meta msg-actions";' in body
assert 'row.setAttribute("role", "list");' in body, (
"the row is an accessible list (the house meta-row convention)"
)
assert 'row.setAttribute("aria-label", "Answer actions");' in body, (
"the row's accessible name (WCAG — the row is never unnamed)"
)
assert "body.appendChild(row)" in body, "a new row lands in .msg-body"
assert 'wrap?.querySelector?.(".msg-body")' in body, (
"defensive .msg-body lookup (the stopped-note convention)"
)
assert "if (!body) return null" in body, "no .msg-body → null (appenders no-op)"
assert "return row" in body
def test_get_actions_row_is_the_shared_helper() -> None:
"""EXACTLY ONE getActionsRow (no per-appender copies of the
get-or-create logic), and the created row's class string appears
nowhere else in the file (the appenders only ever LOOK it up —
they never build a second actions row)."""
js = _js()
assert js.count("function getActionsRow(wrap) {") == 1, (
"exactly ONE getActionsRow — the shared helper"
)
assert js.count('row.className = "msg-meta msg-actions";') == 1, (
"the row is created in exactly ONE place — the helper; the "
"appenders never build a row themselves"
)
assert js.count('body.querySelector(".msg-meta.msg-actions")') == 1, (
"the reuse query lives in exactly ONE place — the helper"
)
# ---------------------------------------------------------------------------
# the five appenders/notes — all through getActionsRow
# ---------------------------------------------------------------------------
#: (function header, the one-per-bubble guard's class, the child class)
APPENDERS = [
("function appendTuneButton(wrap) {", ".tune-btn", "tune-btn"),
("function appendRetryButton(wrap) {", ".retry-btn", "retry-btn"),
(
"function appendSaveAsDocButton(wrap, markdown) {",
".save-as-doc-btn",
"save-as-doc-btn",
),
("function appendStoppedNote(wrap) {", ".stopped-note", "stopped-note"),
("function appendFailedNote(wrap, detail) {", ".failed-note", "failed-note"),
]
def test_all_action_appenders_join_the_actions_row() -> None:
"""All FIVE appenders/notes (Tune, Retry, Save-as-doc, Stopped,
Failed) route through ``getActionsRow(wrap)`` and no-op on its
null (no ``.msg-body``) — none of them reuses the FIRST
``.msg-meta`` row anymore (the pre-phase-130 soup-div behavior is
gone from every appender)."""
js = _js()
for header, guard, child in APPENDERS:
body = _fn_body(js, header)
assert "getActionsRow(wrap)" in body, f"{header} must use getActionsRow"
assert 'querySelector(".msg-meta")' not in body, (
f"{header} must not query the bare first .msg-meta row"
)
assert "if (!meta) return" in body, (
f"{header} must no-op when getActionsRow returns null"
)
assert f'querySelector("{guard}")' in body, (
f"{header} keeps its one-per-bubble guard"
)
assert f'"{child}"' in body, f"{header} keeps its child class"
def test_action_appenders_keep_the_listitem_aria() -> None:
"""ARIA is kept (the existing convention): the role=list check
stays on every appender and the child joins the row as a
listitem (the row is ALWAYS role=list — getActionsRow sets it —
so the check always applies)."""
js = _js()
for header, _, _ in APPENDERS:
body = _fn_body(js, header)
assert 'meta.getAttribute("role") === "list"' in body, (
f"{header} keeps the role=list check"
)
assert 'setAttribute("role", "listitem")' in body, (
f"{header} keeps the listitem join"
)
def test_tune_and_save_gates_are_unchanged() -> None:
"""The gates are untouched (no behavior change in completed work):
Tune is admin-gated (phase 16) and Save-as-doc is admin-gated AND
docs-repo-gated (phase 59) — both BEFORE any row work. Retry is
NOT admin-gated (owner-locked: every visitor)."""
js = _js()
tune = _fn_body(js, "function appendTuneButton(wrap) {")
assert 'if (!isAdmin) return' in tune
save = _fn_body(js, "function appendSaveAsDocButton(wrap, markdown) {")
assert "if (!isAdmin || !docsRepoConfigured) return" in save
retry = _fn_body(js, "function appendRetryButton(wrap) {")
assert "isAdmin" not in retry, "Retry stays NOT admin-gated"
def test_failed_note_detail_is_textcontent_only() -> None:
"""The ``failed-detail`` contract survives (task 02 item 3): the
error detail goes through ``textContent`` — no HTML from error
strings, ever."""
js = _js()
body = _fn_body(js, "function appendFailedNote(wrap, detail) {")
assert 'd.className = "failed-detail"' in body
assert "d.textContent = detail" in body, (
"the error string is NEVER innerHTML"
)
# The icon constant is the ONLY innerHTML (the static aria-hidden
# SVG) — the detail path never builds HTML (comments stripped —
# the "NEVER innerHTML" remark is not code).
code = "\n".join(
line for line in body.splitlines() if "//" not in line
)
assert code.count("innerHTML") == 1, (
"only the static FAILED_ICON innerHTML remains in the note"
)
# ---------------------------------------------------------------------------
# markLastRetryable — the rework (task 02 item 4)
# ---------------------------------------------------------------------------
def test_mark_last_retryable_moves_retry_into_the_actions_row() -> None:
"""``markLastRetryable`` keeps the phase-49 remove-then-append
shape (remove every rendered .retry-btn FIRST, then re-append on
the last brain bubble — only when its preceding user record
exists), but the re-append now lands in the bubble's .msg-actions
row (via appendRetryButton → getActionsRow). The phase-59
save-button re-append hack (the ``saveDocBtn`` block) is DELETED:
the row's row-level right-align (task 03) right-aligns the whole
cluster, and the DOM order inside the row is the insertion order.
The 'auto margins split the free space' rationale is gone with it."""
js = _js()
body = _fn_body(js, "function markLastRetryable() {")
assert 'querySelectorAll(".retry-btn")' in body
assert ".remove()" in body
assert body.index(".remove()") < body.index("appendRetryButton(lastBrainWrap)"), (
"the existing buttons are removed before the new one is appended"
)
assert "appendRetryButton(lastBrainWrap)" in body, (
"the re-append targets the last brain wrap (via getActionsRow)"
)
assert 'who !== "user"' in body, "the preceding-user-record guard stays"
assert "saveDocBtn" not in body, (
"the phase-59 save-button re-append hack is deleted"
)
assert "parentElement" not in body, (
"no DOM re-parenting of the save button anymore"
)
assert "auto margins" not in body, (
"the 'auto margins split the free space' rationale is gone"
)
# The hack had no other home in the file.
assert "saveDocBtn" not in js
# ---------------------------------------------------------------------------
# the call-order audit (task 02 item 5) — .msg-actions is the LAST row
# ---------------------------------------------------------------------------
def test_done_branch_appends_actions_last_in_insertion_order() -> None:
"""The live ``done`` branch (a completed brain bubble): the row
order is the DOM order — the answer figure, then the source
clusters (appendSources), then the related row, then the action
appenders — so the .msg-actions row is the bubble's LAST row.
Inside the row the insertion order is Tune → Retry (the
markLastRetryable re-append) → Save-as-doc."""
js = _js()
branch = _done_branch(js)
for marker in (
"appendSources(wrap, ev.sources);",
"appendRelated(wrap, ev.related);",
"appendTuneButton(wrap);",
"markLastRetryable();",
'appendSaveAsDocButton(wrap, finalText || acc || "…");',
):
assert marker in branch, f"the done branch must keep {marker!r}"
order = [
branch.index("appendSources(wrap, ev.sources);"),
branch.index("appendRelated(wrap, ev.related);"),
branch.index("appendTuneButton(wrap);"),
branch.index("markLastRetryable();"),
branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'),
]
assert order == sorted(order), (
"done branch: sources → related → Tune → Retry → Save-as-doc "
"(the .msg-actions row is the bubble's LAST row — phase 130 "
"task 02; the row's inner order is the insertion order)"
)
# lastBrainWrap is set before the Retry re-append reads it.
assert branch.index("lastBrainWrap = wrap") < branch.index(
"markLastRetryable();"
)
def test_zero_frame_fallback_appends_actions_last() -> None:
"""The zero-frame-but-completed fallback bubble (the stream
settled with no events): a failed turn — Tune + the Failed note +
Retry (markLastRetryable) + Save-as-doc, all in the bubble's
.msg-actions row (the only row it has), the note BEFORE the Retry
re-append (the 'note + Retry' reading), the save button LAST in
the row (the phase-59 parity with the done path)."""
js = _js()
zero = js.index("if (!aborted && !wrap) {")
block = js[zero : js.index("} catch (err) {", zero)]
for marker in (
"appendTuneButton(fwrap);",
"appendFailedNote(fwrap, nothing);",
"markLastRetryable();",
"appendSaveAsDocButton(fwrap, fallback);",
):
assert marker in block, f"the fallback block must keep {marker!r}"
order = [
block.index("appendTuneButton(fwrap);"),
block.index("appendFailedNote(fwrap, nothing);"),
block.index("markLastRetryable();"),
block.index("appendSaveAsDocButton(fwrap, fallback);"),
]
assert order == sorted(order), (
"fallback: Tune → Failed note → Retry → Save-as-doc — the "
"actions row holds the whole cluster (phase 130 task 02)"
)
def test_stop_finalize_appends_actions_last() -> None:
"""The stop finalize (a stopped partial — the prime retry
candidate): Tune + the Stopped note + Retry (markLastRetryable),
all in the actions row — the stop path renders no sources or
related rows, so the actions row IS the bubble's only (and last)
row. The order is Tune → note → Retry (the 'note + Retry'
reading), unchanged from pre-phase (no save button — a stopped
partial is a note, not an answer)."""
js = _js()
catch = js.index("} catch (err) {")
stop = js.index('stoppedByUser || err?.name === "AbortError"', catch)
branch = js[stop : js.index("} else {", stop)]
for marker in (
"appendTuneButton(wrap);",
"appendStoppedNote(wrap);",
"markLastRetryable();",
):
assert marker in branch, f"the stop branch must keep {marker!r}"
order = [
branch.index("appendTuneButton(wrap);"),
branch.index("appendStoppedNote(wrap);"),
branch.index("markLastRetryable();"),
]
assert order == sorted(order), (
"stop: Tune → Stopped note → Retry (phase 130 task 02 — the "
"note + Retry in the actions row)"
)
assert "appendSaveAsDocButton" not in branch, (
"the stopped partial (note, not answer) still gets no save button"
)
def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
"""The phase-120 failure funnel (finalizeFailedTurn): the Failed
note is appended and the funnel ENDS with markLastRetryable
immediately followed by markLastEditable (phase 159 task 01 —
the failed turn's question is editable too) — the note + Retry
+ Edit in the failed bubble's actions row (the partial
keeps its streamed text; the zero-frame shape gets a plain
FAILED_TURN_TEXT bubble). Both shapes end retryable +
editable."""
js = _js()
header = (
"function finalizeFailedTurn(\n"
" detail,\n"
" { acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }\n"
") {"
)
body = _fn_body(js, header)
assert body.count("appendFailedNote(") == 2, (
"both funnel shapes add the in-bubble error note"
)
lines = [line for line in body.splitlines() if line.strip()]
assert lines[-1].strip() == "}" and lines[-2].strip().startswith(
"markLastEditable();"
) and lines[-3].strip().startswith(
"markLastRetryable();"
), (
"the funnel ends with the markLastRetryable() + "
"markLastEditable() pair — the Retry + Edit buttons land on "
"the failed bubble's actions row"
)
def test_restore_path_appends_actions_last() -> None:
"""The phase-14 restore path (renderStoredMessage): the answer
figure → sources → related → the action appenders (Tune /
Save-as-doc / the Stopped + Failed notes) — so the .msg-actions
row a restored bubble gets is its LAST row too, identical to the
live done path. The restore loop's after-the-loop
markLastRetryable() then lands the Retry button in that same row
(on the LAST restored brain bubble)."""
js = _js()
body = _fn_body(js, "function renderStoredMessage(m) {")
for marker in (
"appendSources(wrap, m.sources);",
"appendRelated(wrap, m.related);",
"appendTuneButton(wrap)",
"appendSaveAsDocButton(wrap, m.text);",
"appendStoppedNote(wrap);",
"appendFailedNote(wrap, m.error);",
"lastBrainWrap = wrap",
):
assert marker in body, f"the restore path must keep {marker!r}"
order = [
body.index("appendSources(wrap, m.sources);"),
body.index("appendRelated(wrap, m.related);"),
body.index("appendTuneButton(wrap)"),
body.index("appendSaveAsDocButton(wrap, m.text);"),
body.index("lastBrainWrap = wrap"),
]
assert order == sorted(order), (
"restore: sources → related → the action appenders → "
"lastBrainWrap (the after-the-loop markLastRetryable lands the "
"Retry in the same actions row — phase 130 task 02)"
)