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
+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``
— the terminal case: the existing error state renders with the
dedicated copy ("The model returned a malformed reply — please try
again."), no raw tokens in the DOM, no answer bubble, no ``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.
again."), no raw tokens in the DOM, no answer bubble (phase 120:
the zero-frame turn renders the FAILED note bubble — the fixed
honest line + the error note + the Retry, NOT an answer), no
``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
false positive: a plain deflected question streams its
first-request answer byte-clean (the concatenated delta text is
@@ -64,6 +67,7 @@ from __future__ import annotations
import json
import re
import time
from pathlib import Path
from playwright.sync_api import Page, expect
from sqlalchemy import select, text
@@ -73,6 +77,24 @@ from app.models import QueryLog
from e2e.auth_helpers import login
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
# --------------------------------------------------------------------------
@@ -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-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.
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)
# Wire: the terminal error frame is LAST — no done, and NO delta