Files
brain-of-reese/tests/unit/test_frontend_error_detail.py
T
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

363 lines
15 KiB
Python

"""Unit: the shared ``errorDetailText`` serializer (phase 164, task 02).
Behavior-pins the exported helper under node (the ``_run_node`` house
pattern — the function is pure, so its extracted source runs standalone)
across the full shape matrix, led by the EXACT live FastAPI 422 body of
2026-10-07: a pydantic list-of-objects ``detail`` must serialize to the
``msg`` verbatim — never ``"[object Object]"``, never the rejected
``input`` text (the entries carry the full conversation — it must never
reach the DOM). Source pins: the two chat call sites now read through
the helper, the helper never ``JSON.stringify``-s anything, and the
SEVEN existing guarded detail-readers (tuning.js, theme.js,
document.js, git-sources.js, doc-edit.js, app.js's tuning form and its
image-upload guard) stay byte-identical — no opportunistic refactor.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
APP_JS = ASSETS / "app.js"
CHAT_PY = Path(__file__).resolve().parents[2] / "app" / "api" / "chat.py"
HAVE_NODE = shutil.which("node") is not None
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _asset(name: str) -> str:
return (ASSETS / name).read_text(encoding="utf-8")
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
def _fn(js: str, name: str) -> str:
"""The full source of ``export function name(…) {…}`` via
balanced-brace counting (the test_frontend_* convention — the
``export`` prefix still matches the ``function name(`` search)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in app.js"
depth = 0
for i in range(js.find("{", start), len(js)):
if js[i] == "{":
depth += 1
elif js[i] == "}":
depth -= 1
if depth == 0:
return js[start : i + 1]
raise AssertionError(f"unbalanced braces in {name}()")
#: Sentinel serialized to the JS literal ``undefined`` (json has none).
class _Undef:
pass
UNDEFINED = _Undef()
def _lit(v: object) -> str:
if isinstance(v, _Undef):
return "undefined"
return json.dumps(v)
def _serialize(cases: list[tuple[str, object, object]]) -> dict[str, str]:
"""Run the REAL exported ``errorDetailText`` under node over
``[(name, value, fallback), …]`` and return ``{name: result}``."""
script = (
_fn(_js(), "errorDetailText")
+ "\nconst out = [];\n"
+ "".join(
f"out.push([{_lit(name)}, errorDetailText({_lit(value)}, {_lit(fallback)})]);\n"
for name, value, fallback in cases
)
+ "for (const row of out) console.log(JSON.stringify(row));\n"
)
rows = (json.loads(line) for line in _run_node(script).splitlines() if line)
return {row[0]: row[1] for row in rows}
#: The EXACT live 422 body (phase 164 overview): the follow-up turn of
#: a >32k-char answer, rejected by the old HistoryTurn.text cap. The
#: `input` is the whole rejected conversation — here a marked stand-in
#: that must NEVER appear in the serialized result.
LIVE_422_DETAIL = [
{
"type": "string_too_long",
"loc": ["body", "history", 5, "text"],
"msg": "String should have at most 600000 characters",
"input": f"CANARY-INPUT {'x' * 4000}",
}
]
FB = "FALLBACK-LINE"
# ---------------------------------------------------------------------------
# Behavior — the shape matrix under node (the exported, REAL function)
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_live_422_list_detail_serializes_the_msg_only() -> None:
"""The EXACT live 422 body: the entry's ``msg`` verbatim, and the
result carries NO ``input`` text and no ``"[object Object]"`` —
the 2026-10-07 failure is unrepresentable (locked A2)."""
got = _serialize([("live422", LIVE_422_DETAIL, FB)])
assert got["live422"] == "String should have at most 600000 characters"
assert "CANARY" not in got["live422"], "the rejected input must never serialize"
assert "[object Object]" not in got["live422"]
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_array_entries_join_with_semicolon_space() -> None:
"""Multi-entry arrays join their usable texts with ``"; "``:
several object ``msg``s, and mixed object + plain-string entries
(the generalization of the seven guarded copies)."""
got = _serialize(
[
(
"multi_obj",
[{"msg": "first problem"}, {"msg": "second problem"}],
FB,
),
("mixed", [{"msg": "obj msg"}, "plain string"], FB),
("plain_only", ["a", "b"], FB),
("number_entry", [42], FB),
]
)
assert got["multi_obj"] == "first problem; second problem"
assert got["mixed"] == "obj msg; plain string"
assert got["plain_only"] == "a; b"
assert got["number_entry"] == "42"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_empty_array_entries_drop_and_all_empty_falls_back() -> None:
"""Entries with neither a usable ``msg`` nor a non-empty string
drop (objects without msg, empty msgs, nullish, empty strings); an
all-empty array — and the bare ``[]`` — fall back."""
got = _serialize(
[
("drop_and_keep", [{"nomsg": 1}, {"msg": ""}, None, "", {"msg": "kept"}], FB),
("all_empty", [{"nomsg": 1}, None, ""], FB),
("bare_empty", [], FB),
]
)
assert got["drop_and_keep"] == "kept"
assert got["all_empty"] == FB
assert got["bare_empty"] == FB
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_object_detail_msg_or_fallback() -> None:
"""A plain object with a non-empty ``msg`` serializes it verbatim;
without one — even carrying a huge rejected ``input`` — it falls
back (never stringified)."""
got = _serialize(
[
("obj_msg", {"type": "string_too_long", "msg": "only the msg"}, FB),
("obj_nomsg", {"input": "CANARY " * 500}, FB),
("obj_empty_msg", {"msg": ""}, FB),
]
)
assert got["obj_msg"] == "only the msg"
assert got["obj_nomsg"] == FB
assert got["obj_empty_msg"] == FB
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_string_verbatim_incl_server_fixed_copy_otherwise_fallback() -> None:
"""A non-empty string serializes VERBATIM — the pinned server-built
frame copies ("Question too long…", "The chat model dropped the
connection…") must reach the user byte-identical. ``""``, null,
undefined, a number, and a boolean all fall back."""
server_copy_1 = "Question too long — trim it and re-ask."
server_copy_2 = "The chat model dropped the connection — try again?"
# The server-side half of the pin: these ARE the ChatErrorEvent copies.
chat_py = CHAT_PY.read_text(encoding="utf-8")
assert f'detail="{server_copy_1}"' in chat_py
assert f'detail="{server_copy_2}"' in chat_py
got = _serialize(
[
("server_copy_1", server_copy_1, FB),
("server_copy_2", server_copy_2, FB),
("plain", "some string", FB),
("empty", "", FB),
("null", None, FB),
("undefined", UNDEFINED, FB),
("number", 42, FB),
("boolean", True, FB),
]
)
assert got["server_copy_1"] == server_copy_1
assert got["server_copy_2"] == server_copy_2
assert got["plain"] == "some string"
for name in ("empty", "null", "undefined", "number", "boolean"):
assert got[name] == FB, name
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_no_shape_yields_object_object() -> None:
"""TOTALITY sweep: across every shape the helper can meet, no
result is ever ``"[object Object]"`` (or contains it) — the string
is unrepresentable at the serializer level."""
shapes = [
LIVE_422_DETAIL,
[{"type": "x", "loc": [1], "msg": "m", "input": "i"}] * 3,
[{"a": {"b": 1}}],
{"deep": {"nested": [1, 2]}},
[[{"msg": "nested"}]],
"s",
0,
None,
UNDEFINED,
]
got = _serialize([(f"s{i}", v, FB) for i, v in enumerate(shapes)])
for name, res in got.items():
assert isinstance(res, str) and res, name
assert "[object Object]" not in res, name
# ---------------------------------------------------------------------------
# Source pins — the export, the two call sites, the untouched copies
# ---------------------------------------------------------------------------
def test_error_detail_text_is_exported_for_unit_pinning() -> None:
"""The helper is an EXPORTED function (the TURN_TIMEOUT_MS /
THINKING_NEAR_BOTTOM_PX export-for-unit-pin idiom), declared
exactly once."""
js = _js()
assert re.search(r"^export function errorDetailText\(value, fallback\) \{", js, re.M), (
"errorDetailText must be an exported top-level function"
)
assert js.count("function errorDetailText(") == 1
def test_helper_never_stringifies_never_reads_input_loc_ctx() -> None:
"""LOCKED A2 inside the helper: NO ``JSON.stringify`` anywhere in
its source, and the words ``input``/``loc``/``ctx`` never appear —
only ``msg`` and plain strings serialize (the docstring names the
rule but the FUNCTION body is what this pins)."""
fn = _fn(_js(), "errorDetailText")
assert "JSON.stringify" not in fn
for forbidden in ("input", "loc", "ctx"):
assert re.search(rf"\b{forbidden}\b", fn) is None, (
f"the helper must never touch `{forbidden}` (the 422 entries "
"carry the full rejected conversation)"
)
# The docstring above the helper names the live failure + the rule.
js = _js()
doc = js[: js.find("function errorDetailText(")][-2500:]
assert "2026-10-07" in doc and "[object Object]" in doc
assert "input" in doc, "the docstring names the input-leak rule"
def test_prestream_call_site_reads_through_the_helper() -> None:
"""The pre-stream ``!res.ok || !res.body`` handler: the unguarded
``if (body.detail) detail = body.detail`` is GONE, the assignment
reads ``errorDetailText(body.detail, …)`` with the per-status
string as the fallback, and the try-around-res.json + non-JSON
catch keep their shape."""
js = _js()
assert "if (body.detail) detail = body.detail;" not in js
site = js.index('if (!res.ok || !res.body) {')
block_end = js.index("await readSSE(", site)
block = js[site:block_end]
assert "let detail = `Brain's API answered with HTTP ${res.status}.`;" in block
assert "detail = errorDetailText(body.detail, detail);" in block, (
"the per-status default is the serializer's fallback"
)
assert "} catch { /* non-JSON error body */ }" in block
# Phase 164 task 03 decorates THIS error (bubbleText + hint) before
# the throw — the thrown message stays the serialized detail.
assert "const err = new Error(detail);" in block
assert "throw err;" in block
# The fallback is exactly the status line the bubble must show for
# a non-JSON 500 (phase 164 E2E case b).
assert "Brain's API answered with HTTP" in block
def test_sse_error_frame_call_site_reads_through_the_helper() -> None:
"""The SSE ``error``-frame throw: ``new Error(errorDetailText(ev.detail,
"Something went wrong on my side."))`` — and the phase-114
``err.hint`` line stays byte-identical right after it."""
js = _js()
assert (
'const err = new Error(errorDetailText(ev.detail, "Something went wrong on my side."));'
in js
)
site = js.index('const err = new Error(errorDetailText(ev.detail,')
tail = js[site : site + 200]
assert tail.splitlines()[1].strip() == "err.hint = ev.hint;", (
"the err.hint channel is untouched"
)
assert 'new Error(ev.detail ||' not in js, "the old unguarded read is gone"
#: One guarded 422-reader block per existing site (the seven copies,
#: D2 "stay byte-identical — no opportunistic refactor"): the exact
#: idiom lines, file → (array-msg line, string line).
GUARDED_COPIES: dict[str, tuple[str, str]] = {
"tuning.js": (
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
' if (typeof data.detail === "string" && data.detail) return data.detail;',
),
"theme.js": (
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
' if (typeof data.detail === "string" && data.detail) return data.detail;',
),
"document.js": (
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
' if (typeof data.detail === "string" && data.detail) return data.detail;',
),
"git-sources.js": (
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
' if (typeof data.detail === "string" && data.detail) return data.detail;',
),
"doc-edit.js": (
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
' if (typeof data.detail === "string" && data.detail) return data.detail;',
),
}
@pytest.mark.parametrize("name", sorted(GUARDED_COPIES))
def test_existing_guarded_readers_are_byte_identical(name: str) -> None:
"""The five external guarded readers keep their EXACT lines
(indented, verbatim) — the phase touches only the two unguarded
chat paths."""
src = _asset(name)
array_line, string_line = GUARDED_COPIES[name]
assert array_line in src, f"{name} lost its guarded array-msg line"
assert string_line in src, f"{name} lost its guarded string line"
def test_app_js_guarded_copies_are_untouched() -> None:
"""app.js's own guarded copies — the tuning-form reader (the
three-branch idiom) and the image-upload string guard — stay
byte-identical, and the tuning form was NOT opportunistically
rewritten onto the new helper (exactly one Array-guard block in
app.js: the tuning form)."""
js = _js()
guard = "if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {"
assert js.count(guard) == 1
assert "detail = String(data.detail[0].msg);" in js
assert "} else if (typeof data.detail === \"string\" && data.detail) {" in js
assert 'if (typeof body?.detail === "string") detail = body.detail;' in js