phase: 164_chat_error_honesty
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 29s

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:
2026-10-07 18:00:12 -04:00
parent 6ba46e735a
commit 34d60efbc1
42 changed files with 2601 additions and 84 deletions
+88 -8
View File
@@ -385,6 +385,56 @@ const EMPTY_ANSWER_FALLBACK = "Hmm — that came back empty. Ask me again?";
persisted `error` key (capped at 500 — the ChatMessage bound). */
const FAILED_TURN_TEXT =
"My answer didn't make it — the connection dropped. Use Retry to ask again.";
/* Phase 164 (D3, locked A3): the PRE-STREAM HTTP-rejection settle — the
server ANSWERED with a 4xx/5xx (the request died BEFORE the model ran;
the 2026-10-07 live 422-after-a-long-answer is the finding). A distinct
honest line: FAILED_TURN_TEXT's "the connection dropped" is a LIE here
(the connection held — the API deliberately rejected the request). The
real detail rides the in-bubble .failed-note + the persisted `error`
key exactly as FAILED_TURN_TEXT's does (the phase-120 persistence
contract extends, unchanged). NOT for transport failures — a rejected
`fetch` is no answer at all, so FAILED_TURN_TEXT stays honest there. */
const FAILED_REQUEST_TEXT =
"I couldn't take that — my API rejected the request. Use Retry to ask again.";
/* Phase 164 (D2, locked A2): the SHARED serializer for an API error
* `detail` — the live failure of 2026-10-07: a FastAPI 422 carries a
* LIST OF OBJECTS ([{type, loc, msg, input}, …]) as `detail`, and the
* unguarded `new Error(body.detail)` on the chat path stringified the
* whole array to "[object Object]" inside the failed-turn note. TOTAL
* semantics — every input shape yields a human string or the named
* fallback: a non-empty string verbatim (every server-built frame —
* ChatErrorEvent — carries one); an ARRAY entry serializes as
* String(entry.msg) when the entry is an object with a non-empty msg,
* else String(entry) for a non-object entry (entries with neither
* drop), joined "; " (an all-empty result falls back); an OBJECT
* serializes as String(value.msg) when it has a non-empty msg, else
* falls back; nullish / empty / any other type falls back. NEVER
* JSON.stringify a raw detail and NEVER touch `input`/`loc`/`ctx`:
* the FastAPI 422 entries carry the full rejected input — the whole
* conversation — and only `msg` and plain strings may reach the DOM.
* Exported so the shape matrix is unit-pinned (same pattern as
* TURN_TIMEOUT_MS). */
export function errorDetailText(value, fallback) {
if (typeof value === "string") return value || fallback;
if (Array.isArray(value)) {
const parts = [];
for (const entry of value) {
if (entry && typeof entry === "object") {
if (typeof entry.msg === "string" && entry.msg) parts.push(String(entry.msg));
} else if (typeof entry === "string") {
if (entry) parts.push(entry);
} else if (entry !== null && entry !== undefined) {
parts.push(String(entry));
}
}
return parts.length ? parts.join("; ") : fallback;
}
if (value && typeof value === "object") {
return typeof value.msg === "string" && value.msg ? String(value.msg) : fallback;
}
return fallback;
}
/* Calm, don't remove: smooth scrolling is the one motion JS controls. */
const reducedMotion =
@@ -3778,10 +3828,19 @@ async function handleSend(e) {
* untrimmed string). NOT this funnel: the stop path (its own branch),
* the 300s abort (the `aborted` branch), and the zero-frame-but-completed
* case (the stream settled — runTurn's inline fallback branch keeps its
* EMPTY_ANSWER_FALLBACK bubble and only gains the failed marker). */
* EMPTY_ANSWER_FALLBACK bubble and only gains the failed marker).
*
* Phase 164 (D3): the `bubbleText` option — the caller-supplied zero-frame
* bubble line (runTurn's catch forwards `err.bubbleText`; ONLY the
* pre-stream HTTP-rejection throw sets one — FAILED_REQUEST_TEXT). The
* ZERO-frame branch settles `bubbleText || FAILED_TURN_TEXT` into BOTH
* the visible bubble and the persisted record ("what the user saw is what
* is stored" — the phase-120 contract, extended). The partial-stream
* branch never reads the key: a pre-stream throw has no `wrap` by
* construction, so a bubbleText can only ever reach the zero-frame path. */
function finalizeFailedTurn(
detail,
{ acc, thinking, tools, segments, wrap, leavePartialIndex }
{ acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }
) {
const error = (detail || "").trim().slice(0, 500);
if (wrap) {
@@ -3812,11 +3871,15 @@ function finalizeFailedTurn(
} else {
// Zero frames (network error, pre-stream HTTP error): a new failed
// bubble with the fixed honest line — the real detail rides the note
// + the persisted `error` key.
const fwrap = addMessage("brain", FAILED_TURN_TEXT);
// + the persisted `error` key. Phase 164 (D3): a caller-supplied
// bubbleText (the pre-stream HTTP rejection — FAILED_REQUEST_TEXT)
// replaces the connection-dropped line; the SAME value goes into the
// bubble and the record (stored-what-you-saw).
const fwrapText = bubbleText || FAILED_TURN_TEXT;
const fwrap = addMessage("brain", fwrapText);
appendFailedNote(fwrap, error);
rememberBrainTurn(
FAILED_TURN_TEXT,
fwrapText,
{ failed: true, error: error || undefined },
leavePartialIndex
);
@@ -3953,9 +4016,21 @@ async function runTurn(text, { reask = false, image = null } = {}) {
let detail = `Brain's API answered with HTTP ${res.status}.`;
try {
const body = await res.json();
if (body.detail) detail = body.detail;
// Phase 164 (D2): the 422 `detail` is a LIST OF OBJECTS — the
// shared serializer yields the `msg` text, never
// "[object Object]" and never the rejected `input`.
detail = errorDetailText(body.detail, detail);
} catch { /* non-JSON error body */ }
throw new Error(detail);
// Phase 164 (D3, locked A3/A4): a pre-stream HTTP rejection gets
// its own honest bubble (the server ANSWERED — it did not drop),
// and the phase-114 hint channel: the banner reassures that only
// this send died. runTurn's catch threads bubbleText into
// finalizeFailedTurn; hint rides setUiState's opts unchanged.
const err = new Error(detail);
err.bubbleText = FAILED_REQUEST_TEXT;
err.hint =
"The app answered — it rejected the request before the model ran. Nothing was lost but this send.";
throw err;
}
await readSSE(res, (ev) => {
if (aborted) return;
@@ -4350,7 +4425,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// Phase 114 (TODO L6): carry the frame's optional hint (the
// "question too long" frame has one) through the throw — the
// banner shows it in place of the default reachability hint.
const err = new Error(ev.detail || "Something went wrong on my side.");
const err = new Error(errorDetailText(ev.detail, "Something went wrong on my side."));
err.hint = ev.hint;
throw err;
}
@@ -4482,6 +4557,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
wrap,
leavePartialIndex,
// Phase 164 (D3): the pre-stream HTTP rejection carries its own
// honest bubble line (FAILED_REQUEST_TEXT); every other throw
// (transport, SSE error frame) leaves it undefined → the
// zero-frame settle keeps FAILED_TURN_TEXT byte-identically.
bubbleText: err?.bubbleText,
});
}
// Phase 114 (TODO L6): the SSE error frame's optional hint flows to