phase: 120_failed_turn_retry
Build and Push Containers / build-and-push-app (push) Successful in 3m5s
Build and Push Containers / build-and-push-db (push) Failing after 2m13s

All verification complete. Final report:

**Phase 120 — Failed-turn retry: verification pass (all 3 tasks were done; final verification + 1 regression fix)**

**Verified:** `ChatMessage.failed`/`error` (≤500, `extra="forbid"` intact); `finalizeFailedTurn` funnel on the 3 failure paths (catch-else, stream-drop guard, zero-frame fallback) with `failed: true` + capped detail + `markLastRetryable`; `appendFailedNote` restore branch (Save-as-doc/Tune excluded); `showErrorBanner`/`retryLastTurn` byte-pinned untouched; only the three paths persist `failed: true` (grep + unit pin); no test asserts the old broken behavior.

**Defect found & fixed (rule 7):** a real navigate-away mid-turn let the browser's teardown fetch rejection (TypeError, not AbortError) leak into the failed funnel, persisting a phantom failed brain record — `test_sources_midstream_bug.py::test_no_orphan_brain_message_when_navigated_before_first_token` failed (2 `.msg` after reload) and violated the phase-20 navigate-away convention. Fixed: turn-scoped `leftThePage` flag (set unconditionally on `pagehide`, reset in `runTurn`) skips the funnel in the catch-else branch; pinned by new unit test `test_navigate_away_is_not_a_failed_turn`. No phase-overview/PLAN/todo/complete files touched; no commits made.

**Gates (exact):**
- `uv run pytest` → 2577 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 4271 stmts, 99% (>90%)
- `uv run pytest tests/e2e/test_failed_turn_retry.py -v --no-cov` → 4 passed (isolated)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- Regression E2E, isolated: `test_sources_midstream_bug.py` 6/6 (was 5/6); `test_llm_retry`/`test_tool_scaffolding_guardrails`/`test_stop_generation`/`test_navbar_refresh` 17/17

**Completion criteria:** (1) network error → banner + in-bubble Retry, re-ask without re-typing ✅ (E2E A); (2) refresh restores failed bubble + working Retry, no "new chat" ✅ (E2E C); (3) stopped/successful turns byte-identical ✅ (negative E2E, stop suite, byte-identity units); (4) pytest/coverage/lint/types ✅; (5) commit + phase move — left to the harness per pass rules.

