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/`.
363 lines
15 KiB
Python
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
|