phase: 120_failed_turn_retry
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:
@@ -60,6 +60,11 @@ Test → source mapping:
|
||||
appears (role=alert, the "dropped the connection" copy), the last
|
||||
retrying status is the highest attempt — "(4 of 4)…" — and the send
|
||||
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
|
||||
|
||||
@@ -115,6 +120,21 @@ MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||
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)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -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") == "delta"]
|
||||
|
||||
# No answer bubble was ever rendered (no frame ever streamed a
|
||||
# token) — the user bubble is the only message in the DOM.
|
||||
expect(page.locator("#messages > .msg.brain")).to_have_count(0)
|
||||
# Phase 120 (locked A1): the zero-frame turn is no longer a bare
|
||||
# question — it persists as a FAILED brain record (the phase-48
|
||||
# ``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)
|
||||
|
||||
Reference in New Issue
Block a user