**Notable:** deviation = the regression fix above (a navigation is not a failed turn; phase-20 partial-persist convention restored). Next pending phase: `121_git_source_tokens`.
This commit is contained in:
2026-09-24 18:50:36 -04:00
parent 0ff1f8c4d6
commit 3a0fc3db05
29 changed files with 2298 additions and 28 deletions
@@ -0,0 +1,18 @@
All verification complete. Final report:
**Phase 120 — Failed-turn retry: verification pass (all 3 tasks were done; final verification + 1 regression fix)**
**Verified:** `ChatMessage.failed`/`error` (≤500, `extra="forbid"` intact); `finalizeFailedTurn` funnel on the 3 failure paths (catch-else, stream-drop guard, zero-frame fallback) with `failed: true` + capped detail + `markLastRetryable`; `appendFailedNote` restore branch (Save-as-doc/Tune excluded); `showErrorBanner`/`retryLastTurn` byte-pinned untouched; only the three paths persist `failed: true` (grep + unit pin); no test asserts the old broken behavior.
**Defect found & fixed (rule 7):** a real navigate-away mid-turn let the browser's teardown fetch rejection (TypeError, not AbortError) leak into the failed funnel, persisting a phantom failed brain record — `test_sources_midstream_bug.py::test_no_orphan_brain_message_when_navigated_before_first_token` failed (2 `.msg` after reload) and violated the phase-20 navigate-away convention. Fixed: turn-scoped `leftThePage` flag (set unconditionally on `pagehide`, reset in `runTurn`) skips the funnel in the catch-else branch; pinned by new unit test `test_navigate_away_is_not_a_failed_turn`. No phase-overview/PLAN/todo/complete files touched; no commits made.
**Gates (exact):**
- `uv run pytest` → 2577 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 4271 stmts, 99% (>90%)
- `uv run pytest tests/e2e/test_failed_turn_retry.py -v --no-cov` → 4 passed (isolated)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- Regression E2E, isolated: `test_sources_midstream_bug.py` 6/6 (was 5/6); `test_llm_retry`/`test_tool_scaffolding_guardrails`/`test_stop_generation`/`test_navbar_refresh` 17/17
**Completion criteria:** (1) network error → banner + in-bubble Retry, re-ask without re-typing ✅ (E2E A); (2) refresh restores failed bubble + working Retry, no "new chat" ✅ (E2E C); (3) stopped/successful turns byte-identical ✅ (negative E2E, stop suite, byte-identity units); (4) pytest/coverage/lint/types ✅; (5) commit + phase move — left to the harness per pass rules.
**Notable:** deviation = the regression fix above (a navigation is not a failed turn; phase-20 partial-persist convention restored). Next pending phase: `121_git_source_tokens`.
@@ -0,0 +1,104 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 47%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 97%]
......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 156 1 99%
app/api/git_sources.py 232 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 231 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 347 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 219 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 242 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 332 0 100%
--------------------------------------------------
TOTAL 4271 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
**Task 01 complete — persist the failed turn: schema fields + live error paths**
Implemented:
- `app/schemas.py`: `ChatMessage.failed: bool|None` + `error: str|None (max_length=500)` beside `stopped`, docstring extended (phase-48 precedent, locked A1); `extra="forbid"` intact.
- `frontend/assets/app.js`: `FAILED_TURN_TEXT` constant; `appendFailedNote(wrap, detail)` (mirror of `appendStoppedNote`, detail via textContent); `finalizeFailedTurn()` funnel (partial → close block/calls + note + persist `failed:true`; zero-frames → FAILED_TURN_TEXT bubble; both set `lastBrainWrap` + `markLastRetryable()`, detail trimmed/capped 500). Wired into: error-catch `else` (before `setUiState`), stream-drop guard, and the zero-frame fallback (now `failed:true` + "The model answered with nothing." note).
- `frontend/assets/styles.css`: `.failed-note` — stopped-note family in `--err-ink` (~9:1 on `--surface`, wraps, non-interactive).
- `showErrorBanner`/`retryLastTurn`/`markLastRetryable`/stop/done paths byte-untouched (verified via diff); `failed: true` persisted at exactly the three failed paths (grep).
- Ripple fixes for the new response keys (exact-shape pins): `tests/unit/test_schemas.py` fixtures, `tests/integration/test_chats_api.py` (`_expect` + `FULL_BRAIN`), `tests/e2e/test_navbar_refresh.py` allowed key-set.
Results:
- `uv run pytest` → 2547 passed; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- Adjacent E2E sanity (isolated): `test_retry_answer.py` 4/4, `test_stop_generation.py` 3/3 — stopped/success turns unchanged.
Decisions: no test files authored (task 03 owns them, per the task file); schema verified via ad-hoc checks. No commit made (harness owns the atomic commit).
Next pending task: `.agents/phases/todo/120_failed_turn_retry/02_restore_failed_turn.md`.
@@ -0,0 +1,104 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 28%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 156 1 99%
app/api/git_sources.py 232 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 231 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 347 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 219 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 242 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 332 0 100%
--------------------------------------------------
TOTAL 4271 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,21 @@
All green. My task-02 hunks are cleanly scoped (lines 76, 85–109 of the diff); the rest are task 01's prior work already in the tree.
**Task 02 — Restore the failed turn: COMPLETE**
**Implemented** (`frontend/assets/app.js` → `renderStoredMessage`):
- Failed-branch restore: `m.failed` record renders its `text`, then `appendFailedNote(wrap, m.error)` — note omitted entirely when `m.error` is null (record's `text` already carries the detail)
- Save-as-doc gate extended: `if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);`
- Tune gate added: `if (!m.failed) appendTuneButton(wrap);` (a note, not an answer; stopped/successful records byte-identical — they keep Tune)
- No other restore change: existing `lastBrainWrap = wrap` + `markLastRetryable()` already land the Retry on the last restored brain bubble (the failed one when it ends the chat); `retryLastTurn` unchanged; mid-conversation failed bubble gets no Retry (last-bubble-only rule intact)
- Shared page: confirmed `shared.js` `renderSharedMessage` never reads `failed`/`error` → failed records render text-only, no note/Retry, no code change (per task)
- Updated `bor.chat.v1` record-shape comment (+ `failed?, error?`) and the phase-59 line-pin in `tests/unit/test_save_as_doc_button.py`
**Tests / lint / coverage**
- `uv run pytest` → 2547 passed
- `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings)
- `uv run pytest --cov=app` → TOTAL 99% (>90% gate; `schemas.py` 100%)
- `bash .agents/validate.sh` → validation OK
**Decisions:** the task's "No Tune button for failed records either" implemented as a `!m.failed`-only gate (the restore call site didn't previously gate Tune on `m.stopped`, so stopped behavior is untouched per the byte-identical criterion). Unit/E2E tests for this task are task 03's per the phase plan.
**Next pending task:** `.agents/phases/todo/120_failed_turn_retry/03_failed_turn_tests.md`
@@ -0,0 +1,104 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 28%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 156 1 99%
app/api/git_sources.py 232 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 231 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 347 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 219 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 242 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 332 0 100%
--------------------------------------------------
TOTAL 4271 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
All gates green. Final report:
**Phase 120 task 03 — Failed-turn tests: COMPLETE**
**Implemented**
- `tests/unit/test_chat_message_failed.py` (new, 10 tests): `failed`/`error` acceptance, 501-char `error` 422 (500 passes), unknown-key 422 (`extra="forbid"` intact), pre-phase records byte-identical on existing keys
- `tests/unit/test_frontend_failed_turn.py` (new, 14 tests): all three live failure paths route through `finalizeFailedTurn` (persist `failed: true`, end `markLastRetryable`), `appendFailedNote` dedup + textContent-only, `FAILED_TURN_TEXT` distinct from `EMPTY_ANSWER_FALLBACK`, `showErrorBanner` + `retryLastTurn` **byte-pinned**, restore branch (note + Save-as-doc/Tune exclusion), `.failed-note` CSS
- `tests/integration/test_chats_api.py` (extended, +4): failed record POST/GET/PUT round-trips byte-identical; `error`>500 → 422 (POST + PUT, nothing stored); shared chat with failed record serves unchanged public shape
- `tests/e2e/test_failed_turn_retry.py` (new, 4 tests) + two new mock injections (`fail first turn`, `partial then fail`): A zero-frame error → banner Retry + failed bubble → re-ask → grounded answer, bubble gone; B partial+error → text kept + note + in-bubble Retry → redo-in-place; C refresh restores failed bubble with working Retry; negative: stopped turn restores "Stopped" note, never a failed note
- Defect fixes: `test_llm_retry.py` (exhaustion) + `test_tool_scaffolding_guardrails.py` (terminal) asserted the old broken behavior (no bubble on error) — updated to the phase-120 contract
**Results**: `uv run pytest` 2576 passed · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% (>90%) · `uv run ruff check . && uv run pyright` clean · `tests/e2e/test_failed_turn_retry.py -v --no-cov` 4/4 in isolation (×2) · validate.sh OK
**Decisions**: mock fails exactly one turn (4 attempts = forced default budget) then answers, so Retry succeeds; B/C use grounded questions so re-asks assert `MOCK_ANSWER_MARKER`; E2E waits for the idle "Send" label before reading localStorage (done-frame settle — the marker is visible a few frames before `done`).
**Next pending**: none in phase 120 (all 3 tasks done); next phase dir: `todo/121_git_source_tokens`.
@@ -0,0 +1,104 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 47%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 226 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 156 1 99%
app/api/git_sources.py 232 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 231 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 347 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 219 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 102 0 100%
app/rag/retriever.py 242 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 332 0 100%
--------------------------------------------------
TOTAL 4271 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+19 -2
View File
@@ -744,8 +744,8 @@ class ChatMessage(BaseModel):
(phase 14) — the stored ``messages`` payload of a saved chat (phase 50). (phase 14) — the stored ``messages`` payload of a saved chat (phase 50).
``{who, text, sources?, related?, deflected?, suggestions?, ``{who, text, sources?, related?, deflected?, suggestions?,
thinking?, tools?, stopped?}`` — raw text, never HTML, so a saved thinking?, tools?, stopped?, failed?, error?}`` — raw text, never
chat restores pixel-identical through the existing HTML, so a saved chat restores pixel-identical through the existing
``renderStoredMessage`` path. ``renderStoredMessage`` path.
``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped ``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
@@ -774,6 +774,19 @@ class ChatMessage(BaseModel):
shape; ``suggestions`` ≤ 50 chips of ≤ 200 chars; ``tools`` shape; ``suggestions`` ≤ 50 chips of ≤ 200 chars; ``tools``
≤ 50 — one entry per tool call, the round cap is 10). Only value ≤ 50 — one entry per tool call, the round cap is 10). Only value
bounds were added — the accepted/rejected KEYS are unchanged. bounds were added — the accepted/rejected KEYS are unchanged.
Phase 120 (task 01, locked A1): a FAILED turn (network error, SSE
``error`` frame, stream drop — the client-side failure paths) stores
``{who: "brain", text: <detail or fallback>, failed: true,
error: <detail>}`` — the phase-48 ``stopped`` precedent: a marker +
the capped detail on the brain record itself, no separate error
table, no new API (``retryLastTurn``'s pop-the-last-brain-record
logic works on a failed record UNCHANGED — the question's user
record immediately precedes it). ``error`` is the persisted banner
detail, capped at 500 (the phase-83 value-bounds style). No
serializer change: ``None`` values flow as absent/None exactly like
``stopped`` today — the phase-50 byte-identical round-trip contract
covers the new keys automatically.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -787,6 +800,10 @@ class ChatMessage(BaseModel):
thinking: str | None = Field(default=None, max_length=32_000) thinking: str | None = Field(default=None, max_length=32_000)
tools: list[ToolCall] | None = Field(default=None, max_length=50) tools: list[ToolCall] | None = Field(default=None, max_length=50)
stopped: bool | None = None stopped: bool | None = None
# Phase 120 (task 01, locked A1): the failed-turn marker + the
# persisted error detail (the phase-48 ``stopped`` precedent).
failed: bool | None = None
error: str | None = Field(default=None, max_length=500)
class SavedChatCreate(BaseModel): class SavedChatCreate(BaseModel):
+198 -10
View File
@@ -364,6 +364,15 @@ const ERROR_HINT = "If this persists, check the LLM is reachable.";
exhausted max_tokens — phase 17) still renders a bubble, and this exact exhausted max_tokens — phase 17) still renders a bubble, and this exact
text is what gets persisted: what the user saw is what is stored. */ text is what gets persisted: what the user saw is what is stored. */
const EMPTY_ANSWER_FALLBACK = "Hmm — that came back empty. Ask me again?"; const EMPTY_ANSWER_FALLBACK = "Hmm — that came back empty. Ask me again?";
/* Phase 120 (TODO.md L3–4, locked A1): the fixed bubble text of a FAILED
turn that streamed ZERO frames (network error, pre-stream HTTP error) —
a short honest "my answer didn't make it" line. NOT the
EMPTY_ANSWER_FALLBACK: that constant is the zero-frame-but-COMPLETED
case's answer text (the stream settled; a failed turn didn't). The real
detail rides the in-bubble .failed-note (appendFailedNote) and the
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.";
/* Calm, don't remove: smooth scrolling is the one motion JS controls. */ /* Calm, don't remove: smooth scrolling is the one motion JS controls. */
const reducedMotion = const reducedMotion =
@@ -504,6 +513,48 @@ function appendStoppedNote(wrap) {
meta.appendChild(note); meta.appendChild(note);
} }
/* Phase 120 (TODO.md L3–4): the "Failed" note in the meta row of a failed
* brain bubble — the mirror of appendStoppedNote for the phase-120 failed
* turn (network error, SSE error frame, stream drop). The live failure
* paths (finalizeFailedTurn / the zero-frame fallback) and the phase-14
* restore path (a record with `m.failed`) share this helper, so a restored
* bubble reads exactly like the failed one. The banner keeps its
* role="alert" summary; this in-bubble note is the refresh-surviving copy
* (on restore it renders from the persisted `error` detail). Reuses the
* .msg-meta row the way appendStoppedNote does (role=list → the span joins
* as a listitem so ARIA stays valid); the triangle glyph is aria-hidden
* decoration — the "Failed" label + the detail text carry the accessible
* meaning (text + color, never color alone — B5). `detail` goes through
* textContent (no HTML from the error string, ever). */
const FAILED_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>';
function appendFailedNote(wrap, detail) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".failed-note")) return; // one per bubble
const note = document.createElement("span");
note.className = "failed-note";
if (meta.getAttribute("role") === "list") note.setAttribute("role", "listitem");
note.innerHTML = FAILED_ICON;
const label = document.createElement("span");
label.textContent = "Failed";
note.appendChild(label);
if (detail) {
const d = document.createElement("span");
d.className = "failed-detail";
d.textContent = detail; // the error string is NEVER innerHTML
note.appendChild(d);
}
meta.appendChild(note);
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the rendered wrap of /* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the rendered wrap of
* the CURRENT last brain record. Set wherever a brain bubble becomes the * the CURRENT last brain record. Set wherever a brain bubble becomes the
* latest persisted answer (the `done` branch, the empty-answer fallback, * latest persisted answer (the `done` branch, the empty-answer fallback,
@@ -1182,6 +1233,19 @@ let toolLineWrap = null; // the live wrap the clock suffixes (null when stopped)
let acc = ""; // accumulated answer text this turn let acc = ""; // accumulated answer text this turn
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn) let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
let persistedOnLeave = false; // pagehide partial-persist at most once let persistedOnLeave = false; // pagehide partial-persist at most once
/* Phase 120 verification (navigate-away regression): a pagehide has
* fired. A REAL departure tears the document down and the browser
* cancels the in-flight fetch — that teardown rejection (a TypeError,
* NOT an AbortError) must not be read as a FAILED turn by the error
* catch's else branch: the phase-20 convention is that a thinking-only
* navigate-away persists NOTHING brain-side (the question is already
* saved on send — the user can re-ask) and a partial navigate-away
* persists the pagehide partial, not a failed record. Set
* unconditionally on pagehide — a merely-hidden tab in some browsers
* also fires it, but there the stream keeps arriving (no rejection
* follows), so the flag stays inert — and reset per turn like the
* other turn locals. */
let leftThePage = false;
/* Phase 73 (task 02, TODO.md L3): the pagehide partial is CORRELATED with /* Phase 73 (task 02, TODO.md L3): the pagehide partial is CORRELATED with
* the turn's settle. `leavePartialIndex` is the index in `conversation` * the turn's settle. `leavePartialIndex` is the index in `conversation`
* of the brain record the `pagehide` handler pushed for THIS turn (else * of the brain record the `pagehide` handler pushed for THIS turn (else
@@ -1546,7 +1610,8 @@ function appendMaybeTry(wrap, suggestions) {
* bor.chat.v1 → { v: 1, chatId: string | null, * bor.chat.v1 → { v: 1, chatId: string | null,
* messages: [{ who: "user"|"brain", text, * messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions?, * sources?, deflected?, suggestions?,
* thinking?, tools?, stopped? }] } * thinking?, tools?, stopped?,
* failed?, error? }] }
* *
* Only RAW TEXT is stored — restore re-renders it through the escape-first * Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user * markdown renderer, so no HTML is ever persisted. Save points: the user
@@ -1680,12 +1745,28 @@ function renderStoredMessage(m) {
appendMaybeTry(wrap, m.suggestions); appendMaybeTry(wrap, m.suggestions);
} }
appendSources(wrap, m.sources); appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too // Phase 120: a failed turn (m.failed) is a note, not an answer — no
// Tune (the "note, not an answer" scope the m.stopped exclusion
// below shares; the live failure paths add no Tune either). Stopped
// partials keep their button — byte-identical to pre-phase-120.
if (!m.failed) appendTuneButton(wrap); // restored brain answers are tunable too
// Phase 59: the RAW persisted markdown (m.text — HTML is never // Phase 59: the RAW persisted markdown (m.text — HTML is never
// persisted). A stopped partial (m.stopped) is a note, not an answer // persisted). A stopped partial (m.stopped) or a failed turn
// — no button (the live stop path adds none either). // (m.failed) is a note, not an answer — no button (the live
if (!m.stopped) appendSaveAsDocButton(wrap, m.text); // stop/failure paths add none either).
if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
// Phase 120: the failed marker restores — the in-bubble error note
// re-renders from the persisted `error` detail (the note is absent
// when it is null: the record's `text` already carries the detail).
// No other restore change: the lastBrainWrap assignment below + the
// restore loop's markLastRetryable() already land the Retry button
// on the LAST restored brain bubble — the failed one when it ends
// the conversation — so the refreshed page is the same retryable
// state the live error was (retryLastTurn re-asks the question that
// precedes the failed record, unchanged; a mid-conversation failed
// bubble gets no button — the phase-49 last-bubble-only rule).
if (m.failed && m.error) appendFailedNote(wrap, m.error);
// Phase 113 (task 02): the related tier restores with the bubble // Phase 113 (task 02): the related tier restores with the bubble
// (LAST — after the meta-row claimers, exactly like the live done // (LAST — after the meta-row claimers, exactly like the live done
// path). Pre-phase records carry no `related` → appendRelated // path). Pre-phase records carry no `related` → appendRelated
@@ -2333,6 +2414,67 @@ async function handleSend(e) {
await runTurn(text, { reask: false }); await runTurn(text, { reask: false });
} }
/* Phase 120 (TODO.md L3–4, locked A1): the single funnel for every
* NON-STOP, NON-ABORT turn failure — the SSE error-frame catch's else
* branch (network error, pre-stream HTTP error, the `error` frame's
* throw) and the stream-drop guard (frames arrived, no `done`). A failed
* turn persists as a BRAIN record marked `failed: true` + the capped
* error detail (the phase-48 `stopped` precedent — no separate error
* table, no new API): the question's user record immediately precedes it,
* so retryLastTurn's pop-the-last-brain-record-then-re-ask logic works
* UNCHANGED, and lastBrainWrap is set BEFORE the caller's
* setUiState(UI_STATE.error, …) — so showErrorBanner's EXISTING
* `opts.retryable && lastBrainWrap` condition finally reveals the
* phase-111 banner Retry on a network error (showErrorBanner itself is
* byte-unchanged).
*
* Two shapes: a partial `wrap` exists (a frame streamed) — close the
* thinking block + tool calls (the stop-finalize pattern), keep the
* streamed answer text, add the in-bubble error note; or zero frames —
* a new FAILED_TURN_TEXT bubble. Either way the record rides
* rememberBrainTurn (localStorage + the phase-55 auto-save) and the
* in-bubble Retry button lands on the failed bubble (markLastRetryable).
* `detail` is trimmed + capped at 500 before persistence (the schema's
* ChatMessage.error bound is the backstop; the banner keeps the original
* 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). */
function finalizeFailedTurn(detail, { acc, thinking, tools, wrap, leavePartialIndex }) {
const error = (detail || "").trim().slice(0, 500);
if (wrap) {
// Partial streamed: keep the answer text the user saw, settle the
// block + calls closed (the stop-finalize pattern), mark it failed.
closeThinkingBlock(wrap);
closeToolCalls(wrap);
appendFailedNote(wrap, error);
rememberBrainTurn(
acc,
{
thinking: thinking || undefined,
tools: tools.length ? tools : undefined,
failed: true,
error: error || undefined,
},
leavePartialIndex
);
lastBrainWrap = wrap;
} 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);
appendFailedNote(fwrap, error);
rememberBrainTurn(
FAILED_TURN_TEXT,
{ failed: true, error: error || undefined },
leavePartialIndex
);
lastBrainWrap = fwrap;
}
markLastRetryable(); // the in-bubble Retry lands on the failed bubble
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn — /* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn —
* extracted from handleSend so the retry redo can re-run a question * extracted from handleSend so the retry redo can re-run a question
* without re-adding it. `reask` skips (a) the user-bubble append and * without re-adding it. `reask` skips (a) the user-bubble append and
@@ -2367,6 +2509,7 @@ async function runTurn(text, { reask = false } = {}) {
acc = ""; acc = "";
thinkingAcc = ""; thinkingAcc = "";
persistedOnLeave = false; persistedOnLeave = false;
leftThePage = false;
leavePartialIndex = -1; leavePartialIndex = -1;
// Phase 48: a fresh abort owner per turn (cleared in the finally); the // Phase 48: a fresh abort owner per turn (cleared in the finally); the
// stop flag resets with the rest of the turn locals. // stop flag resets with the rest of the turn locals.
@@ -2654,20 +2797,40 @@ async function runTurn(text, { reask = false } = {}) {
// idle with a half bubble. The zero-frame case falls through to the // idle with a half bubble. The zero-frame case falls through to the
// existing empty-answer fallback below. // existing empty-answer fallback below.
if (!sawDone && !aborted && (acc || thinkingAcc)) { if (!sawDone && !aborted && (acc || thinkingAcc)) {
setUiState( // Phase 120 (TODO.md L3–4): a half-answer is a FAILED answer — the
UI_STATE.error, // partial persists as failed (its text + the in-bubble error note +
"The stream ended before my answer finished — try again?" // a working Retry) BEFORE the error state, so Refresh restores what
); // the user saw + a Retry (no bare question, no "new chat").
const detail = "The stream ended before my answer finished — try again?";
finalizeFailedTurn(detail, {
acc,
thinking: thinkingAcc,
tools: toolAcc,
wrap,
leavePartialIndex,
});
setUiState(UI_STATE.error, detail);
} }
if (!aborted && !wrap) { if (!aborted && !wrap) {
const fallback = EMPTY_ANSWER_FALLBACK; const fallback = EMPTY_ANSWER_FALLBACK;
// Phase 120 (TODO.md L3–4, task 01 ASSUMPTION): the stream
// COMPLETED with ZERO frames — it is a failed turn too. The bubble
// text stays EMPTY_ANSWER_FALLBACK (a meaningful record text); the
// failed marker + error note make it persist + restore as a failure
// with a working Retry (the pre-phase record persisted with no
// marker — inconsistent with the refresh case this phase fixes).
const nothing = "The model answered with nothing.";
const fwrap = addMessage("brain", fallback); const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap); appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
appendFailedNote(fwrap, nothing);
// Phase 73: correlated like the other settles — unreachable when a // Phase 73: correlated like the other settles — unreachable when a
// pagehide partial exists (that needs acc, which means a wrap), but // pagehide partial exists (that needs acc, which means a wrap), but
// passed so EVERY settle write goes through the same correlation. // passed so EVERY settle write goes through the same correlation.
rememberBrainTurn(fallback, {}, leavePartialIndex); // persist what the user actually saw rememberBrainTurn(fallback, {
failed: true,
error: nothing,
}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap; lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too markLastRetryable(); // phase 49: the fallback bubble is retryable too
} }
@@ -2708,6 +2871,30 @@ async function runTurn(text, { reask = false } = {}) {
err instanceof Error && err.message err instanceof Error && err.message
? err.message ? err.message
: "Something went wrong on my side."; : "Something went wrong on my side.";
// Phase 120 (TODO.md L3–4, locked A1): persist the failed turn
// BEFORE the error state — the funnel sets lastBrainWrap, so the
// existing `opts.retryable && lastBrainWrap` condition in
// setUiState → showErrorBanner now reveals the phase-111 banner
// Retry on a network error (the button's precondition finally
// holds; showErrorBanner itself is byte-unchanged).
// Phase 120 verification (navigate-away regression): a REAL
// departure (pagehide fired) tears the document down and the
// browser cancels the in-flight fetch — that teardown rejection
// is NOT a failed turn. The phase-20 convention stands: a
// thinking-only navigate-away persists NOTHING brain-side, and a
// partial navigate-away persists the pagehide partial (above) as
// a plain record — never a failed one. A real network error
// while the page is still alive (no pagehide) takes the funnel
// as before.
if (!leftThePage) {
finalizeFailedTurn(detail, {
acc,
thinking: thinkingAcc,
tools: toolAcc,
wrap,
leavePartialIndex,
});
}
// Phase 114 (TODO L6): the SSE error frame's optional hint flows to // Phase 114 (TODO L6): the SSE error frame's optional hint flows to
// the banner (setUiState → showErrorBanner's opts.hint); the // the banner (setUiState → showErrorBanner's opts.hint); the
// phase-111 Retry button rides along on the same turn-error path. // phase-111 Retry button rides along on the same turn-error path.
@@ -2776,6 +2963,7 @@ staleRegenBtn?.addEventListener("click", regenerateStaleChat);
* C1). A REAL navigation (the page actually unloads) never runs a settle, * C1). A REAL navigation (the page actually unloads) never runs a settle,
* so the partial stays persisted exactly as before. */ * so the partial stays persisted exactly as before. */
window.addEventListener("pagehide", () => { window.addEventListener("pagehide", () => {
leftThePage = true; // the dying fetch's teardown rejection is not a failed turn
if (persistedOnLeave) return; if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming) if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
return; return;
+33
View File
@@ -898,6 +898,39 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
} }
.stopped-note svg { width: 10px; height: 10px; display: block; fill: currentColor; } .stopped-note svg { width: 10px; height: 10px; display: block; fill: currentColor; }
/* Phase 120 (TODO.md L3–4): the "Failed" note in a failed brain
bubble's meta row — the .stopped-note family in the theme's error
color (text + color, never color alone — B5: the "Failed" label
carries the meaning, the triangle glyph is aria-hidden decoration in
the JS). --err-ink on the --surface bubble ≈9.0:1 (the same value as
--brand-ink, computed 9.0:1 on --surface above) — well past 4.5:1
(WCAG 2.1 AA); the monochrome theme grays it automatically (token,
not literal — phase-92 zero-literal). Non-interactive — no hover, no
focus (pointer-events: none), the stopped note's way. The detail
span WRAPS (the stopped note's nowrap fits a one-word label; a 500-
char error detail must not blow out the 46rem chat column) —
overflow-wrap: anywhere keeps long unbroken details inside the row. */
.failed-note {
display: inline-flex;
align-items: flex-start;
gap: 0.3rem;
max-width: 100%;
color: var(--err-ink);
font-size: 0.75rem;
font-weight: 600;
pointer-events: none;
}
.failed-note svg {
width: 10px;
height: 10px;
margin-top: 0.15rem;
display: block;
flex: none;
fill: none;
stroke: currentColor;
}
.failed-detail { font-weight: 400; overflow-wrap: anywhere; }
/* Inline tuning form under the bubble: labeled textarea + Save/Cancel /* Inline tuning form under the bubble: labeled textarea + Save/Cancel
(both ≥44px). Save = brand button (dark ink 5.2:1), Cancel = ghost. */ (both ≥44px). Save = brand button (dark ink 5.2:1), Cancel = ghost. */
.tune-form { .tune-form {
+112
View File
@@ -521,6 +521,36 @@ test_llm_retry.py``). The mock is single-conversation per e2e server, so
strings are this suite's own folder names, so no other E2E can hit strings are this suite's own folder names, so no other E2E can hit
them (they seed different trees). them (they seed different trees).
Failure injection (phase 120, failed-turn retry, TODO.md L3–4) —
deterministic failures for the failed-turn E2E suite
(``tests/e2e/test_failed_turn_retry.py``), reusing the phase-67
counter machinery:
- user message containing ``fail first turn``
(``FAIL_FIRST_TURN_TRIGGER``): EVERY app-level attempt of the FIRST
turn responds 500 — the full forced-default budget (conftest pins
``BOR_LLM_RETRIES`` to the code default 3 → 4 app-level attempts =
12 POSTs, ``FAIL_FIRST_TURN_DEAD_ATTEMPTS`` ×
``_HTTPS_PER_DEAD_ATTEMPT``) — the retry-budget exhaustion with
ZERO frames (the terminal ``error`` before any delta) — and the
SECOND turn's first request streams the normal composed answer (the
banner/in-bubble Retry's re-ask). The sequence resets after that
success, so a second question carrying the trigger re-drives the
failure from zero (the phase-67 convention).
- user message containing ``partial then fail``
(``PARTIAL_FAIL_TRIGGER``): the FIRST matching streaming request
sends ``PARTIAL_FAIL_TEXT`` in normal 12-char ``delta.content``
chunks and then the generator RAISES — a genuine mid-stream
connection reset (the body ends without ``finish_reason`` /
``[DONE]``; a mid-stream failure never re-POSTs — the body was
already flowing). The app's ``chat_stream_retried`` re-raises the
wrapped ``LLMError`` (a piece already emitted — the locked
retry-before-first-frame rule) and the chat endpoint settles the
turn with the terminal SSE ``error`` frame AFTER the partial
deltas (the partial-then-error wire: the partial bubble keeps its
text + the error note + the Retry). The SECOND request streams the
normal composed answer (the retry's re-ask) and resets the
sequence.
``max_tokens`` is honored deterministically (token ≈ whitespace word), ``max_tokens`` is honored deterministically (token ≈ whitespace word),
like a real endpoint: an answer longer than the cap is truncated. This like a real endpoint: an answer longer than the cap is truncated. This
is what makes the phase-11 truncation regression observable. is what makes the phase-11 truncation regression observable.
@@ -750,6 +780,46 @@ ALWAYS_FAIL_TRIGGER = "always fail"
#: bag-of-words vector — the endpoint's pre-stream embedding retry loop. #: bag-of-words vector — the endpoint's pre-stream embedding retry loop.
EMBED_FAIL_TRIGGER = "embed fail once" EMBED_FAIL_TRIGGER = "embed fail once"
# ---------------------------------------------------------------------------
# Phase 120 (failed-turn retry, TODO.md L3–4): deterministic failure
# injections for the failed-turn E2E suite (tests/e2e/
# test_failed_turn_retry.py) — see the module docstring
# ---------------------------------------------------------------------------
#: A user message containing this substring (case-insensitive) dies for
#: the ENTIRE first turn — every app-level attempt 500s (the
#: retry-budget exhaustion, the zero-frame terminal ``error``) — and
#: streams the normal composed answer from the SECOND turn on (the
#: banner/in-bubble Retry's re-ask, phase 111/120). The dead window is
#: exactly ONE turn: ``FAIL_FIRST_TURN_DEAD_ATTEMPTS`` = the e2e
#: forced-default budget (conftest pins ``BOR_LLM_RETRIES`` to the code
#: default 3 → 4 app-level attempts = 12 POSTs at
#: ``_HTTPS_PER_DEAD_ATTEMPT``). Each sequence resets after the success
#: it guards, so a second question carrying the trigger re-drives the
#: failure from zero (the phase-67 ``_fail_posts`` convention).
FAIL_FIRST_TURN_TRIGGER = "fail first turn"
#: One full turn under the forced-default budget (3 retries → 4
#: attempts): the whole first turn dies, the second turn's first
#: attempt streams (the Retry's re-ask).
FAIL_FIRST_TURN_DEAD_ATTEMPTS = 4
#: A user message containing this substring (case-insensitive) streams
#: ``PARTIAL_FAIL_TEXT`` as ordinary ``delta.content`` chunks on its
#: FIRST matching request and then the mock's generator RAISES (a
#: mid-stream connection reset — the SSE body ends without
#: ``finish_reason``/``[DONE]``; a mid-stream failure never re-POSTs).
#: The app's ``chat_stream_retried`` re-raises the wrapped ``LLMError``
#: (a piece already emitted — the locked retry-before-first-frame rule)
#: and the chat endpoint settles with the terminal SSE ``error`` frame
#: AFTER the partial deltas. The SECOND request streams the normal
#: composed answer (the retry's re-ask) and resets the sequence.
PARTIAL_FAIL_TRIGGER = "partial then fail"
#: Matching requests that partial-then-die (one POST — see above).
PARTIAL_FAIL_DEAD_REQUESTS = 1
#: The deterministic partial answer (byte-stable — the E2E asserts the
#: bubble KEEPS exactly this text, with the error note appended).
PARTIAL_FAIL_TEXT = "Here is the start of the answer that never finished."
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Phase 71 (tool-scaffolding guardrails, 2026-09-03 incident): # Phase 71 (tool-scaffolding guardrails, 2026-09-03 incident):
# deterministic raw-markup flows — see the module docstring # deterministic raw-markup flows — see the module docstring
@@ -987,6 +1057,32 @@ def _chat_dead(key: str, dead_attempts: int) -> bool:
return _bump_fail(key) <= dead_attempts * _HTTPS_PER_DEAD_ATTEMPT return _bump_fail(key) <= dead_attempts * _HTTPS_PER_DEAD_ATTEMPT
def _partial_fail_stream() -> Any:
"""The phase-120 partial-then-fail stream (``PARTIAL_FAIL_TRIGGER``):
``PARTIAL_FAIL_TEXT`` in 12-char ``delta.content`` chunks (the
mock's default cadence — NO ``finish_reason``, NO ``[DONE]``), then
a RAISE that ends the SSE body abruptly (a connection reset
mid-stream — the client SDK sees a truncated body, the app wraps it
in ``LLMError`` after the pieces already emitted, and the chat
endpoint's terminal ``error`` frame lands AFTER the partial deltas
on the client's stream)."""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
for piece in re.findall(r".{1,12}", PARTIAL_FAIL_TEXT, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{"index": 0, "delta": {"content": piece}, "finish_reason": None}
],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
raise RuntimeError("e2e mid-stream failure (phase 120 partial-then-fail)")
#: The agent's ``read`` tool-result prefix (app.rag.agent #: The agent's ``read`` tool-result prefix (app.rag.agent
#: ``_execute_tool``): ``"Document <source/path>:\n<content>"``. #: ``_execute_tool``): ``"Document <source/path>:\n<content>"``.
_READ_RESULT_PREFIX = "Document " _READ_RESULT_PREFIX = "Document "
@@ -2471,6 +2567,22 @@ def chat_completions(body: dict[str, Any]) -> Any:
if _chat_dead(RETRY_TRIGGER, RETRY_DEAD_ATTEMPTS): if _chat_dead(RETRY_TRIGGER, RETRY_DEAD_ATTEMPTS):
return _llm_500(RETRY_TRIGGER) return _llm_500(RETRY_TRIGGER)
_fail_posts[RETRY_TRIGGER] = 0 # the answer streamed — restart _fail_posts[RETRY_TRIGGER] = 0 # the answer streamed — restart
# Phase 120 (failed-turn retry): the failed-turn suite's two
# injections — the whole-first-turn death (the zero-frame
# exhaustion whose Retry's re-ask the mock then answers) and the
# partial-then-die (the partial deltas + terminal error frame).
if FAIL_FIRST_TURN_TRIGGER in user_lower:
if _chat_dead(FAIL_FIRST_TURN_TRIGGER, FAIL_FIRST_TURN_DEAD_ATTEMPTS):
return _llm_500(FAIL_FIRST_TURN_TRIGGER)
_fail_posts[FAIL_FIRST_TURN_TRIGGER] = 0 # the answer streamed — restart
if PARTIAL_FAIL_TRIGGER in user_lower:
if _bump_fail(PARTIAL_FAIL_TRIGGER) <= PARTIAL_FAIL_DEAD_REQUESTS:
return StreamingResponse(
_partial_fail_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
_fail_posts[PARTIAL_FAIL_TRIGGER] = 0 # the answer streamed — restart
# Phase 71 (tool-scaffolding guardrails): the deterministic raw- # Phase 71 (tool-scaffolding guardrails): the deterministic raw-
# markup flow — checked BEFORE the search/tool marker flows (the # markup flow — checked BEFORE the search/tool marker flows (the
# trigger is independent of the ``<tools>`` marker, so both # trigger is independent of the ``<tools>`` marker, so both
+566
View File
@@ -0,0 +1,566 @@
"""Phase 120 E2E (Playwright): failed-turn retry — network errors and
refresh survive a failed turn.
Source: ``TODO.md`` L3–4 — "Retry doesn't seem to work on network
error" + "Refreshing the page after an error shows only the chat
message you sent and no options to retry the message …".
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_failed_turn_retry.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the failure
shapes are the deterministic injections in ``tests/e2e/mock_llm.py``
(the phase-67 counter machinery, the phase-120 triggers):
* ``fail first turn`` (``FAIL_FIRST_TURN_TRIGGER``): the WHOLE first
turn dies — every app-level attempt of the forced-default budget
(conftest pins ``BOR_LLM_RETRIES`` to the code default 3 → 4
attempts) 500s, ZERO frames — the retry-budget exhaustion
(``tests/e2e/test_llm_retry.py``'s ``always fail`` past its budget) —
and the second turn's first request streams the normal answer (the
Retry's re-ask).
* ``partial then fail`` (``PARTIAL_FAIL_TRIGGER``): the first request
streams ``PARTIAL_FAIL_TEXT`` in ordinary delta chunks, then the
mock's generator RAISES — a genuine mid-stream connection reset: the
app's ``chat_stream_retried`` re-raises (a piece already emitted —
the locked retry-before-first-frame rule) and the chat endpoint
settles with the terminal SSE ``error`` frame AFTER the partial
deltas. The second request streams the normal answer.
The e2e app server boots with ``BOR_LLM_RETRY_DELAY=0`` (conftest) so
the dead attempts are instant; ``BOR_LLM_RETRIES`` is forced to the
real default (3) so the exhaustion turn burns the REAL budget — 4
attempts, the last ``retry`` frame reads "(4 of 4)".
KB seed (the ``test_llm_retry.py`` direct-seed pattern): ONE fixture
document (``homelab/kubernetes.md``) — the "How is my Kubernetes
cluster set up?" questions FTS-hit it → HIGH → the grounded path (the
re-asked turn streams the grounded ``MOCK_ANSWER_MARKER`` answer).
Test → contract mapping (locked A1: a failed turn persists as a brain
record with the ``failed`` marker + the capped ``error`` detail; the
banner Retry stays as-is — ``lastBrainWrap`` simply EXISTS on the error
paths now):
1. ``test_network_error_shows_banner_retry_and_failed_bubble`` —
A (zero-frame network error): the banner (role=alert) is visible
WITH the phase-111 Retry button (its ``lastBrainWrap`` precondition
finally holds), a failed bubble with the fixed honest line + the
in-bubble error note (the detail) + the in-bubble Retry exists, the
record persists ``failed: true`` + ``error`` in localStorage, and
the wire is retry×3 + terminal error with NO delta/done. Clicking
the BANNER Retry re-asks WITHOUT re-typing (no second user
bubble), the mock now answers, the grounded answer streams in
place, and the failed bubble is gone (redo-in-place).
2. ``test_partial_then_error_keeps_partial_with_note_and_retry`` —
B (SSE error frame after partial deltas): the partial bubble KEEPS
its streamed text, gains the in-bubble error note (the detail) +
the in-bubble Retry, the record persists the RAW partial +
``failed: true`` + ``error``, and the wire is deltas-then-terminal-
error with no done. Clicking the IN-BUBBLE Retry re-asks in place
(the failed record is replaced by the fresh grounded answer — no
re-typing, no failed note).
3. ``test_failed_turn_survives_a_refresh`` — C (the refresh case):
after a zero-frame failure, ``page.reload()`` restores the question
+ the failed bubble (error detail visible) WITH a working Retry
button on it — no "new chat" required — and clicking it re-asks
(the grounded answer replaces the failed record).
4. ``test_stopped_turn_is_not_a_failed_turn`` — negative (phase 48
unchanged): a user-stopped turn restores with the "Stopped" note
and NOT a failed note — the two markers are mutually exclusive by
construction (the stop path never touches the failed funnel).
"""
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
import pytest
from playwright.sync_api import Page, 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 PARTIAL_FAIL_TEXT, embed_text
REPO = Path(__file__).resolve().parents[2]
# --------------------------------------------------------------------------
# Seed + questions (see the module docstring for the gate notes)
# --------------------------------------------------------------------------
SEED_SOURCE = "docs"
SEED_PATH = "homelab/kubernetes.md"
KUB_CONTENT = (REPO / "tests" / "fixtures" / "docs" / SEED_PATH).read_text()
#: A (zero-frame network error) + C (refresh) — HIGH turn (FTS-hits the
#: seed) so the re-asked turn streams the GROUNDED mock answer.
FAIL_Q = "How is my Kubernetes cluster set up? fail first turn"
#: B (partial deltas + the terminal error frame) — HIGH turn, same
#: re-ask contract.
PARTIAL_Q = "How is my Kubernetes cluster set up? partial then fail"
#: Negative: the mock's ~8 s long answer (12 chars / 0.02 s) — the
#: comfortable stop window (the phase-48 suite's phrasing).
STOP_Q = "How is my Kubernetes cluster set up? write a long answer"
#: The app's terminal LLM-failure copy (app/api/chat.py's LLMError
#: frame) — the error detail the note/banner/record all carry.
ERROR_COPY = "The chat model dropped the connection — try again?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The conftest forces BOR_LLM_RETRIES to its default (3) → 4 attempts
#: total; the retry-frame math in the assertions is fixed by that.
MAX_ATTEMPTS = 4
STORAGE_KEY = "bor.chat.v1"
def _js_const(name: str) -> str:
"""A frontend string constant, read from app.js — the E2E asserts
against the SAME text the page renders (no JS/Python drift)."""
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")
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, cf. test_llm_retry.py)
# --------------------------------------------------------------------------
def _seed(db: Session) -> None:
"""The single fixture document (see the module docstring)."""
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()
# One chunk carrying the mock's own embedding → genuine token overlap
# for the grounded questions (the FTS path carries them to HIGH).
db.add(
Chunk(
document_id=md.id,
position=0,
content=KUB_CONTENT,
embedding=embed_text(KUB_CONTENT),
)
)
def _reset_db() -> None:
"""Truncate the KB (plus the prompt-shaping tables), then re-seed.
``steering_notes`` / ``kb_overview`` are truncated too, so the
prompts are byte-stable regardless of leftovers from other suites.
"""
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 so the suite starts from (and leaves)
an empty deployment (the phase-80/103 isolation pattern)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
# The auto-save is fire-and-forget (the browser never awaits the
# PUT): a short margin lets the last turn's PUT land server-side
# before the TRUNCATE, so the teardown never 500s an in-flight
# upsert (the "Could not refresh instance" race). This runs AFTER
# the page closes (LIFO finalization), so the PUT is either done
# or aborted — never in flight.
time.sleep(0.75)
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# --------------------------------------------------------------------------
# Page hooks (the SSE capture — the phase-37/67 pattern from
# test_llm_retry.py) + localStorage reads
# --------------------------------------------------------------------------
SSE_HOOK = """
() => {
if (window.__sseInstalled) return;
window.__sseInstalled = true;
window.__sseFrames = [];
const origFetch = window.fetch;
window.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
if (url.includes('/api/chat')) {
res.clone().text().then((bodyText) => {
for (const block of bodyText.split('\\n\\n')) {
const line = block.trim();
if (line.startsWith('data: ')) {
window.__sseFrames.push(line.slice(6));
}
}
});
}
} catch (e) { /* non-clonable responses: ignored */ }
return res;
};
}
"""
def _install_sse_hook(page: Page) -> None:
page.evaluate(SSE_HOOK)
def _frames(page: Page, terminal: str = "done") -> list[dict]:
"""The captured SSE frames of the CURRENT turn, once the *terminal*
frame lands (``error`` for the failure scenarios)."""
deadline = time.monotonic() + 30.0
while True:
raw = page.evaluate("() => window.__sseFrames || []")
parsed = [json.loads(line) for line in raw if line]
if any(f.get("type") == terminal for f in parsed):
return parsed
if time.monotonic() > deadline:
raise AssertionError(
f"SSE hook captured no `{terminal}` frame (frames so far: "
f"{len(parsed)}) — hook install failed?"
)
time.sleep(0.05)
def _stored(page: Page) -> dict:
"""The persisted ``bor.chat.v1`` conversation (localStorage — the
phase-14 store the restore reads on refresh)."""
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 _submit(page: Page, question: str) -> None:
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _assert_failed_bubble(
page: Page, *, expected_text: str, note_detail: str
) -> None:
"""The failed-bubble contract shared by the scenarios: ONE brain
bubble with *expected_text*, the in-bubble error note (the "Failed"
label + *note_detail*), and the in-bubble Retry button
(``markLastRetryable`` on the failed bubble — the last brain
wrap)."""
brain = page.locator(".msg.brain")
expect(brain).to_have_count(1)
expect(brain.locator(".bubble")).to_have_text(expected_text)
note = brain.locator(".failed-note")
expect(note).to_have_count(1)
expect(note).to_contain_text("Failed")
expect(note).to_contain_text(note_detail)
expect(brain.locator(".retry-btn")).to_have_count(1)
# A note, not an answer: no Save-as-doc button on the failed bubble.
expect(brain.locator(".save-as-doc-btn")).to_have_count(0)
def _assert_settled_composer(page: Page) -> None:
"""The failed turn settles to idle: the Send button recovered
(never a zombified Stop — the §7.4 never-stale contract)."""
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_redo_succeeded(page: Page) -> None:
"""After a Retry re-ask: exactly question + the fresh GROUNDED
answer (the mock now answers) — the failed record is REPLACED in
place (no second user bubble, no failed note, no failed marker on
the new record)."""
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.brain")).to_have_count(1)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator(".failed-note")).to_have_count(0)
# The turn has FULLY settled: the ``done`` frame is processed (the
# answer record persisted through rememberBrainTurn — synchronously
# in the frame handler) BEFORE the finally's idle state flips the
# label to Send. The label is the settle signal, so the local
# storage read below is race-free (the marker can be visible a few
# frames before ``done`` — the last deltas carry it).
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
stored = _stored(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
last = stored["messages"][-1]
assert last.get("failed") in (None, False), "the fresh answer is not failed"
assert MOCK_ANSWER_MARKER in last["text"]
# The fresh grounded record's text is the rendered answer (no
# failed-marker leftovers in the conversation).
assert FAILED_TURN_TEXT not in last["text"]
# --------------------------------------------------------------------------
# A — zero-frame network error: the banner WITH a working Retry + the
# failed bubble; clicking the banner Retry re-asks without re-typing
# --------------------------------------------------------------------------
def test_network_error_shows_banner_retry_and_failed_bubble(
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="/")
_install_sse_hook(page)
_submit(page, FAIL_Q)
# After the 4th dead attempt (the forced-default budget) the turn
# dies with ZERO frames: the existing terminal error banner
# (role=alert, the "dropped the connection" copy) — NOW with its
# Retry button revealed (the phase-111 condition
# ``opts.retryable && lastBrainWrap`` finally holds: the failed
# bubble's wrap exists before the error state).
banner = page.locator("#kb-banner")
expect(banner).to_have_attribute("role", "alert", timeout=60_000)
expect(banner).to_contain_text(ERROR_COPY)
expect(page.locator("#banner-retry")).to_be_visible(timeout=10_000)
_assert_settled_composer(page)
# The zero-frame failed bubble: the fixed honest line (NOT the raw
# detail — that rides the note), the in-bubble error note carrying
# the detail, and the in-bubble Retry.
_assert_failed_bubble(page, expected_text=FAILED_TURN_TEXT, note_detail=ERROR_COPY)
# Persisted: the question's user record + the failed brain record —
# ``failed: true`` + the capped detail (the phase-48 ``stopped``
# precedent in localStorage; the phase-55 auto-save rides the same
# call, so the server-side saved chat carries it too).
stored = _stored(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][0]["text"] == FAIL_Q
failed = stored["messages"][-1]
assert failed["text"] == FAILED_TURN_TEXT
assert failed["failed"] is True
assert failed["error"] == ERROR_COPY
# Wire: the three retry frames (attempts 2–4 of 4 — the real
# budget), then the terminal error frame LAST — no delta, no done
# (ZERO frames reached the client).
frames = _frames(page, terminal="error")
assert [f for f in frames if f["type"] == "retry"] == [
{"type": "retry", "attempt": a, "max_attempts": MAX_ATTEMPTS}
for a in (2, 3, 4)
], frames
assert frames[-1]["type"] == "error"
assert ERROR_COPY in frames[-1]["detail"]
assert not [f for f in frames if f.get("type") in ("done", "delta")], frames
# The BANNER Retry: re-asks WITHOUT re-typing (no second user
# bubble) — the mock now answers (the sequence resets after its
# guarded success) and the grounded answer streams in place of the
# failed bubble (redo-in-place — the failed record is popped).
page.click("#banner-retry")
_assert_redo_succeeded(page)
# --------------------------------------------------------------------------
# B — SSE error frame after partial deltas: the partial keeps its text
# + the error note + the in-bubble Retry; clicking it re-asks in
# place (the failed record is replaced by the fresh answer)
# --------------------------------------------------------------------------
def test_partial_then_error_keeps_partial_with_note_and_retry(
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="/")
_install_sse_hook(page)
_submit(page, PARTIAL_Q)
# The partial streams, then the mock's generator dies mid-stream:
# the app settles the turn with the terminal error banner AFTER the
# partial deltas — and the partial bubble KEEPS its text.
banner = page.locator("#kb-banner")
expect(banner).to_have_attribute("role", "alert", timeout=60_000)
expect(banner).to_contain_text(ERROR_COPY)
_assert_settled_composer(page)
# The failed partial: the streamed text kept verbatim + the
# in-bubble error note (the detail) + the in-bubble Retry.
_assert_failed_bubble(
page, expected_text=PARTIAL_FAIL_TEXT, note_detail=ERROR_COPY
)
# Persisted: the RAW partial (what the user saw is what is stored —
# the phase-17/20 convention, now with the failed marker + detail).
stored = _stored(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
failed = stored["messages"][-1]
assert failed["text"] == PARTIAL_FAIL_TEXT
assert failed["failed"] is True
assert failed["error"] == ERROR_COPY
# Wire: the delta frames (the partial, in order) precede the
# terminal error frame — no done (the stream never settled), no
# retry (a piece already emitted — the locked retry-before-first-
# frame rule means the failure is terminal, never redone).
frames = _frames(page, terminal="error")
deltas = [f for f in frames if f["type"] == "delta"]
assert deltas, "no delta frames before the error"
assert "".join(d["text"] for d in deltas) == PARTIAL_FAIL_TEXT
assert not [f for f in frames if f.get("type") == "retry"], frames
assert frames[-1]["type"] == "error"
assert not [f for f in frames if f.get("type") == "done"], frames
# The IN-BUBBLE Retry: redo-in-place — the failed partial record is
# popped + re-asked (no re-typing), and the fresh grounded answer
# (the mock now answers — the sequence resets) replaces it.
page.click(".msg.brain .retry-btn")
_assert_redo_succeeded(page)
# --------------------------------------------------------------------------
# C — the refresh case: reload restores the failed bubble WITH a
# working Retry; clicking it re-asks (no "new chat" required)
# --------------------------------------------------------------------------
def test_failed_turn_survives_a_refresh(
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="/")
_install_sse_hook(page)
_submit(page, FAIL_Q)
# The zero-frame failure lands (same shape as scenario A).
banner = page.locator("#kb-banner")
expect(banner).to_have_attribute("role", "alert", timeout=60_000)
expect(banner).to_contain_text(ERROR_COPY)
expect(page.locator("#banner-retry")).to_be_visible(timeout=10_000)
_assert_failed_bubble(
page, expected_text=FAILED_TURN_TEXT, note_detail=ERROR_COPY
)
# The refresh: the page restores from localStorage (the failed
# record persisted by the funnel) — the question + the failed
# bubble with its error detail, and the Retry button on the failed
# bubble (the restore loop's lastBrainWrap + markLastRetryable land
# it on the LAST restored brain bubble — the failed one).
page.reload()
expect(page.locator("#messages > .msg")).to_have_count(2)
expect(page.locator(".msg.user .bubble")).to_have_text(FAIL_Q)
_assert_failed_bubble(
page, expected_text=FAILED_TURN_TEXT, note_detail=ERROR_COPY
)
# The error state itself does NOT restore (the banner is a live-
# turn state) — the recovery affordance is the in-bubble Retry.
expect(page.locator("#kb-banner")).to_be_hidden()
_assert_settled_composer(page)
# The restored record still carries the marker (the restore is
# lossless: text + detail + marker).
stored = _stored(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][-1]["failed"] is True
assert stored["messages"][-1]["error"] == ERROR_COPY
# The restored Retry WORKS: clicking it re-asks the question that
# precedes the failed record (retryLastTurn, unchanged) — the
# grounded answer streams and replaces the failed record.
page.click(".msg.brain .retry-btn")
_assert_redo_succeeded(page)
# --------------------------------------------------------------------------
# Negative — a STOPPED turn (phase 48) is NOT a failed turn: the stop
# path restores with the "Stopped" note and never the failed note
# (mutually exclusive by construction — the stop branch is the catch's
# own, the failed funnel is the catch's else)
# --------------------------------------------------------------------------
def test_stopped_turn_is_not_a_failed_turn(
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="/")
# The ~8 s long answer — a comfortable stop window (phase 48).
_submit(page, STOP_Q)
answer = page.locator(".msg.brain .bubble:not(.typing)")
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the stop"
expect(page.locator("#send-label")).to_have_text("Stop")
page.click("#send-btn") # the in-flight button IS the Stop control
# Settled to idle: no error banner (the stop path never shows one),
# the partial kept + the "Stopped" note — and NO failed note.
expect(page.locator("#send-label")).to_have_text("Send", timeout=5_000)
expect(page.locator("#kb-banner")).to_be_hidden()
expect(page.locator(".msg.brain .stopped-note")).to_have_count(1)
expect(page.locator(".msg.brain .stopped-note")).to_contain_text("Stopped")
expect(page.locator(".msg.brain .failed-note")).to_have_count(0)
stopped_text = answer.inner_text()
assert stopped_text.strip()
# Persisted: the stopped marker, NOT the failed marker (the two are
# mutually exclusive by construction).
stored = _stored(page)
last = stored["messages"][-1]
assert last["stopped"] is True
assert last.get("failed") in (None, False), "a stopped turn is not a failed turn"
assert "error" not in last or last["error"] is None
# The refresh: the stopped partial restores with the "Stopped"
# note and NOT the failed note — phase 48's restore is unchanged.
page.reload()
expect(page.locator("#messages > .msg")).to_have_count(2)
expect(page.locator(".msg.user .bubble")).to_have_text(STOP_Q)
expect(page.locator(".msg.brain .bubble:not(.typing)")).to_have_text(
stopped_text
)
expect(page.locator(".msg.brain .stopped-note")).to_have_count(1)
expect(page.locator(".msg.brain .failed-note")).to_have_count(0)
expect(page.locator("#kb-banner")).to_be_hidden()
stored = _stored(page)
assert stored["messages"][-1]["stopped"] is True
assert stored["messages"][-1].get("failed") in (None, False)
+34 -3
View File
@@ -60,6 +60,11 @@ Test → source mapping:
appears (role=alert, the "dropped the connection" copy), the last appears (role=alert, the "dropped the connection" copy), the last
retrying status is the highest attempt — "(4 of 4)…" — and the send retrying status is the highest attempt — "(4 of 4)…" — and the send
button re-enables (the banner path settles the state machine). button re-enables (the banner path settles the state machine).
Phase 120 (locked A1): the zero-frame turn ALSO renders the
retryable failed bubble (the fixed honest line + the in-bubble
error note with the detail + the in-bubble Retry) and reveals the
banner Retry (its ``lastBrainWrap`` precondition finally holds on
the error path) — a network error is retryable.
""" """
from __future__ import annotations from __future__ import annotations
@@ -115,6 +120,21 @@ MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
DEFLECT_PHRASE = r"haven't done anything like that" DEFLECT_PHRASE = r"haven't done anything like that"
ERROR_COPY = "The chat model dropped the connection — try again?" ERROR_COPY = "The chat model dropped the connection — try again?"
def _js_const(name: str) -> str:
"""A frontend string constant, read from app.js (no JS/Python
drift — the E2E asserts against the SAME text the page renders)."""
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)
#: Phase 120: the fixed bubble text of a ZERO-frame failed turn (the
#: exhaustion case below) — read from app.js so this suite's assertion
#: tracks the page's constant.
FAILED_TURN_TEXT = _js_const("FAILED_TURN_TEXT")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, cf. test_agent_document_tools.py) # DB seeding (TRUNCATE-then-seed, cf. test_agent_document_tools.py)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -501,7 +521,18 @@ def test_exhaustion_lands_on_the_error_banner(
assert not [f for f in frames if f.get("type") == "done"] assert not [f for f in frames if f.get("type") == "done"]
assert not [f for f in frames if f.get("type") == "delta"] assert not [f for f in frames if f.get("type") == "delta"]
# No answer bubble was ever rendered (no frame ever streamed a # Phase 120 (locked A1): the zero-frame turn is no longer a bare
# token) — the user bubble is the only message in the DOM. # question — it persists as a FAILED brain record (the phase-48
expect(page.locator("#messages > .msg.brain")).to_have_count(0) # ``stopped`` precedent) and renders the retryable error bubble:
# the fixed honest line (no frame ever streamed a token, so the
# bubble is NOT the empty-answer fallback), the in-bubble error
# note carrying the detail, the in-bubble Retry, and the banner
# Retry revealed too (its ``lastBrainWrap`` precondition finally
# holds on the error path — a network error is retryable).
brain = page.locator("#messages > .msg.brain")
expect(brain).to_have_count(1)
expect(brain.locator(".bubble")).to_have_text(FAILED_TURN_TEXT)
expect(brain.locator(".failed-note")).to_contain_text(ERROR_COPY)
expect(brain.locator(".retry-btn")).to_have_count(1)
expect(page.locator("#banner-retry")).to_be_visible()
expect(page.locator(".msg.user .bubble")).to_have_count(1) expect(page.locator(".msg.user .bubble")).to_have_count(1)
+1 -1
View File
@@ -628,7 +628,7 @@ def test_api_created_chats_carry_the_bor_chat_v1_shape(
# brain records (accepted by the ChatMessage schema — its earlier # brain records (accepted by the ChatMessage schema — its earlier
# absence 422'd the done-time auto-save, phase-118 verification fix). # absence 422'd the done-time auto-save, phase-118 verification fix).
allowed = {"who", "text", "sources", "related", "deflected", "suggestions", allowed = {"who", "text", "sources", "related", "deflected", "suggestions",
"thinking", "tools", "stopped"} "thinking", "tools", "stopped", "failed", "error"}
assert all({"who", "text"} <= set(m) <= allowed for m in body["messages"]) assert all({"who", "text"} <= set(m) <= allowed for m in body["messages"])
# The History row renders it (the Open link's text IS the title). # The History row renders it (the Open link's text IS the title).
page.click("#nav-history") page.click("#nav-history")
+39 -6
View File
@@ -48,10 +48,13 @@ Test → phase mapping (Playwright Mapping Rule):
2. ``test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable`` 2. ``test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable``
— the terminal case: the existing error state renders with the — the terminal case: the existing error state renders with the
dedicated copy ("The model returned a malformed reply — please try dedicated copy ("The model returned a malformed reply — please try
again."), no raw tokens in the DOM, no answer bubble, no ``done`` again."), no raw tokens in the DOM, no answer bubble (phase 120:
and NO ``query_log`` row (the existing terminal-error semantics) — the zero-frame turn renders the FAILED note bubble — the fixed
and the app stays usable: a follow-up plain question in the same honest line + the error note + the Retry, NOT an answer), no
session gets a normal deflected answer and the banner clears. ``done`` and NO ``query_log`` row (the existing terminal-error
semantics) — and the app stays usable: a follow-up plain question
in the same session gets a normal deflected answer and the banner
clears.
3. ``test_plain_turn_never_recovers_and_streams_byte_clean`` — no 3. ``test_plain_turn_never_recovers_and_streams_byte_clean`` — no
false positive: a plain deflected question streams its false positive: a plain deflected question streams its
first-request answer byte-clean (the concatenated delta text is first-request answer byte-clean (the concatenated delta text is
@@ -64,6 +67,7 @@ from __future__ import annotations
import json import json
import re import re
import time import time
from pathlib import Path
from playwright.sync_api import Page, expect from playwright.sync_api import Page, expect
from sqlalchemy import select, text from sqlalchemy import select, text
@@ -73,6 +77,24 @@ from app.models import QueryLog
from e2e.auth_helpers import login from e2e.auth_helpers import login
from tests.e2e.mock_llm import SCAFFOLD_RECOVERY_ANSWER, compose_answer from tests.e2e.mock_llm import SCAFFOLD_RECOVERY_ANSWER, compose_answer
REPO = Path(__file__).resolve().parents[2]
def _js_const(name: str) -> str:
"""A frontend string constant, read from app.js (no JS/Python
drift — the E2E asserts against the SAME text the page renders)."""
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)
#: Phase 120: the fixed bubble text of a ZERO-frame failed turn (the
#: terminal scaffolding case below) — read from app.js so this suite's
#: assertion tracks the page's constant.
FAILED_TURN_TEXT = _js_const("FAILED_TURN_TEXT")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Questions + the mock's deterministic expectations # Questions + the mock's deterministic expectations
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -364,9 +386,20 @@ def test_terminal_scaffolding_lands_on_dedicated_error_app_stays_usable(
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# No answer bubble was ever rendered (every streamed frame was # No ANSWER bubble was ever rendered (every streamed frame was
# stripped server-side) and no raw token is anywhere in the DOM. # stripped server-side) and no raw token is anywhere in the DOM.
expect(page.locator("#messages > .msg.brain")).to_have_count(0) # Phase 120 (locked A1): the zero-frame turn persists as a FAILED
# brain record (the phase-48 ``stopped`` precedent) and renders the
# retryable error bubble — the fixed honest line (NOT an answer),
# the in-bubble error note carrying the dedicated copy, the
# in-bubble Retry, and the banner Retry revealed too (its
# ``lastBrainWrap`` precondition finally holds on the error path).
brain = page.locator("#messages > .msg.brain")
expect(brain).to_have_count(1)
expect(brain.locator(".bubble")).to_have_text(FAILED_TURN_TEXT)
expect(brain.locator(".failed-note")).to_contain_text(MALFORMED_ERROR_COPY)
expect(brain.locator(".retry-btn")).to_have_count(1)
expect(page.locator("#banner-retry")).to_be_visible()
_assert_no_raw_tokens(page) _assert_no_raw_tokens(page)
# Wire: the terminal error frame is LAST — no done, and NO delta # Wire: the terminal error frame is LAST — no done, and NO delta
+122 -1
View File
@@ -11,7 +11,8 @@ unchanged: the auto-title convention (first
user message, whitespace-collapsed, 120-char cap + the no-user-message user message, whitespace-collapsed, 120-char cap + the no-user-message
fallback), the list order (``updated_at desc, id desc``), the fallback), the list order (``updated_at desc, id desc``), the
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
``sources``/``thinking``/``tools``/``stopped`` survives losslessly), ``sources``/``thinking``/``tools``/``stopped``/``failed``/``error``
survives losslessly),
the PUT upsert semantics (replacement + title-keep + title-set + the PUT upsert semantics (replacement + title-keep + title-set +
``updated_at`` bump), the delete 404/204, and (phase 53, task 03) the ``updated_at`` bump), the delete 404/204, and (phase 53, task 03) the
sources-version stamp + ``stale`` flag: create and re-Save stamp the sources-version stamp + ``stale`` flag: create and re-Save stamp the
@@ -103,6 +104,12 @@ FULL_BRAIN: dict[str, Any] = {
}, },
], ],
"stopped": False, "stopped": False,
# Phase 120 (task 01): the failed-turn marker + the persisted error
# detail — the phase-48 ``stopped`` precedent. A FULL brain record
# carries the keys (``failed: False`` = the marker is explicit, not
# absent; the round-trip stays byte-identical through them).
"failed": False,
"error": None,
} }
OUT_KEYS = { OUT_KEYS = {
@@ -191,6 +198,8 @@ def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
"thinking": m.get("thinking"), "thinking": m.get("thinking"),
"tools": m.get("tools"), "tools": m.get("tools"),
"stopped": m.get("stopped"), "stopped": m.get("stopped"),
"failed": m.get("failed"),
"error": m.get("error"),
} }
for m in records for m in records
] ]
@@ -1185,3 +1194,115 @@ def test_put_rejects_oversized_message_and_leaves_row_unchanged(
assert got.json()["messages"] == _expect(_simple_conversation()) assert got.json()["messages"] == _expect(_simple_conversation())
assert got.json()["message_count"] == 2 # original count, not the rejected 1 assert got.json()["message_count"] == 2 # original count, not the rejected 1
# ---------- failed-turn records (phase 120, task 01 — locked A1: a
# failed chat turn persists as a BRAIN record with the `failed` marker
# + the capped `error` detail, the phase-48 `stopped` precedent — no
# separate error table, no new API) ----------
#: The failed record shape exactly as the client persists it (the
#: zero-frame network-error case — ``finalizeFailedTurn``'s
#: FAILED_TURN_TEXT bubble + the terminal error detail).
FAILED_BRAIN: dict[str, Any] = {
"who": "brain",
"text": "My answer didn't make it — the connection dropped. Use Retry to ask again.",
"failed": True,
"error": "The chat model dropped the connection — try again?",
}
def test_create_round_trips_failed_record_byte_identical(
admin_client: TestClient,
) -> None:
"""``POST /api/chats`` with a failed brain record returns it
byte-identically (the phase-50 contract through the new keys),
``GET`` survives the trip to Postgres and back, and a ``PUT``
re-Save round-trips it too (the re-Save upsert keeps the marker +
detail — the auto-save rides exactly this path)."""
records = [_user(FIRST_QUESTION), FAILED_BRAIN]
r = admin_client.post("/api/chats", json={"messages": records})
assert r.status_code == 201
body = r.json()
assert body["messages"] == _expect(records)
assert body["messages"][1]["failed"] is True
assert body["messages"][1]["error"] == FAILED_BRAIN["error"]
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
assert got.json()["messages"] == _expect(records)
r2 = admin_client.put(
f"/api/chats/{body['id']}", json={"messages": records}
)
assert r2.status_code == 200
assert r2.json()["messages"] == _expect(records)
def test_post_rejects_error_over_500_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The phase-83 style value bound on the new key: a 501-char
``error`` 422s at the boundary (``ChatMessage.error``
``max_length=500``) and NOTHING is stored — the hostile detail
string never lands in the JSONB."""
baseline = admin_client.get("/api/chats").json()["chats"]
bad = dict(FAILED_BRAIN)
bad["error"] = "e" * 501
r = client.post(
"/api/chats",
json={"messages": [_user("hi"), bad]},
)
assert r.status_code == 422
assert admin_client.get("/api/chats").json()["chats"] == baseline
def test_put_rejects_error_over_500_and_leaves_row_unchanged(
client: TestClient, admin_client: TestClient
) -> None:
"""The re-Save path is gated by the SAME bound: a 501-char
``error`` 422s and the row keeps its original payload
byte-for-byte."""
created = client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
bad = dict(FAILED_BRAIN)
bad["error"] = "e" * 501
r = client.put(
f"/api/chats/{created['id']}",
json={"messages": [bad]},
)
assert r.status_code == 422
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["messages"] == _expect(_simple_conversation())
def test_shared_chat_with_failed_record_serves_public_shape(
admin_client: TestClient,
) -> None:
"""The phase-51 public read is UNCHANGED by the failed record:
``GET /api/shared/<token>`` still serves exactly the public shape
(``title`` + ``messages`` — no id, no timestamps, no token) and the
failed record rides the snapshot verbatim (the shared page renders
its ``text`` as-is — no note, no Retry, read-only by design)."""
records = [_user(FIRST_QUESTION), FAILED_BRAIN]
created = admin_client.post(
"/api/chats",
json={"title": EXPLICIT_TITLE, "messages": records, "share": True},
).json()
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
r = anon.get(f"/api{created['share_url']}")
assert r.status_code == 200
body = r.json()
assert set(body) == SHARED_OUT_KEYS # title + messages — the public shape
assert body["title"] == EXPLICIT_TITLE
assert body["messages"] == _expect(records)
assert body["messages"][1]["failed"] is True
assert body["messages"][1]["error"] == FAILED_BRAIN["error"]
+196
View File
@@ -0,0 +1,196 @@
"""Unit: the failed-turn schema boundary (phase 120, task 03).
Phase 120 (task 01, locked A1) added two OPTIONAL keys to
``ChatMessage`` (``app/schemas.py``) — ``failed`` (a bool marker) and
``error`` (the persisted error detail, capped at 500) — the phase-48
``stopped`` precedent: a FAILED chat turn (network error, SSE ``error``
frame, stream drop) persists as a BRAIN record
``{who: "brain", text: <detail or fallback>, failed: true, error:
<detail>}`` — no separate error table, no new API (``retryLastTurn``'s
pop-the-last-brain-record logic works on a failed record UNCHANGED).
This module pins the boundary the phase plan names:
* a failed record (``failed: true`` + ``error``) validates and
round-trips losslessly;
* ``error`` of 501 chars 422s at the boundary (the phase-83
value-bounds style; exactly 500 passes);
* an unknown key still 422s (``extra="forbid"`` intact — the new keys
are DECLARED, they did not loosen the boundary);
* a record WITHOUT the new keys validates, serializes with the new keys
as explicit nulls, and — on every pre-phase key — is byte-identical
to the pre-phase-120 stored shape (the phase-50 contract: the server
stores ``model_dump()`` without ``exclude_none``, so a pre-phase
round-trip is untouched apart from the two added nulls).
House convention: pure schema tests (no DB, no client) — the API-level
round-trip pins live in ``tests/integration/test_chats_api.py``.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.schemas import ChatMessage
#: The phase-120 failed record shape (locked A1) — the zero-frame
#: network-error case exactly as ``finalizeFailedTurn`` persists it.
FAILED_RECORD: dict = {
"who": "brain",
"text": "My answer didn't make it — the connection dropped. Use Retry to ask again.",
"failed": True,
"error": "The chat model dropped the connection — try again?",
}
# ---------- acceptance: the failed record validates + round-trips ----------
def test_failed_record_validates() -> None:
"""A failed brain record (``failed: true`` + the capped ``error``
detail) crosses the boundary and the fields survive the trip."""
msg = ChatMessage.model_validate(FAILED_RECORD)
assert msg.who == "brain"
assert msg.text == FAILED_RECORD["text"]
assert msg.failed is True
assert msg.error == FAILED_RECORD["error"]
def test_failed_record_round_trips_losslessly() -> None:
"""The stored shape (``model_dump`` — plain, no ``exclude_none``,
the phase-50 storage convention) keeps the marker + detail and
fills the remaining optional keys with explicit nulls (the
restore path is null-safe)."""
dumped = ChatMessage.model_validate(FAILED_RECORD).model_dump()
assert dumped["failed"] is True
assert dumped["error"] == FAILED_RECORD["error"]
for key in ("sources", "related", "deflected", "suggestions", "thinking", "tools", "stopped"):
assert dumped[key] is None, f"{key} must be an explicit null, got {dumped[key]!r}"
# Re-validate the stored shape — the round-trip is lossless.
assert ChatMessage.model_validate(dumped).model_dump() == dumped
def test_failed_false_is_an_explicit_marker() -> None:
"""``failed: false`` is a legal value (a full ``bor.chat.v1`` brain
record carries the key explicitly — the integration FULL_BRAIN
round-trip relies on it); it must not be coerced to absent."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "hi", "failed": False, "error": None}
)
assert msg.failed is False
assert msg.error is None
assert msg.model_dump()["failed"] is False
# ---------- the value bounds: error ≤ 500 (phase-83 style) ----------
def test_error_at_500_chars_passes() -> None:
"""The bound is inclusive: exactly 500 chars validate."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "hi", "failed": True, "error": "e" * 500}
)
assert len(msg.error or "") == 500
def test_error_over_500_chars_is_rejected() -> None:
"""501 chars 422s at the boundary (the API surfaces this as a 422 —
the hostile detail string is capped at the schema, the
``finalizeFailedTurn`` 500-char slice is the UI-side first cut)."""
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "hi", "failed": True, "error": "e" * 501}
)
# ---------- the boundary stays strict: extra="forbid" intact ----------
def test_unknown_key_is_still_rejected() -> None:
"""``extra="forbid"`` was NOT loosened by the new keys: a stray
key still rejects at the boundary (the corrupted / HTML-shaped
payload defense, unchanged)."""
with pytest.raises(ValidationError):
ChatMessage.model_validate({"who": "brain", "text": "hi", "foo": 1})
def test_unknown_key_is_rejected_even_with_the_new_keys_present() -> None:
"""A record carrying the new keys AND an unknown key still
rejects — the new declarations did not widen the accepted key set
beyond ``failed``/``error``."""
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "hi", "failed": True, "error": "x", "foo": 1}
)
# ---------- backward compatibility: the pre-phase-120 shape ----------
def test_record_without_the_new_keys_validates_with_none() -> None:
"""A pre-phase record (no ``failed``/``error`` keys) still
validates; both new fields default to ``None`` (they round-trip as
nulls — absent/None, exactly like ``stopped`` today)."""
msg = ChatMessage.model_validate({"who": "brain", "text": "hi"})
assert msg.failed is None
assert msg.error is None
dumped = msg.model_dump()
assert dumped["failed"] is None
assert dumped["error"] is None
def test_pre_phase_record_round_trips_byte_identical_on_existing_keys() -> None:
"""The phase-50 contract through the new schema: a pre-phase-120
STORED record (every phase-50 key present, explicit nulls where an
optional key does not apply) validates, and re-serializes
byte-identically on EVERY pre-phase key — the only diff is the two
added keys as explicit nulls. Old saved chats and shared links
therefore render/restore exactly as before."""
pre_phase: dict = {
"who": "brain",
"text": "Your k3s cluster runs on three nodes — you've got this.",
"sources": [{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes"}],
"related": [{"source": "Homelab", "path": "traefik.md", "title": "Traefik"}],
"deflected": False,
"suggestions": ["What ports does Traefik expose?"],
"thinking": "scratchpad",
"tools": [
{
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
}
],
"stopped": None,
}
dumped = ChatMessage.model_validate(pre_phase).model_dump()
# Every pre-phase key survives byte-identical…
for key, value in pre_phase.items():
assert dumped[key] == value, f"pre-phase key {key!r} changed: {dumped[key]!r}"
# …and the ONLY additions are the two new keys as explicit nulls.
assert set(dumped) == set(pre_phase) | {"failed", "error"}
assert dumped["failed"] is None
assert dumped["error"] is None
# The stored shape re-validates (round-trip through the DB JSONB).
assert ChatMessage.model_validate(dumped).model_dump() == dumped
def test_minimal_user_record_unchanged() -> None:
"""A user record (no brain metadata at all) is untouched by the
phase: it validates and carries the new keys as nulls only."""
dumped = ChatMessage.model_validate({"who": "user", "text": "hi"}).model_dump()
assert dumped == {
"who": "user",
"text": "hi",
"sources": None,
"related": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
"failed": None,
"error": None,
}
+468
View File
@@ -0,0 +1,468 @@
"""Unit: the failed-turn frontend contract (phase 120, task 03).
Pins the phase-120 client contract as source assertions (the house
``test_frontend_*`` style — no browser): the three live failure paths
route through the single ``finalizeFailedTurn`` funnel (persist
``failed: true`` + the capped detail, end retryable), the zero-frame
fallback bubble persists the marker too, ``appendFailedNote`` mirrors
the stopped note (one per bubble, textContent-only detail),
``FAILED_TURN_TEXT`` is a distinct constant (NOT
``EMPTY_ANSWER_FALLBACK``), the restore branch renders the failed note
and excludes the Save-as-doc / Tune buttons, and — the phase's explicit
"NOT touched" contract — ``showErrorBanner`` and ``retryLastTurn`` are
byte-unchanged (their full sources are pinned below).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _find_body_brace(js: str, start: int) -> int:
"""The index of the REAL opening brace of a function at *start* —
the first ``{`` OUTSIDE the parameter list (paren depth 0), so
empty object defaults (``opts = {}``) and destructured parameters
(``{ acc, thinking }``) are skipped (the phase-111 banner-test
convention, extended)."""
i = start
paren = 0
while i < len(js):
c = js[i]
if c == "(":
paren += 1
elif c == ")":
paren -= 1
elif c == "{" and paren == 0:
return i
i += 1
return -1
def _fn_source(js: str, name: str) -> str:
"""The full source of ``function name(…){…}`` (signature + body)."""
start = js.index(f"function {name}(")
brace = _find_body_brace(js, start)
depth = 0
i = brace
while True:
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
i += 1
return js[start : i + 1]
def _fn_body(js: str, name: str) -> str:
"""Just the body of ``function name(…){…}`` (between the braces)."""
src = _fn_source(js, name)
start = js.index(f"function {name}(")
brace = _find_body_brace(js, start)
rel = brace - start
return src[rel + 1 : len(src) - 1]
# ---------------------------------------------------------------------------
# appendFailedNote — the in-bubble error note (the stopped-note mirror)
# ---------------------------------------------------------------------------
def test_append_failed_note_exists_and_guards_dedup() -> None:
"""``appendFailedNote(wrap, detail)`` exists and mirrors
``appendStoppedNote``: it reuses the ``.msg-meta`` row and adds at
most ONE ``.failed-note`` per bubble (the duplicate guard)."""
js = _js()
body = _fn_body(js, "appendFailedNote")
assert '.msg-body' in body, "the note must land in the bubble's .msg-body"
assert ".msg-meta" in body, "the note rides the existing .msg-meta row"
assert '.failed-note' in body
assert (
'if (meta.querySelector(".failed-note")) return;' in body
), "one .failed-note per bubble (the duplicate guard, the stopped-note way)"
assert 'note.className = "failed-note";' in body
def test_append_failed_note_is_text_and_color_never_color_alone() -> None:
"""The note is TEXT + color (B5): the "Failed" label is set through
``textContent``, the icon is aria-hidden decoration, and the detail
goes through ``textContent`` too — the error string is NEVER
innerHTML (no HTML from an error, ever)."""
js = _js()
body = _fn_body(js, "appendFailedNote")
assert 'label.textContent = "Failed";' in body
assert 'd.className = "failed-detail"' in body
assert "d.textContent = detail" in body, "the detail is textContent, never innerHTML"
# The icon itself is the only innerHTML — the static SVG constant,
# aria-hidden decoration (the accessible meaning is the label +
# detail text — B5: text + color, never color alone).
assert "note.innerHTML = FAILED_ICON;" in body
m_icon = re.search(r"const FAILED_ICON\s*=\s*\n?\s*'([^']*)'", js)
assert m_icon, "const FAILED_ICON must exist"
assert 'aria-hidden="true"' in m_icon.group(1), "the icon is decoration"
def test_failed_turn_text_is_a_distinct_constant() -> None:
"""``FAILED_TURN_TEXT`` is its OWN string literal — NOT
``EMPTY_ANSWER_FALLBACK`` (that constant stays the
zero-frame-but-COMPLETED case's answer text) and a short honest
"my answer didn't make it" line (not the answer text, not the raw
detail)."""
js = _js()
m_fail = re.search(r'const FAILED_TURN_TEXT\s*=\s*\n?\s*"([^"]*)"', js)
assert m_fail, "const FAILED_TURN_TEXT must be a string literal"
failed_text = m_fail.group(1)
assert failed_text, "FAILED_TURN_TEXT must be non-empty"
m_empty = re.search(r'const EMPTY_ANSWER_FALLBACK\s*=\s*\n?\s*"([^"]*)"', js)
assert m_empty, "const EMPTY_ANSWER_FALLBACK must still be a string literal"
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
# ---------------------------------------------------------------------------
# finalizeFailedTurn — the single funnel for the live failure paths
# ---------------------------------------------------------------------------
def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
"""The funnel persists ``failed: true`` in BOTH shapes (partial
wrap + the zero-frame bubble) with the capped detail, and the
partial keeps the streamed text (``acc``)."""
js = _js()
body = _fn_body(js, "finalizeFailedTurn")
# Both branches persist the marker…
assert body.count("failed: true") == 2, (
"both funnel shapes must persist failed: true"
)
# …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
assert "appendFailedNote(fwrap, error);" in body
# …and the partial shape settles the block + calls closed (the
# stop-finalize pattern) and adds the note.
assert "closeThinkingBlock(wrap);" in body
assert "closeToolCalls(wrap);" in body
assert "appendFailedNote(wrap, error);" in body
# Both shapes land the record through rememberBrainTurn (local
# storage + the phase-55 auto-save ride) and set lastBrainWrap
# BEFORE the caller's setUiState(error, …).
assert body.count("rememberBrainTurn(") == 2
assert body.count("lastBrainWrap =") == 2
def test_finalize_failed_turn_ends_retryable() -> None:
"""The funnel ENDS with ``markLastRetryable()`` (the last statement
— a trailing comment is fine) — the in-bubble Retry button lands
on the failed bubble (the last brain wrap)."""
js = _js()
body = _fn_body(js, "finalizeFailedTurn").rstrip()
last_line = body.splitlines()[-1].strip()
assert last_line.startswith("markLastRetryable();"), (
"finalizeFailedTurn must end with the markLastRetryable() call"
)
def test_error_catch_else_routes_through_the_funnel() -> None:
"""The error catch's ``else`` branch (non-abort, non-stop — network
error, pre-stream HTTP error, the SSE ``error`` frame's throw)
calls ``finalizeFailedTurn`` BEFORE ``setUiState(UI_STATE.error, …)``
— the funnel sets lastBrainWrap, so the banner's EXISTING
``opts.retryable && lastBrainWrap`` condition reveals the Retry."""
js = _js()
# The stop branch ends where the plain else begins.
stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {')
else_branch = js.index("} else {", stop_branch)
finally_branch = js.index("} finally {", else_branch)
else_body = js[else_branch:finally_branch]
assert "finalizeFailedTurn(detail, {" in else_body
# The persistence (the funnel) precedes the error state — the
# banner's Retry precondition is set before the banner shows.
assert else_body.index("finalizeFailedTurn(detail, {") < else_body.index(
"setUiState(UI_STATE.error, detail,"
)
def test_navigate_away_is_not_a_failed_turn() -> None:
"""A REAL departure is not a failed turn (phase-120 verification
fix): the pagehide handler sets a turn-scoped ``leftThePage`` flag
(module scope, like ``persistedOnLeave``), and the error catch's
``else`` branch skips the failed funnel when it is set — the
browser's teardown rejection of the cancelled in-flight fetch (a
TypeError, NOT an AbortError) must not persist a failed brain
record: the phase-20 convention stands (thinking-only
navigate-away persists nothing brain-side; a partial navigate-away
persists the pagehide partial as a plain record). The flag is set
unconditionally on pagehide (a merely-hidden tab in some browsers
also fires it — the stream keeps arriving, so no rejection
follows and it stays inert there) and reset per turn at the top of
``runTurn`` with the other turn locals."""
js = _js()
# Module-scope declaration (column 0), exactly once.
assert re.search(r"^let leftThePage = false", js, re.M), (
"leftThePage must be a module-scope flag (the pagehide handler "
"reads it), like persistedOnLeave"
)
assert js.count("let leftThePage = false;") == 1
# Set as the FIRST statement of the pagehide handler — before the
# persistedOnLeave early return (a thinking-only navigate-away
# returns early, but the flag must be set for the funnel skip).
ph = js.index('window.addEventListener("pagehide", () => {')
ph_end = js.index("\n});", ph)
ph_body = js[ph:ph_end]
idx_flag = ph_body.find("leftThePage = true;")
idx_return = ph_body.find("if (persistedOnLeave) return;")
assert 0 <= idx_flag < idx_return, (
"leftThePage must be set BEFORE the pagehide early returns"
)
# Reset per turn at the top of runTurn (the persistedOnLeave group).
turn = js.index("async function runTurn")
abort_idx = js.index("turnAbort = new AbortController()", turn)
turn_top = js[turn:abort_idx]
assert "leftThePage = false;" in turn_top, (
"leftThePage must be reset per turn at the top of the turn handler"
)
assert turn_top.index("persistedOnLeave = false;") < turn_top.index(
"leftThePage = false;"
)
# The catch's else branch (non-abort, non-stop) skips the funnel
# when the flag is set — the funnel call itself stays intact (the
# real-network-error path, no pagehide).
stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {')
else_branch = js.index("} else {", stop_branch)
finally_branch = js.index("} finally {", else_branch)
else_body = js[else_branch:finally_branch]
idx_guard = else_body.find("if (!leftThePage) {")
idx_funnel = else_body.find("finalizeFailedTurn(detail, {")
assert 0 <= idx_guard < idx_funnel, (
"the failed funnel must be skipped after a real page departure "
"(the teardown rejection is not a failed turn)"
)
def test_stream_drop_guard_routes_through_the_funnel() -> None:
"""The stream-drop guard (frames arrived, no ``done`` — the
connection died mid-turn) routes through the SAME funnel: the
half-answer persists as failed (its text + the error note + a
working Retry) BEFORE the error state."""
js = _js()
guard = js.index("if (!sawDone && !aborted && (acc || thinkingAcc)) {")
zero_frame = js.index("if (!aborted && !wrap) {", guard)
guard_body = js[guard:zero_frame]
assert "finalizeFailedTurn(detail, {" in guard_body
assert guard_body.index("finalizeFailedTurn(detail, {") < guard_body.index(
"setUiState(UI_STATE.error, detail);"
)
def test_zero_frame_fallback_persists_failed_marker() -> None:
"""The zero-frame-but-COMPLETED fallback bubble (the stream settled
with no events) is a failed turn too (task 01 ASSUMPTION): the
bubble text stays ``EMPTY_ANSWER_FALLBACK`` (a meaningful record
text) but the record gains ``failed: true`` + the error note, and
the bubble ends retryable."""
js = _js()
zero_frame = js.index("if (!aborted && !wrap) {")
catch = js.index("} catch (err) {", zero_frame)
block = js[zero_frame:catch]
assert "const fallback = EMPTY_ANSWER_FALLBACK;" in block, (
"the bubble text stays the EMPTY_ANSWER_FALLBACK answer text"
)
assert "appendFailedNote(fwrap, nothing);" in block
assert "failed: true," in block
assert "error: nothing," in block
assert "markLastRetryable();" in block
def test_only_the_three_failed_paths_persist_failed() -> None:
"""No call site OUTSIDE the three failed paths persists
``failed: true`` (task 01 completion criterion, grep-level):
exactly three CODE sites — two in ``finalizeFailedTurn`` (the
partial + zero-frame shapes) and one in the zero-frame-but-
completed fallback — the done, stop, and restore paths never set
the marker (they read it, or don't touch it)."""
js = _js()
fn = _fn_body(js, "finalizeFailedTurn")
zero_frame = js.index("if (!aborted && !wrap) {")
catch = js.index("} catch (err) {", zero_frame)
fallback_block = js[zero_frame:catch]
fn_start = js.index("function finalizeFailedTurn(")
fn_src = _fn_source(js, "finalizeFailedTurn")
code_outside = js[:fn_start] + js[fn_start + len(fn_src) :]
code_outside = code_outside.replace(fallback_block, "")
# Comments may mention the marker; code must not (the file's
# block-comment lines start with * or /* after stripping).
code_lines = [
line
for line in code_outside.splitlines()
if not line.strip().startswith(("//", "*", "/*"))
]
assert "failed: true" not in "\n".join(code_lines), (
"only the three failed paths may persist failed: true"
)
assert fn.count("failed: true") == 2
assert fallback_block.count("failed: true") == 1
# ---------------------------------------------------------------------------
# restore — a failed record renders as an error bubble with the note
# ---------------------------------------------------------------------------
def test_restore_renders_the_failed_note() -> None:
"""The restore branch re-renders the in-bubble error note from the
persisted ``error`` detail — and only when it is present (a record
whose ``error`` is null has the detail in its ``text`` already)."""
js = _js()
body = _fn_body(js, "renderStoredMessage")
assert "if (m.failed && m.error) appendFailedNote(wrap, m.error);" in body, (
"the failed note restores from the persisted error detail"
)
def test_restore_excludes_save_as_doc_and_tune_for_failed() -> None:
"""A failed turn is a NOTE, not an answer: the restore excludes
both the Save-as-doc button (the ``m.stopped`` exclusion extended
with ``!m.failed``) and the Tune button (``!m.failed``). Stopped
and successful records keep their buttons — the pre-phase-120
behavior for them is byte-identical."""
js = _js()
body = _fn_body(js, "renderStoredMessage")
assert "if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);" in body
assert "if (!m.failed) appendTuneButton(wrap);" in body
# ---------------------------------------------------------------------------
# NOT touched — the phase's explicit contract (byte-pinned sources)
# ---------------------------------------------------------------------------
#: The FULL source of ``showErrorBanner`` as of phase 120 — the
#: phase-111 button + the phase-114 hint, byte-unchanged by this phase
#: (the phase makes ``lastBrainWrap`` EXIST on the error paths instead
#: of changing the condition). A diff here is a contract violation.
PINNED_SHOW_ERROR_BANNER = """function showErrorBanner(detail, opts = {}) {
banner.hidden = false;
banner.classList.add("is-error");
banner.setAttribute("role", "alert");
// Phase 114 (TODO L6): a frame-carried hint (the "question too long"
// case — reachability is fine, only the length is the problem) replaces
// the default reachability hint when present.
bannerText.textContent = detail
? `${detail} ${opts.hint ?? ERROR_HINT}`
: (opts.hint ?? ERROR_HINT);
// Phase 111 (task 01): reveal the banner Retry button only for failed
// chat turns (opts.retryable) AND when a retryable bubble exists.
if (opts.retryable) {
const btn = document.querySelector("#banner-retry");
if (btn && lastBrainWrap) {
btn.hidden = false;
// Bind click once per reveal — the old listener is removed after
// the first click, so re-binding on every reveal is safe.
btn.addEventListener("click", () => retryLastTurn(lastBrainWrap));
}
}
}"""
def test_show_error_banner_is_byte_unchanged() -> None:
"""``showErrorBanner`` is byte-unchanged by phase 120 (the
"NOT touched" contract): its full source must match the pin —
the ``opts.retryable && lastBrainWrap`` condition, the phase-114
hint merge, and the once-per-reveal binding included."""
assert _fn_source(_js(), "showErrorBanner") == PINNED_SHOW_ERROR_BANNER
#: The FULL source of ``retryLastTurn`` as of phase 120 — the phase-49
#: redo-in-place (pop the last brain record, re-ask the preceding
#: question). It works on a failed record UNCHANGED (locked A1): the
#: question's user record immediately precedes the failed record.
PINNED_RETRY_LAST_TURN = """function retryLastTurn(wrap) {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
if (wrap !== lastBrainWrap) return; // stale click — the button moved on
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
if (lastIdx === -1) return;
// Invariant: every brain record follows its user record — the
// question to re-ask is the record immediately before the popped one.
const prev = conversation[lastIdx - 1];
if (!prev || prev.who !== "user") return;
const text = prev.text;
conversation.splice(lastIdx, 1); // redo in place: the old answer is gone
// Save BEFORE the rerun: what the user saw — the removed answer — is
// what is stored from this point on (the question stays, the replaced
// answer never comes back).
saveConversation();
wrap.remove();
lastBrainWrap = null;
// Re-ask without re-adding: the reask turn skips the user append and
// persistence save point 1 (the question is already in both).
// Phase 53: the promise is returned (the Regenerate await above);
// runTurn never rejects — a failure surfaces as the error banner.
return runTurn(text, { reask: true });
}"""
def test_retry_last_turn_is_byte_unchanged() -> None:
"""``retryLastTurn`` is byte-unchanged by phase 120 (locked A1 —
the redo-in-place is REUSED, not extended): the full source must
match the pin — the pop-the-last-brain-record + re-ask logic, the
in-flight guard, and the stale-click guard included."""
assert _fn_source(_js(), "retryLastTurn") == PINNED_RETRY_LAST_TURN
# ---------------------------------------------------------------------------
# CSS — the in-bubble error line (the .stopped-note family, error color)
# ---------------------------------------------------------------------------
def test_failed_note_css_uses_the_error_token() -> None:
"""``.failed-note`` exists in styles.css, colored by the theme's
error TOKEN (``--err-ink`` — the monochrome theme grays it
automatically; the contrast floor is the stopped-note family's) —
never a literal color (the phase-92 zero-literal convention)."""
css = _css()
m = re.search(r"\.failed-note \{([\s\S]*?)\n\}", css)
assert m, "styles.css must define .failed-note"
body = m.group(1)
assert "color: var(--err-ink);" in body, (
".failed-note must use the theme's error token"
)
assert "pointer-events: none;" in body, "the note is non-interactive (the stopped-note way)"
def test_failed_detail_wraps_long_details() -> None:
"""The detail span WRAPS (a 500-char error detail must not blow
out the 46rem chat column — the stopped note's nowrap fits a
one-word label, not a detail)."""
css = _css()
m = re.search(r"\.failed-detail \{([\s\S]*?)\n\}", css)
assert m, "styles.css must define .failed-detail"
assert "overflow-wrap: anywhere;" in m.group(1)
+8 -5
View File
@@ -172,9 +172,10 @@ def test_app_js_call_sites_pass_the_raw_markdown() -> None:
the rendered HTML): the live `done` branch (exactly the string the rendered HTML): the live `done` branch (exactly the string
rememberBrainTurn stores, so a reload offers the identical draft), rememberBrainTurn stores, so a reload offers the identical draft),
the empty-answer fallback bubble (parity with the done path), and the empty-answer fallback bubble (parity with the done path), and
the restore path (m.text). A stopped partial is a note, not an the restore path (m.text). A stopped partial — or a failed turn
answer — the restore gates on !m.stopped; the live stop path and (phase 120: a note, not an answer either) — is excluded: the
the pagehide partial never call the helper at all.""" restore gates on !m.stopped && !m.failed; the live stop/failure
paths and the pagehide partial never call the helper at all."""
js = _text(APP_JS) js = _text(APP_JS)
assert 'appendSaveAsDocButton(wrap, finalText || acc || "…");' in js, ( assert 'appendSaveAsDocButton(wrap, finalText || acc || "…");' in js, (
"the live done branch must pass the raw persisted text" "the live done branch must pass the raw persisted text"
@@ -182,8 +183,10 @@ def test_app_js_call_sites_pass_the_raw_markdown() -> None:
assert "appendSaveAsDocButton(fwrap, fallback);" in js, ( assert "appendSaveAsDocButton(fwrap, fallback);" in js, (
"the empty-answer fallback bubble must get the button too" "the empty-answer fallback bubble must get the button too"
) )
assert "if (!m.stopped) appendSaveAsDocButton(wrap, m.text);" in js, ( assert (
"the restore path must pass m.text and skip stopped records" "if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);" in js
), (
"the restore path must pass m.text and skip stopped + failed records"
) )
# The live call sits next to the Tune button (same meta row scope). # The live call sits next to the Tune button (same meta row scope).
tune_idx = js.find("appendTuneButton(wrap); // every completed brain bubble is tunable") tune_idx = js.find("appendTuneButton(wrap); // every completed brain bubble is tunable")
+14
View File
@@ -339,6 +339,8 @@ def test_minimal_message_still_validates() -> None:
msg.thinking, msg.thinking,
msg.tools, msg.tools,
msg.stopped, msg.stopped,
msg.failed,
msg.error,
) == ( ) == (
None, None,
None, None,
@@ -347,6 +349,8 @@ def test_minimal_message_still_validates() -> None:
None, None,
None, None,
None, None,
None,
None,
) )
@@ -373,6 +377,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"thinking": None, "thinking": None,
"tools": None, "tools": None,
"stopped": None, "stopped": None,
"failed": None,
"error": None,
}, },
{ {
"who": "brain", "who": "brain",
@@ -411,6 +417,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
}, },
], ],
"stopped": None, "stopped": None,
"failed": None,
"error": None,
}, },
{ {
"who": "user", "who": "user",
@@ -422,6 +430,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"thinking": None, "thinking": None,
"tools": None, "tools": None,
"stopped": None, "stopped": None,
"failed": None,
"error": None,
}, },
{ {
"who": "brain", "who": "brain",
@@ -433,6 +443,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"thinking": None, "thinking": None,
"tools": None, "tools": None,
"stopped": True, # the owner stopped the generation mid-answer "stopped": True, # the owner stopped the generation mid-answer
"failed": None,
"error": None,
}, },
] ]
@@ -459,6 +471,8 @@ def test_realistic_payload_round_trips_through_update_model() -> None:
"thinking": "scratchpad", "thinking": "scratchpad",
"tools": [_tool_call()], "tools": [_tool_call()],
"stopped": None, "stopped": None,
"failed": None,
"error": None,
} }
payload = SavedChatUpdate.model_validate({"messages": [msg]}) payload = SavedChatUpdate.model_validate({"messages": [msg]})
assert payload.model_dump()["messages"] == [msg] assert payload.model_dump()["messages"] == [msg]