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:
+88
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user