Files
brain-of-reese/tests/e2e/test_chat_error_honesty.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

541 lines
21 KiB
Python

"""Phase 164 E2E (Playwright): chat failure honesty — a pre-stream HTTP
rejection states what ACTUALLY happened, and a full-length answer flows
through the whole chain.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov
The live finding (owner report, 2026-10-07): after an answer hit the
length cap, the NEXT turn's bubble read "My answer didn't make it — the
connection dropped. Use Retry to ask again. Failed [object Object]."
The chain: the long answer rode back as ``history`` on the next
``POST /api/chat``; the old 32 000-char ``HistoryTurn.text`` cap
rejected it with a 422 whose ``detail`` is a LIST OF OBJECTS; the
unguarded ``body.detail`` assignment stringified the array to
"[object Object]" inside the connection-dropped bubble — a lie (the
server WAS reachable; it answered 422).
The phase's remedies, proven here against the REAL ``app.js`` send
path (the stubs ride the browser's own network layer via
``page.route`` — the house pattern from test_navbar_refresh /
test_archive_upload_sources — so every assert drives the shipped
pre-stream handler, serializer, and failed-turn funnel):
1. ``test_prestream_422_renders_honest_line_with_serialized_detail`` —
(the live bug) ``POST /api/chat`` is stubbed to the EXACT FastAPI
422 shape for a long-history rejection. The bubble reads
``FAILED_REQUEST_TEXT`` verbatim (NOT the connection line), the
``.failed-note`` carries the pydantic ``msg`` verbatim, NO
"[object Object]" and NO rejected ``input`` text reach the DOM, the
banner shows the phase-114 hint, Retry + Edit land, and the
composer re-enables (§7.4).
2. ``test_non_json_500_settles_on_the_http_status_fallback`` — a 500
with a NON-JSON body settles on the ``HTTP 500`` default string
(the serializer's fallback path) under the same honest pre-stream
bubble — no crash.
3. ``test_full_length_answer_history_is_accepted`` — (the D1 fix,
REAL server, no stub) a seeded ``bor.chat.v1`` conversation whose
last brain record carries a ~525k-char answer (the live full-cap
size) sends a follow-up that is ACCEPTED — the response is 200, the
mock's deterministic echo settles the turn, the saved-chat
auto-save chain stays 2xx — where pre-phase this was the 422 that
killed every follow-up. The locked budget trim (A4) may drop the
giant turn WHOLE from the prompt; this suite asserts ACCEPTANCE and
settle, never prompt content.
4. ``test_sse_error_frame_with_array_detail_serializes_into_note`` —
(client tolerance) a valid SSE stream whose terminal ``error``
frame carries an ARRAY ``detail``: the note serializes the ``msg``,
the zero-frame settle keeps ``FAILED_TURN_TEXT`` byte-identically
(a model-side failure is NOT the pre-stream line), and the
persisted record carries the serialized detail.
The bubble texts are read from ``app.js`` itself (the phase-120
``_js_const`` convention) so the Python pins can never drift from the
shipped copy.
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlparse
import pytest
from playwright.sync_api import Page, Route, expect
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
REPO = Path(__file__).resolve().parents[2]
# ---------------------------------------------------------------------------
# Copy constants, read from app.js (no JS/Python drift — the phase-120
# convention). The unit suite pins the strings themselves once; the E2E
# asserts the PAGE renders the SAME text.
# ---------------------------------------------------------------------------
def _js_const(name: str) -> str:
js = (REPO / "frontend" / "assets" / "app.js").read_text(encoding="utf-8")
m = re.search(rf'const {name}\s*=\s*\n?\s*"([^"]*)"', js)
assert m, f"const {name} not found in app.js"
return m.group(1)
FAILED_TURN_TEXT = _js_const("FAILED_TURN_TEXT")
FAILED_REQUEST_TEXT = _js_const("FAILED_REQUEST_TEXT")
def _js_prestream_hint() -> str:
"""The pre-stream throw's banner hint (phase 164 D3) — the ONLY
``err.hint`` assignment in app.js that carries a string literal (the
SSE-frame one reads ``ev.hint``), so the match is unique."""
js = (REPO / "frontend" / "assets" / "app.js").read_text(encoding="utf-8")
matches = re.findall(r'err\.hint =\s*\n?\s*"([^"]*)"', js)
assert len(matches) == 1, f"expected exactly one literal err.hint, got {matches}"
return matches[0]
PRESTREAM_HINT = _js_prestream_hint()
#: The pydantic 83 boundary rejection for HistoryTurn.text (600 000
#: since phase 164, locked A1) — the EXACT live message the note must
#: render verbatim.
PYDANTIC_MSG = "String should have at most 600000 characters"
#: A probe from the rejected 422 entry's `input` (the whole rejected
#: turn — it must NEVER reach the DOM). A 100-char run of "x" cannot
#: occur naturally in the serialized page.
INPUT_PROBE = "x" * 100
STORAGE_KEY = "bor.chat.v1"
# ---------------------------------------------------------------------------
# Assert 3 fixtures: the seeded full-length answer + the grounded echo
# question (the test_failed_turn_retry / test_llm_history seed pattern).
# ---------------------------------------------------------------------------
SEED_SOURCE = "docs"
SEED_PATH = "homelab/kubernetes.md"
KUB_CONTENT = (REPO / "tests" / "fixtures" / "docs" / SEED_PATH).read_text()
#: The seeded conversation (the live repro's shape): a question and a
#: brain answer of the FULL-CAP size — 131 072 tokens ≈ 524 300 chars
#: at ~4 chars/token (the 2026-10-07 live report). Over the OLD 32 000
#: ceiling (the pre-phase 422), well under the new 600 000.
SEED_QUESTION = "Write everything you know about my Kubernetes cluster?"
BIG_ANSWER_LEN = 525_000
BIG_ANSWER = ("A kubernetes cluster note. " * (BIG_ANSWER_LEN // 27 + 1))[
:BIG_ANSWER_LEN
]
#: The follow-up that died with the 422 live. `echo my history` is the
#: mock's HISTORY_TRIGGER (fires whatever the honesty gate says) and
#: "kubernetes cluster" keeps it on-topic for the one-doc KB, so the
#: turn settles with the byte-stable deterministic echo.
FOLLOWUP_Q = "echo my history about my kubernetes cluster"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
# ---------------------------------------------------------------------------
# DB seeding (the test_failed_turn_retry pattern): TRUNCATE-then-seed
# one fixture document so the follow-up's gate is deterministic.
# ---------------------------------------------------------------------------
def _seed(db: Session) -> None:
md = Document(
source=SEED_SOURCE,
path=SEED_PATH,
full_path=f"/tmp/{SEED_PATH}",
title="Kubernetes",
content=KUB_CONTENT,
content_hash=hashlib.sha256(KUB_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
)
db.add(md)
db.flush()
db.add(
Chunk(
document_id=md.id,
position=0,
content=KUB_CONTENT,
embedding=embed_text(KUB_CONTENT),
)
)
def _reset_db() -> None:
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
_seed(db)
db.commit()
@pytest.fixture(autouse=True)
def clean_chats() -> Iterator[None]:
"""The send auto-saves a ``saved_chats`` row per turn (phase 55) —
truncate around every test (the phase-80/103/120 isolation
pattern, including the teardown margin for the fire-and-forget
PUT)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
time.sleep(0.75)
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _submit(page: Page, question: str) -> None:
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _stored(page: Page) -> dict:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
def _assert_settled_composer(page: Page) -> None:
"""§7.4 never-stale: after the failure the composer is fully usable
again (Send enabled, label back — nothing zombified)."""
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
def _assert_dom_is_honest(page: Page, *, forbidden_input_probe: str | None) -> None:
"""The [object Object] / input-leak rule, page-wide: neither the
garbage stringification nor a probe of the rejected `input` may
appear anywhere in the DOM."""
html = page.content()
assert "[object Object]" not in html
if forbidden_input_probe:
assert forbidden_input_probe not in html
# ---------------------------------------------------------------------------
# 1 — the live bug: a pre-stream 422 (FastAPI's EXACT validation shape)
# renders the distinct honest line + the serialized msg, never
# "[object Object]", never the rejected input
# ---------------------------------------------------------------------------
def _stub_long_history_422(route: Route) -> None:
"""The EXACT FastAPI 422 body for a long-history rejection (a
``string_too_long`` entry whose ``input`` carries the whole
rejected answer — the payload whose array ``detail`` produced the
live "[object Object]")."""
route.fulfill(
status=422,
content_type="application/json",
body=json.dumps(
{
"detail": [
{
"type": "string_too_long",
"loc": ["body", "history", 5, "text"],
"msg": PYDANTIC_MSG,
"input": "x" * 1000,
}
]
}
),
)
def test_prestream_422_renders_honest_line_with_serialized_detail(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
page.route("**/api/chat", _stub_long_history_422)
with page.expect_response(
lambda r: urlparse(r.url).path == "/api/chat"
) as info:
_submit(page, "How is my Kubernetes cluster set up? (phase 164 e2e)")
# The stub answered through the browser's real network layer — this
# is the pre-stream HTTP settle, not a transport failure.
assert info.value.status == 422
# The bubble: the DISTINCT honest pre-stream line (phase 164 D3) —
# NOT the connection-dropped copy (the server answered deliberately;
# the connection held).
brain = page.locator(".msg.brain")
expect(brain).to_have_count(1)
expect(brain.locator(".bubble")).to_have_text(FAILED_REQUEST_TEXT)
# The note: the pydantic `msg` VERBATIM (task 02's serializer), and
# nowhere on the page the array garbage or the rejected `input`.
note = brain.locator(".failed-note")
expect(note).to_have_count(1)
expect(note).to_contain_text("Failed")
expect(note).to_contain_text(PYDANTIC_MSG)
_assert_dom_is_honest(page, forbidden_input_probe=INPUT_PROBE)
# The banner (role=alert): the same detail plus the phase-114 hint
# channel (D3) — "the app answered, nothing was lost but this send".
banner = page.locator("#kb-banner")
expect(banner).to_have_attribute("role", "alert", timeout=30_000)
expect(banner).to_be_visible()
expect(banner).to_contain_text(PYDANTIC_MSG)
expect(banner).to_contain_text(PRESTREAM_HINT)
# Recovery affordances: Retry on the failed bubble (phase 120) +
# Edit on the question (phase 159), and the composer fully usable
# again (§7.4).
expect(brain.locator(".retry-btn")).to_have_count(1)
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
_assert_settled_composer(page)
# Persisted (the phase-120 contract extended to the new text):
# what the user saw is what is stored — the new bubble line +
# failed: true + the serialized detail in the capped `error`.
stored = _stored(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
failed = stored["messages"][-1]
assert failed["text"] == FAILED_REQUEST_TEXT
assert failed["failed"] is True
assert failed["error"] == PYDANTIC_MSG
# ---------------------------------------------------------------------------
# 2 — the fallback: a 500 with a NON-JSON body settles on the
# "HTTP 500" default string under the same honest pre-stream line
# ---------------------------------------------------------------------------
def _stub_html_500(route: Route) -> None:
"""An outage page: text/html where JSON was expected — the
serializer's `res.json()` catch keeps the per-status default."""
route.fulfill(
status=500,
content_type="text/html",
body="<html><head><title>500</title></head><body>gateway trouble</body></html>",
)
def test_non_json_500_settles_on_the_http_status_fallback(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
page.route("**/api/chat", _stub_html_500)
with page.expect_response(
lambda r: urlparse(r.url).path == "/api/chat"
) as info:
_submit(page, "How is my Kubernetes cluster set up? (phase 164 e2e 500)")
assert info.value.status == 500
# Still the pre-stream settle (the server answered 500) — the
# honest request line, with the `HTTP 500` default string on the
# note (the body was not JSON; the serializer fell back).
brain = page.locator(".msg.brain")
expect(brain).to_have_count(1)
expect(brain.locator(".bubble")).to_have_text(FAILED_REQUEST_TEXT)
note = brain.locator(".failed-note")
expect(note).to_have_count(1)
expect(note).to_contain_text("Failed")
expect(note.locator(".failed-detail")).to_have_text(
"Brain's API answered with HTTP 500."
)
_assert_dom_is_honest(page, forbidden_input_probe=None)
# No crash, and every control usable again (§7.4).
banner = page.locator("#kb-banner")
expect(banner).to_be_visible()
expect(banner).to_contain_text(PRESTREAM_HINT)
expect(brain.locator(".retry-btn")).to_have_count(1)
_assert_settled_composer(page)
stored = _stored(page)
failed = stored["messages"][-1]
assert failed["text"] == FAILED_REQUEST_TEXT
assert failed["failed"] is True
assert failed["error"] == "Brain's API answered with HTTP 500."
# ---------------------------------------------------------------------------
# 3 — the D1 fix, REAL server: a history turn carrying a ~525k-char
# brain answer is ACCEPTED (the live "every follow-up dies after a
# long answer" failure mode is gone) and the turn settles
# ---------------------------------------------------------------------------
def test_full_length_answer_history_is_accepted(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
assert len(BIG_ANSWER) == BIG_ANSWER_LEN
assert len(BIG_ANSWER) > 32_000, "the repro must exceed the OLD 32 000 cap"
# Seed the live repro's shape into bor.chat.v1 (the saved-chat
# suites' seed pattern): question + a FULL-CAP brain answer, the
# brain record LAST — exactly the record the next send turns into
# `history`.
seed = {
"v": 1,
"chatId": None,
"messages": [
{"who": "user", "text": SEED_QUESTION},
{"who": "brain", "text": BIG_ANSWER},
],
}
page.evaluate(
"([key, value]) => localStorage.setItem(key, value)",
[STORAGE_KEY, json.dumps(seed)],
)
# Track the saved-chat chain too: the phase-55 auto-save carries
# the SAME 525k-char record to POST/PUT /api/chats — every one of
# those must stay 2xx (the mirror invariant, live).
chat_saves: list[tuple[str, int]] = []
page.on(
"response",
lambda r: chat_saves.append((r.request.method, r.status))
if urlparse(r.url).path.startswith("/api/chats")
else None,
)
page.reload()
# The restore renders the seeded conversation — the giant bubble
# proves the client actually holds the full-length answer the send
# will put on the wire.
expect(page.locator("#messages > .msg")).to_have_count(2)
expect(page.locator(".msg.user .bubble")).to_have_text(SEED_QUESTION)
restored = page.locator(".msg.brain .bubble").last.inner_text()
assert len(restored) > 32_000, "the seeded long answer must restore"
with page.expect_response(
lambda r: urlparse(r.url).path == "/api/chat"
) as info:
_submit(page, FOLLOWUP_Q)
# THE fix: the follow-up of the full-length answer is ACCEPTED —
# no 422 (pre-phase the 32 000 ceiling rejected exactly this
# request and killed every follow-up).
assert info.value.status == 200, (
f"the long-history follow-up must be accepted, got {info.value.status}"
)
# The turn settles normally on the deterministic echo (the locked
# budget trim may have dropped the giant turn WHOLE from the
# prompt — A4 — so the echo's COUNT is never asserted, only the
# settle). No failed bubble, no banner, composer usable (§7.4).
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=60_000)
expect(bubble).to_contain_text("prior messages")
expect(page.locator(".failed-note")).to_have_count(0)
expect(page.locator("#kb-banner")).to_be_hidden()
_assert_settled_composer(page)
# The record chain held: the question's user record and the fresh
# answer are persisted, the answer is not marked failed.
stored = _stored(page)
last = stored["messages"][-1]
assert last["who"] == "brain"
assert MOCK_ANSWER_MARKER in last["text"]
assert last.get("failed") in (None, False)
# The saved-chat auto-save of the SAME full-length conversation
# was accepted too (201/200 — the ChatMessage mirror + the total
# budget; any 4xx here is the phase-113 quiet-failure class).
time.sleep(1.0) # the fire-and-forget save margin
assert chat_saves, "the auto-save never fired"
assert all(status < 400 for _, status in chat_saves), chat_saves
# ---------------------------------------------------------------------------
# 4 — client tolerance: an SSE terminal `error` frame whose `detail` is
# an ARRAY serializes into the note, and the zero-frame settle
# keeps FAILED_TURN_TEXT (a model-side failure is NOT the
# pre-stream line)
# ---------------------------------------------------------------------------
def _stub_sse_array_error(route: Route) -> None:
"""A valid SSE stream ending in an `error` frame with an ARRAY
detail (server-built frames carry a plain string — pinned in the
task-02 units; this is the tolerant half). The `input` again must
never reach the DOM."""
frame = {
"type": "error",
"detail": [
{
"type": "value_error",
"loc": ["body"],
"msg": "mock stream-side rejection",
"input": "q" * 1000,
}
],
}
route.fulfill(
status=200,
content_type="text/event-stream",
body=f"data: {json.dumps(frame)}\n\n",
)
def test_sse_error_frame_with_array_detail_serializes_into_note(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
page.route("**/api/chat", _stub_sse_array_error)
_submit(page, "How is my Kubernetes cluster set up? (phase 164 e2e sse)")
# Zero frames + the terminal error frame: the MODEL-side settle —
# FAILED_TURN_TEXT byte-identically (phase 120; the pre-stream
# bubbleText is NEVER set on an SSE-frame throw), with the array
# detail serialized into the note instead of stringified garbage.
brain = page.locator(".msg.brain")
expect(brain).to_have_count(1)
expect(brain.locator(".bubble")).to_have_text(FAILED_TURN_TEXT)
note = brain.locator(".failed-note")
expect(note).to_have_count(1)
expect(note).to_contain_text("Failed")
expect(note).to_contain_text("mock stream-side rejection")
_assert_dom_is_honest(page, forbidden_input_probe="q" * 100)
# The persisted record: the same text the user saw + the serialized
# detail (the phase-120 persistence contract, unchanged shape).
stored = _stored(page)
failed = stored["messages"][-1]
assert failed["text"] == FAILED_TURN_TEXT
assert failed["failed"] is True
assert failed["error"] == "mock stream-side rejection"
# Recovery + re-enable (§7.4).
expect(brain.locator(".retry-btn")).to_have_count(1)
expect(page.locator(".msg.user .edit-btn")).to_have_count(1)
_assert_settled_composer(page)