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/`.
This commit is contained in:
@@ -137,9 +137,10 @@ def test_failed_turn_text_is_a_distinct_constant() -> None:
|
||||
assert failed_text != m_empty.group(1), (
|
||||
"FAILED_TURN_TEXT must be DISTINCT from EMPTY_ANSWER_FALLBACK"
|
||||
)
|
||||
# The zero-frame branch uses the constant, not a copy of the
|
||||
# fallback.
|
||||
assert 'addMessage("brain", FAILED_TURN_TEXT);' in js
|
||||
# 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -160,8 +161,12 @@ def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
|
||||
# …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_TURN_TEXT bubble…
|
||||
assert 'addMessage("brain", FAILED_TURN_TEXT);' 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.
|
||||
@@ -448,6 +453,158 @@ def test_retry_last_turn_is_byte_unchanged() -> None:
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user