From 74eb2e265c49e150b371f78da1bc9cfb7bdcfb15 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Wed, 7 Oct 2026 09:57:40 -0400 Subject: [PATCH] docs(phases): add phase 164 (honest pre-stream failure line, shared error-detail serializer, history caps for full-length answers) Captures the 2026-10-07 live '[object Object]' finding: a >32k-char answer rides back as history, the 422's list-of-objects detail hits the unguarded body.detail assignment, and the zero-frame settle lies about a dropped connection. Caps raise as a chain (HistoryTurn + ChatMessage mirror per phase-83 SEC-05) with a bounded anonymous total; the budget trim stays locked and untouched. LLM-Generated: true --- .../todo/164_chat_error_honesty/00_phase.md | 66 +++++++++++++++++++ .../01_caps_long_answer_chain.md | 30 +++++++++ .../02_error_detail_serializer.md | 35 ++++++++++ .../03_prestream_failure_copy.md | 28 ++++++++ .../04_e2e_error_honesty.md | 30 +++++++++ 5 files changed, 189 insertions(+) create mode 100644 .agents/phases/todo/164_chat_error_honesty/00_phase.md create mode 100644 .agents/phases/todo/164_chat_error_honesty/01_caps_long_answer_chain.md create mode 100644 .agents/phases/todo/164_chat_error_honesty/02_error_detail_serializer.md create mode 100644 .agents/phases/todo/164_chat_error_honesty/03_prestream_failure_copy.md create mode 100644 .agents/phases/todo/164_chat_error_honesty/04_e2e_error_honesty.md diff --git a/.agents/phases/todo/164_chat_error_honesty/00_phase.md b/.agents/phases/todo/164_chat_error_honesty/00_phase.md new file mode 100644 index 0000000..3026c40 --- /dev/null +++ b/.agents/phases/todo/164_chat_error_honesty/00_phase.md @@ -0,0 +1,66 @@ +# Phase 164 — Chat failure honesty: the shared error-detail serializer, a distinct pre-stream failure line, and caps that accept a full-length answer + +**Source:** owner live report, 2026-10-07 — brain.reeseapps.com, right after the live answer cap was raised to `BOR_LLM_MAX_OUTPUT_TOKENS=131072`: when an answer hit the length limit, the next turn's bubble read *"My answer didn't make it — the connection dropped. Use Retry to ask again. Failed [object Object]."* The failure chain (traced in code, 2026-10-07): a long answer (> 32 000 chars) lives in `bor.chat.v1` and rides back to the server as `history` on the next `POST /api/chat`; `HistoryTurn.text` (`max_length=32_000`) rejects it with a **422 whose `detail` is a LIST OF OBJECTS** (`[{loc, msg, type}, …]`); the chat's pre-stream error path (`frontend/assets/app.js` ~3956: `if (body.detail) detail = body.detail`) has **no type guard** — `new Error()` stringifies to `"[object Object]"`, which the zero-frame settle shows as the fixed `FAILED_TURN_TEXT` ("the connection dropped" — untrue: the server WAS reachable and answered 422) plus the garbage detail. This is the SECOND occurrence of the same class: `HistoryTurn`'s own docstring records the 4 000 → 32 000 raise for an identical 422 (phase-42 E2E); the 32 000 ceiling broke again the moment outputs could exceed it — **at the DEFAULT `max_output_tokens=32_768` (~130k chars), not only at 131072**. + +**Story:** n/a (owner-directed bug fix; extends the phase-120 failed-turn contract, the phase-114 hint channel, the phase-83 SEC-05 boundary caps, the phase-74 history budgets). + +## Objective +A failed turn states **what actually happened**: pre-stream HTTP rejections get their own honest line (not "the connection dropped") with the validation message serialized verbatim — never `[object Object]` — and a full-length answer (up to a 131 072-token cap ≈ 524k chars) flows through the whole chain: accepted as `history` on the next turn, and saveable/restorable as part of a saved chat. The anonymous saved-chat surface keeps a bounded worst case (the phase-83 SEC-05 property survives the raise). + +## Dependencies +- `83_chat_save_payload_limits` (complete) — the SEC-05 caps this phase raises TOGETHER (`ChatMessage.text`/`thinking` mirror `HistoryTurn`; the mirror invariant must stay true). +- `74`-line history budgets (complete; `history_to_messages`, `history_max_turns=40` / `history_max_chars=24_000`) — the LOCKED budget trim (owner 2026-09-08: overflow turns dropped WHOLE, never cut) — **untouched**; it is the real prompt-side gate and stays so. +- `120_failed_turn_persistence` (complete; the `FAILED_TURN_TEXT` / `finalizeFailedTurn` / `.failed-note` contract) and `114` hint channel (`err.hint` → banner) — the bubble/note mechanics this phase extends. + +## Design (shared by all tasks — the executor reads this, not the chat) + +**D1 — the caps chain up, together (task 01, LOCKED A1).** `app/schemas.py`: +- `HistoryTurn.text` / `HistoryTurn.thinking`: `max_length=600_000` (rationale IN the docstring: a full-cap answer at the owner's `BOR_LLM_MAX_OUTPUT_TOKENS` up to 131 072 ≈ 524 300 chars at ~4 chars/token — 600 000 is that with margin; the budget trim (locked A3, 2026-09-08) remains the real prompt gate — a turn over `history_max_chars` is dropped WHOLE, so a long answer trims itself out of later prompts; this phase fixes the 422, NOT the trim (its locked semantics are a separate owner decision, recorded in the overview)); +- `ChatRequest.history` list: `max_length=100` → `500` (the same "DoS sanity ceiling only" docstring, now also noting the client sends the whole `bor.chat.v1` minus the current question); +- `ChatMessage.text` / `ChatMessage.thinking`: `max_length=600_000` (MIRRORS `HistoryTurn` — the phase-83 invariant "a saved chat can never legitimately carry more than the chat path accepts" must stay TRUE: raise both or neither); +- `SavedChatCreate.messages` / `SavedChatUpdate.messages`: `max_length=200` → `500` (symmetry with the history list) PLUS a new `model_validator` on both: **total chars across all messages (`len(text) + len(thinking or "")`) ≤ 8_000_000** — this bounds the ANONYMOUS SEC-05 surface at the same order as the audited pre-phase worst case (200 × 64k ≈ 12.8 MB) while fitting ~15 full-cap answers; the validator error surfaces as the standard 422 (`ValueError` message names the budget); +- `ChatRequest.message`: `max_length=4000` UNCHANGED (the composer textarea carries the cap client-side — pin it). + +**D2 — the shared `errorDetailText` serializer (task 02, LOCKED A2).** `frontend/assets/app.js` exports `errorDetailText(value, fallback)` (exported const/function, the `TURN_TIMEOUT_MS` unit-pin idiom): a non-empty **string** → verbatim; an **array** → per-entry `entry.msg` when the entry is an object else `String(entry)`, empties dropped, `"; "`-joined, all-empty → `fallback`; an **object** → `String(entry.msg)` when present, else `fallback`; nullish / empty / anything else → `fallback`. NEVER `JSON.stringify` a raw detail (the FastAPI 422 entries carry the FULL `input` — the whole conversation — which must never reach the DOM; only `msg` and plain strings serialize). Applied at the TWO unguarded chat paths: the pre-stream `!res.ok` handler (~3956 — `detail = errorDetailText(body.detail, \`Brain's API answered with HTTP ${res.status}.\`)`) and the SSE `error`-frame throw (~4353 — `new Error(errorDetailText(ev.detail, "Something went wrong on my side."))`, the `err.hint` line untouched). The seven EXISTING guarded copies (`app.js` tuning form, `tuning.js`, `theme.js`, `document.js`, `git-sources.js`, `doc-edit.js`) are behaviorally correct and stay byte-identical (no opportunistic refactor — the new helper matches their semantics for the shared shapes, generalized to arrays-of-strings and multi-entry joins). + +**D3 — the distinct pre-stream failure line (task 03, LOCKED A3/A4).** `app.js`: +- `const FAILED_REQUEST_TEXT = "I couldn't take that — my API rejected the request. Use Retry to ask again.";` (sibling of `FAILED_TURN_TEXT`, its docstring: the PRE-STREAM HTTP-rejection settle — the server answered, the request did not pass; the real detail rides the `.failed-note` + persisted `error` key exactly as `FAILED_TURN_TEXT` does); +- the pre-stream `!res.ok` throw carries `err.bubbleText = FAILED_REQUEST_TEXT` and `err.hint = "The app answered — it rejected the request before the model ran. Nothing was lost but this send."` (the phase-114 banner-hint channel); +- `runTurn`'s catch passes `bubbleText: err?.bubbleText` into `finalizeFailedTurn` (new options key); the zero-frame settle uses `bubbleText || FAILED_TURN_TEXT` — the persisted record text stays "what the user saw is what is stored"; +- UNCHANGED settles: a transport-level failure (the `fetch` itself rejects — `TypeError`) has no `bubbleText` → keeps `FAILED_TURN_TEXT` (that one IS the connection); the SSE `error`-frame zero-frame path (a model-side failure) keeps `FAILED_TURN_TEXT`; the stop / abort paths are untouched. A partial-stream settle (`wrap` + `acc`) never shows a bubble text at all — unchanged. + +**D4 — verification (task 04).** Dedicated Playwright suite `tests/e2e/test_chat_error_honesty.py` (isolation; deterministic mock LLM) with (a) a `page.route` stub of `POST /api/chat` returning the EXACT FastAPI 422 shape for a long-history turn → assert the bubble reads `FAILED_REQUEST_TEXT`, the `.failed-note` detail contains the pydantic `msg` verbatim ("String should have at most 600000 characters" — never `[object Object]`, never `input` text), the banner hint shows, Retry + Edit land, composer re-enables (§7.4); (b) a 500 stub with a non-JSON body → the `HTTP 500` default string line (the serializer's fallback path); (c) a real-server acceptance: send a turn whose history carries a ~525k-char prior brain answer → the request is ACCEPTED (no 422) and the turn settles normally with the mock (the budget trim may drop the giant turn WHOLE from the prompt — the locked A3 behavior; the test asserts acceptance + settle, not prompt content); (d) a stubbed SSE `error` frame carrying an ARRAY detail → the note serializes (client tolerance). Server-side acceptance/422 matrix for every raised cap + the total-chars validator rides integration pins in task 01 (the `test_chats_api.py` pattern). + +**Invariants (all tasks).** The budget trim (`history_to_messages`, budgets, whole-drop, contiguous window) is byte-identical (locked A3 2026-09-08). `ChatRequest.message` 4000. The `FAILED_TURN_TEXT` wording, the phase-120 persistence keys (`failed`, `error` ≤500), Retry/Edit pairing, the SSE frame shapes, and the seven guarded frontend detail-readers are byte-identical. `PLAN.md` untouched. + +## Tasks +1. `01_caps_long_answer_chain.md` — the `schemas.py` raise + the total-chars validator + docstrings + the unit/integration acceptance-rejection matrix (D1). +2. `02_error_detail_serializer.md` — the exported `errorDetailText` + the two chat call sites + unit pins (D2). +3. `03_prestream_failure_copy.md` — `FAILED_REQUEST_TEXT` + the `bubbleText` threading + the banner hint + the failed-turn pins (D3). +4. `04_e2e_error_honesty.md` — the dedicated E2E (D4) + the final gates + the docs consistency pass (incl. the proxy body-size deploy note). + +## Testing & Quality +- Unit: `tests/unit/test_schemas.py` (or the file pinning `HistoryTurn`/`ChatMessage` bounds — locate via `32_000` in `tests/`): every cap's boundary (600_000 accept / +1 reject; 500 vs 501 turns; the total-8M validator: accept ~15 full-cap messages, reject beyond), the 422 `detail` shapes; `tests/unit/test_frontend_error_detail.py` (new; the `test_frontend_*` house pattern): all serializer shapes incl. the exact live FastAPI 422 body; `tests/unit/test_frontend_failed_turn.py` extended: the `bubbleText` threading (pre-stream vs transport vs SSE-frame settles), the never-`innerHTML` rule. +- Integration: `tests/integration/test_chats_api.py` extended — a saved chat carrying ONE 525k-char answer saves 201 (the phase-83 surface stays bounded by the total validator), over-total rejects 422. +- E2E: `tests/e2e/test_chat_error_honesty.py` green **in isolation**; the phase-120 failed-turn E2E stays green (the `FAILED_TURN_TEXT` path unchanged for transport failures). +- Coverage **>90%** on `app/`; `uv run ruff check . && uv run pyright` clean. + +## Completion Criteria +- [ ] A pre-stream 422 shows the distinct honest line + the validation `msg` verbatim in the note — `[object Object]` is structurally impossible on the chat path (unit + E2E pinned; no raw `input` reaches the DOM). +- [ ] A history turn with a 525k-char brain answer is ACCEPTED (200 turn settles); >600k rejects 422; the saved-chat chain accepts the same answer inside a 201 and the anonymous surface stays bounded (total-8M validator pinned). +- [ ] Transport failures and SSE error frames keep `FAILED_TURN_TEXT` verbatim; stop/abort paths byte-identical; §7.4 failure feedback re-enables every control. +- [ ] The budget trim, `message` 4000, the seven guarded detail-readers, and all SSE shapes are byte-identical; full `uv run pytest` green; coverage >90%; ruff + pyright clean. +- [ ] `README.md` + `AGENTS.md` reviewed in full in the final pass — includes the deploy note: the proxy `client_max_body_size` must accommodate the now-legal large chat POSTs (recommend ≥ 16 MB) — and every other inconsistency fixed. +- [ ] One `--no-gpg-sign` commit; the phase dir moves to `complete/` by the pipeline gate. + +## Locked decisions +- **A1 — the caps chain together**: `HistoryTurn.text/thinking` AND `ChatMessage.text/thinking` → 600_000 (≈ 131 072 × 4 chars with margin); history list → 500; saved-messages list → 500 with a total-chars ≤ 8_000_000 model validator (the SEC-05 bounded-anonymous property preserved at the audited order); `message` 4000 unchanged; the phase-83 mirror invariant explicitly honored (owner-confirmed 2026-10-07 — "static generous caps", with the SEC-05 coupling added by this phase's audit of the chain — raise ONE side would break saves). +- **A2 — `errorDetailText`**: the shared serializer's exact semantics (string verbatim; array entries `msg`-else-String joined `"; "`; object `msg`-else-fallback; never `JSON.stringify`; never the `input`), exported from `app.js`, applied at the two unguarded chat paths ONLY; the seven guarded copies untouched (owner-confirmed 2026-10-07). +- **A3 — `FAILED_REQUEST_TEXT`**: distinct pre-stream HTTP-rejection bubble text verbatim; the persisted record is what the user saw (the phase-120 contract extended, not changed); transport failures keep `FAILED_TURN_TEXT` (owner-confirmed 2026-10-07 — "distinct pre-stream line"). +- **A4 — the budget trim is NOT touched**: the locked 2026-09-08 whole-drop contiguous-window semantics stand; the consequence — a >24k-char answer is dropped whole from later prompts (follow-ups WORK with a trimmed window, without that answer's context) — is recorded here; making long answers useful in later prompts (bigger `BOR_HISTORY_MAX_CHARS` is pure env, or a smarter cut) is an owner call outside this phase. +- **A5 — PLAN.md untouched** (the caps rationale lives in the schema docstrings; folded at the next redo). + +## Commit +```bash +git add app/ frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(chat): honest pre-stream failure line, a shared error-detail serializer, and history caps that accept a full-length answer" +``` diff --git a/.agents/phases/todo/164_chat_error_honesty/01_caps_long_answer_chain.md b/.agents/phases/todo/164_chat_error_honesty/01_caps_long_answer_chain.md new file mode 100644 index 0000000..ac63901 --- /dev/null +++ b/.agents/phases/todo/164_chat_error_honesty/01_caps_long_answer_chain.md @@ -0,0 +1,30 @@ +# Task 01 — The caps chain up: 600k text, 500 turns, a bounded anonymous total (phase-164 D1, LOCKED A1/A4) + +**Phase:** `164_chat_error_honesty` · **Source:** phase overview D1 — the live 422: a >32k-char answer rode back as `history` and rejected the NEXT turn. `HistoryTurn`'s docstring already records this failure class happening once (4 000 → 32 000, phase-42 E2E); the ceiling broke again the moment outputs could exceed 32k — at the DEFAULT `max_output_tokens=32_768` too. +**Story:** n/a + +## Objective +Every schema cap in the long-answer chain (`HistoryTurn` → `ChatRequest.history` → `ChatMessage` → saved-chat lists) accepts a full-cap answer (~524k chars at `max_output_tokens=131072`), the phase-83 mirror invariant between the chat path and the saved-chat path stays TRUE, and the anonymous saved-chat worst case stays bounded by a new total-chars validator. + +## Work +1. `app/schemas.py` — `HistoryTurn`: `text`/`thinking` `max_length=32_000`/`32000` → **600_000** both; docstring: the cap accepts a full-length answer (`BOR_LLM_MAX_OUTPUT_TOKENS` up to 131 072 ≈ 524 300 chars at ~4 chars/token, plus margin — the 2026-10-07 live 422: the 32 000 ceiling rejected the FOLLOW-UP turn of a long answer, the same class as the recorded 4 000 → 32 000 raise); the real gate stays the locked budget trim (`history_to_messages` — overflow turns dropped WHOLE; a long answer trims itself out of later prompts, never a 422). +2. `app/schemas.py` — `ChatRequest.history`: `max_length=100` → **500**; the docstring sentence updates: still "a DoS sanity ceiling only — the config budgets do the real trimming (A3)", plus: the client sends the whole `bor.chat.v1` minus the current question, so the ceiling must clear long conversations. +3. `app/schemas.py` — `ChatMessage.text`/`thinking`: → **600_000** (docstring: the phase-83 SEC-05 mirror RAISED with `HistoryTurn` — the invariant "a saved chat can never legitimately carry more than the chat path accepts" survives the raise; raise ONE side and every done-time auto-save of a long answer 422s into the phase-113 quiet-failure). +4. `app/schemas.py` — `SavedChatCreate.messages` / `SavedChatUpdate.messages`: `max_length=200` → **500** AND a `model_validator(mode="after")` on each: total chars `sum(len(m.text) + len(m.thinking or "") for m in messages) <= 8_000_000` else `raise ValueError("saved chat exceeds the 8,000,000-character budget")` — docstring: bounds the ANONYMOUS SEC-05 worst case at the audited order (the pre-phase 200×64k ≈ 12.8 MB) while fitting ~15 full-cap answers; the 422 is the framework's standard validation response (the phase-83 A2 boundary-only style — NO route changes). +5. `ChatRequest.message`: **unchanged** `max_length=4000` (the composer textarea caps client-side — `app.js` ~1122 comment; pin it stays). +6. Unit — extend the file pinning these bounds (locate via `32_000` under `tests/unit/`): + - `HistoryTurn.text` 600_000 accept / 600_001 reject; same for `thinking`; + - `history` 500 accept / 501 reject; `message` 4000 boundary re-pinned unchanged; + - `ChatMessage.text/thinking` 600_000 accept / +1 reject; + - messages 500 accept; the total validator: ~15 × 524k messages REJECT with the budget-naming `ValueError` surfaced in the 422 `detail`, a saved chat with ONE 525k answer + normal turns ACCEPTS; + - the 422 `detail` SHAPE of a cap rejection is the pydantic list-of-objects (the serializer's task-02 input — pinned here as the server-side half of the contract). +7. Integration — `tests/integration/test_chats_api.py`: `POST /api/chats` with a one-525k-answer conversation → 201 (round-trips through JSONB — the DB is JSONB, no column changes needed — verify); over-total → 422; the existing phase-83 boundary matrix green (re-cut only the pins that assert the literal `32_000`/`200` values, naming each in the task's completion). + +## Testing & Quality +- Unit + integration above; coverage **>90%** on `app/`. + +## Completion Criteria +- [ ] The full-cap-answer conversation flows: accepted as `/api/chat` history (integration-level) AND saveable as a saved chat (201), with the 8M total cap the only new anonymous bound. +- [ ] `ChatMessage` mirrors `HistoryTurn` exactly (the phase-83 invariant re-stated in both docstrings, verified by identical-boundary tests). +- [ ] The budget-trim suites (`history_to_messages`) are untouched and green — D1 changes what ARRIVES, never what the trim keeps. +- [ ] `uv run pytest tests/unit tests/integration -v` green; full suite green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/todo/164_chat_error_honesty/02_error_detail_serializer.md b/.agents/phases/todo/164_chat_error_honesty/02_error_detail_serializer.md new file mode 100644 index 0000000..81e5b67 --- /dev/null +++ b/.agents/phases/todo/164_chat_error_honesty/02_error_detail_serializer.md @@ -0,0 +1,35 @@ +# Task 02 — The shared `errorDetailText` serializer for the two chat paths (phase-164 D2, LOCKED A2) + +**Phase:** `164_chat_error_honesty` · **Source:** phase overview D2 — the live "[object Object]": `frontend/assets/app.js` ~3956 does `if (body.detail) detail = body.detail` on a FastAPI 422 whose `detail` is a LIST OF OBJECTS; `new Error()` → message `"[object Object]"`. The SSE `error`-frame path (~4353 `new Error(ev.detail || …)`) is the second unguarded reader. The guarded idiom ALREADY exists in seven copies (`tuning.js`, `theme.js`, `document.js`, `git-sources.js`, `doc-edit.js`, `app.js`'s tuning form) — this task generalizes it ONCE and applies it ONLY where it is missing. +**Story:** n/a + +## Objective +`app.js` exports `errorDetailText(value, fallback)` with exact, total semantics (every input shape yields a human string or the fallback — `[object Object]` and raw `input` leakage are structurally impossible), wired at the pre-stream HTTP-error handler and the SSE `error`-frame throw; the seven existing guarded copies stay byte-identical. + +## Work +1. `frontend/assets/app.js` — new exported function (the `THINKING_NEAR_BOTTOM_PX` / `TURN_TIMEOUT_MS` export-for-unit-pin idiom; place beside the other error constants): + ``` + errorDetailText(value, fallback) → string + ``` + - non-empty **string** → the string verbatim; + - **array** → per entry: `String(entry.msg)` when the entry is an object with a non-empty `msg`, else (non-object entry) `String(entry)`; entries with neither drop; join with `"; "`; empty result → `fallback`; + - **object** → `String(value.msg)` when a non-empty `msg` exists, else `fallback`; + - nullish / empty string / any other type → `fallback`; + - NEVER `JSON.stringify` anything, NEVER touch `input`/`loc`/`ctx` (the FastAPI entries carry the full rejected input — the whole conversation — which must never reach the DOM). Docstring names the live 2026-10-07 `[object Object]` failure and the `input`-leak rule. +2. `frontend/assets/app.js` — the pre-stream handler (~3956, the `!res.ok || !res.body` block): replace the unguarded assignment with `const text = errorDetailText(body.detail, \`Brain's API answered with HTTP ${res.status}.\`); throw new Error(text);` (the `try`-around-`res.json` and the non-JSON catch keep their current shape; the default per-status string remains the fallback). The `bubbleText`/hint threading is task 03 — do NOT add it here. +3. `frontend/assets/app.js` — the SSE `error` frame (~4353): `const err = new Error(errorDetailText(ev.detail, "Something went wrong on my side."));` — the `err.hint = ev.hint;` line byte-identical. A string `detail` (every server-built frame, `ChatErrorEvent`) serializes VERBATIM — pin that the user-visible strings for "Question too long", "dropped the connection" etc. are unchanged. +4. `tests/unit/test_frontend_error_detail.py` (new — read `tests/unit/test_frontend_failed_turn.py` first and reuse its harness/assertion style): + - the EXACT live 422 body (`{"detail":[{"type":"string_too_long","loc":["body","history",5,"text"],"msg":"String should have at most 600000 characters","input":""}]}`) → the `msg` verbatim, and the result contains NO `input` text and no `"[object Object]"`; + - multi-entry array → the `msg`s joined `"; "`; mixed entries (object + plain string) → joined; all-empty array → fallback; + - object with `msg` → verbatim; object without → fallback; + - string → verbatim (incl. the server's fixed copy — pinned); `""`/null/undefined/number → fallback; + - source-contract pins: the two call sites now read through `errorDetailText`; `JSON.stringify` appears NOWHERE in the new helper; the seven guarded copies are byte-identical (hash their regions or assert the untouched lines). + +## Testing & Quality +- Unit: the shape matrix above (the harness the `test_frontend_*` files use); no `app/` change — coverage gate stays green. + +## Completion Criteria +- [ ] Every serialized shape yields a human string or the named fallback — `[object Object]` is unrepresentable at the two chat call sites (unit-proven). +- [ ] A 422 `detail` never puts request `input` text into the DOM (pinned); server string frames render byte-identical copy. +- [ ] The seven existing guarded readers are untouched; the `err.hint` channel is untouched. +- [ ] Full `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/todo/164_chat_error_honesty/03_prestream_failure_copy.md b/.agents/phases/todo/164_chat_error_honesty/03_prestream_failure_copy.md new file mode 100644 index 0000000..075e5ca --- /dev/null +++ b/.agents/phases/todo/164_chat_error_honesty/03_prestream_failure_copy.md @@ -0,0 +1,28 @@ +# Task 03 — The distinct pre-stream failure line: `FAILED_REQUEST_TEXT` (phase-164 D3, LOCKED A3) + +**Phase:** `164_chat_error_honesty` · **Source:** phase overview D3 — the live bubble said *"My answer didn't make it — the connection dropped"* for a **422**: the server was reachable and answered deliberately; the copy lied, and the owner-confirmed remedy is a distinct honest line for pre-stream HTTP rejections. +**Story:** n/a + +## Objective +A pre-stream HTTP rejection (the `!res.ok` settle) bubbles `FAILED_REQUEST_TEXT` — "I couldn't take that — my API rejected the request. Use Retry to ask again." — with the serialized detail on the `.failed-note` and the phase-114 hint on the banner; transport failures (the `fetch` itself rejects) and SSE `error` frames keep `FAILED_TURN_TEXT` byte-identically; the phase-120 persistence contract ("what the user saw is what is stored") extends to the new text. + +## Work +1. `frontend/assets/app.js` — new constant beside `FAILED_TURN_TEXT`: `const FAILED_REQUEST_TEXT = "I couldn't take that — my API rejected the request. Use Retry to ask again.";` docstring: the PRE-STREAM HTTP-rejection settle (the server ANSWERED with a 4xx/5xx — the request died before the model; the 2026-10-07 live 422-after-a-long-answer is the finding); the real detail rides the in-bubble `.failed-note` + the persisted `error` key exactly as `FAILED_TURN_TEXT`'s does; NOT for transport failures (no answer happened at all — `FAILED_TURN_TEXT` is honest there). +2. `frontend/assets/app.js` — the pre-stream throw (task 02's site): attach `err.bubbleText = FAILED_REQUEST_TEXT;` and `err.hint = "The app answered — it rejected the request before the model ran. Nothing was lost but this send.";` to the thrown `Error` (the phase-114 `hint` channel already flows from `err.hint` to `showErrorBanner` via the catch's `setUiState` opts — verify, do not duplicate). +3. `frontend/assets/app.js` — `finalizeFailedTurn(detail, { acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText })`: the ZERO-frame branch (`else` — no `wrap`) renders `bubbleText || FAILED_TURN_TEXT` (the `addMessage` text AND the `rememberBrainTurn` record text — same value, the stored-what-you-saw contract) and the note/persistence/Retry/Edit machinery is otherwise untouched (partial-stream settles never receive a `bubbleText` by construction — a pre-stream throw has no `wrap`; still, the partial branch must ignore the key: read it ONLY in the zero-frame branch). Docstring update (the `bubbleText` key, its caller-only meaning). +4. `frontend/assets/app.js` — `runTurn`'s catch: pass `bubbleText: err?.bubbleText` into the `finalizeFailedTurn` options (the ONE line-level change; the detail/hint/banner sequencing after the call is untouched). +5. `tests/unit/test_frontend_failed_turn.py` (extend; the file's harness): + - a thrown `Error` with `bubbleText` → the zero-frame bubble + persisted record text are `FAILED_REQUEST_TEXT`, note = the detail, `failed: true` + the `error` key unchanged in shape (≤500 cap intact); + - a thrown `Error` WITHOUT `bubbleText` (the transport-failure shape) → `FAILED_TURN_TEXT` byte-identical (the existing pins re-run green); + - the SSE-frame throw (task 02's site) sets NO `bubbleText` → model-side failures keep `FAILED_TURN_TEXT`; + - the banner: `err.hint` reaches the opts the same way for both throw kinds (the phase-114 pin re-runs); + - `FAILED_REQUEST_TEXT`'s exact string pinned ONCE (the copy is owner-locked); the never-`innerHTML` rule holds. + +## Testing & Quality +- Unit: the pins above; the phase-120 suite green WITHOUT modification (the default path byte-identical); coverage gate unchanged (no `app/` code). + +## Completion Criteria +- [ ] Pre-stream HTTP error → bubble `FAILED_REQUEST_TEXT`, note carries the serialized detail (task 02's serializer), hint on the banner, Retry + Edit land, composer re-enables (§7.4). +- [ ] Transport failures and SSE error frames are byte-identical to the phase-120 behavior (`FAILED_TURN_TEXT`, same persistence). +- [ ] The persisted `bor.chat.v1` record for a new-line failure stores the new text + `failed: true` + the capped `error` — a refresh restores it identically (the same property the existing suite pins for `FAILED_TURN_TEXT`). +- [ ] Full `uv run pytest` green; `uv run ruff check . && uv run pyright` clean. diff --git a/.agents/phases/todo/164_chat_error_honesty/04_e2e_error_honesty.md b/.agents/phases/todo/164_chat_error_honesty/04_e2e_error_honesty.md new file mode 100644 index 0000000..1ad1fab --- /dev/null +++ b/.agents/phases/todo/164_chat_error_honesty/04_e2e_error_honesty.md @@ -0,0 +1,30 @@ +# Task 04 — The dedicated E2E: error honesty + long-answer acceptance; the final gates (phase-164 D4) + +**Phase:** `164_chat_error_honesty` · **Source:** phase overview D4 + the AGENTS.md per-phase E2E rule (A16). +**Story:** n/a + +**Pre-fix evidence (2026-10-07, recorded per house practice):** owner-live instance after raising `BOR_LLM_MAX_OUTPUT_TOKENS=131072` — the post-long-answer turn's bubble read *"My answer didn't make it — the connection dropped. Use Retry to ask again. Failed [object Object]."*; code trace: the 422's list-of-objects `detail` hit the unguarded `body.detail` assignment (`app.js` ~3956) → `new Error()` → `"[object Object]"` (the example was not preserved live — the cap raise removed the repro; the 32 000 ceiling repros it at DEFAULT settings with any >32k-char answer). `HistoryTurn`'s own docstring records the identical failure class once already (4 000 ceiling, phase-42 E2E). + +## Objective +One Playwright suite proves the phase end to end against the REAL client code paths — the distinct pre-stream line with the serialized validation message (never `[object Object]`, never `input` leakage), the fallback and tolerance paths, and the real-server acceptance of a full-length-answer history — and the final pass runs every gate plus the full documentation consistency review. + +## Work +1. `tests/e2e/test_chat_error_honesty.py` (new; isolation; deterministic mock LLM; read `tests/e2e/test_frontend_failed_turn*.py`-adjacent suites and any existing `page.route` usage first and reuse the established stubbing approach — if no route-stub precedent exists, intercept `POST /api/chat` with Playwright `page.route` fulfilling the fetch; the assertions must drive the real `app.js` send path): + - **assert 1 (the live bug)**: stub `POST /api/chat` → 422 with the EXACT FastAPI shape for a long-history rejection (`{"detail":[{"type":"string_too_long","loc":["body","history",5,"text"],"msg":"String should have at most 600000 characters","input":"x".repeat(1000)}]}`) → send a question: the bubble text is `FAILED_REQUEST_TEXT` verbatim; the `.failed-note` detail contains the `msg` substring; NO `[object Object]` anywhere in the DOM; the rejected `input` text is NOT in the DOM; the banner shows the hint; Retry + Edit are present; the composer is re-enabled (§7.4). + - **assert 2 (fallback)**: stub → 500 with a NON-JSON body → the note/line settle on the `HTTP 500` default string, `FAILED_REQUEST_TEXT` bubble (still pre-stream), no crash. + - **assert 3 (acceptance — the D1 fix)**: seed `localStorage` `bor.chat.v1` with a conversation whose LAST brain record carries a ~525k-char answer (the seeded-restore pattern the saved-chat suites use), reload, send a short question → the request is NOT 422 (assert the response status 200 via the route/network log) and the turn settles normally with the mock answer (the locked budget trim may drop the giant turn WHOLE from the prompt — asserting settle, never prompt content, per A4). + - **assert 4 (SSE-frame tolerance)**: stub → a valid SSE stream whose `error` frame carries an ARRAY `detail` → the note serializes the `msg` (client tolerance; server-built string frames are pinned in task 02 units). +2. Final pass (the phase pipeline runs this after the tasks): full-suite + gates: + - `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` **>90%**; + - `uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov` green **in isolation**; the phase-120/114 failed-turn + banner suites and `test_chats_api.py` green (re-cut pins named in the commit body); + - `uv run ruff check . && uv run pyright` clean. +3. Documentation & consistency (mandatory): review `README.md` and `AGENTS.md` IN FULL against the post-phase app — add the **deploy note**: *the proxy in front of the app must accept chat POSTs sized to the new caps (recommend `client_max_body_size ≥ 16m` — a follow-up turn after a full-cap answer posts ~0.5 MB+, a saved chat up to the 8 MB budget); a proxy with a smaller limit turns a legal request into a 413* — review the error-handling descriptions for accuracy (the failed-turn copy is documented where phase 120's bubbles are), and fix EVERY inconsistency found, including pre-existing ones not caused by this phase. `PLAN.md` untouched (A5). + +## Testing & Quality +- This task IS the E2E + gates task; its fixtures are test-side; `app/` coverage stays **>90%**. + +## Completion Criteria +- [ ] All four asserts green in isolation; assert 1 proves the exact live message renders honestly end to end (bubble + note + hint + controls). +- [ ] The seeded 525k-answer conversation sends, is accepted, and settles — the live "every follow-up dies after a long answer" failure mode is impossible after this phase. +- [ ] `uv run pytest` fully green; coverage >90%; ruff + pyright clean; no existing suite modified except re-cut pins named in the commit body. +- [ ] `README.md` + `AGENTS.md` reviewed in full, the proxy body-size deploy note added, every inconsistency fixed; the phase dir moves to `complete/` and the single `--no-gpg-sign` commit (overview) is made by the pipeline gate.