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`.
9.8 KiB
Phase 120 — 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, forcing the user to click 'new chat' or be stuck."
Story: n/a (bug-fix follow-up; extends the phase-49/53 redo-in-place retry, phase-67 LLM retry, and phase-111 banner Retry assets).
Context: frontend/assets/app.js — showErrorBanner(detail, opts) (L2210) reveals #banner-retry only when opts.retryable && lastBrainWrap (L2222); retryLastTurn(wrap) (L2276) pops the LAST brain record and re-asks the user question immediately before it (the invariant every brain record follows its user record); rememberBrainTurn(rawText, meta, replaceIndex) (L2108) pushes/replaces the brain record in conversation + saveConversation() + persistConversation() (the phase-55 auto-save rides the same call). lastBrainWrap is assigned only on three paths: the done settle (L2609), the zero-frame fallback bubble (L2671), and the user-stop finalize (L2699) — never on a turn error. The error catch (the else branch at ~L2690) calls setUiState(UI_STATE.error, detail, { hint }) with NO brain record persisted, whether or not a partial wrap exists. The stream-drop guard (~L2651, !sawDone && !aborted && (acc || thinkingAcc)) also lands in the error state with nothing persisted. Restore: renderStoredMessage(m) (L1642) renders m.stopped via appendStoppedNote (L486); the restore loop sets lastBrainWrap on the last restored brain bubble (L1694) and calls markLastRetryable() (L1708, L560 — removes all .retry-btn, re-adds on the LAST .brain-wrap). app/schemas.py — ChatMessage (L742) is extra="forbid" with fields who, text (≤32 000), sources?, related?, deflected?, suggestions?, thinking? (≤32 000), tools?, stopped?: bool | None (L786); SavedChatCreate/Update messages are non-empty, ≤200 (phase 83). tests/e2e/mock_llm.py + tests/e2e/test_llm_retry.py hold the existing LLM-failure mock pattern for the E2E.
Objective
A failed chat turn — network error (zero frames), SSE error frame, or mid-stream drop — leaves a retryable error state both live (the banner Retry and an in-bubble Retry both work) and after a page refresh (the failed turn restores as an error bubble with a working Retry button). No failed turn strands the user with a bare question and no recovery.
Dependencies
119_name_signal_read_chips(complete) — pipeline predecessor (execution order) only.- Code dependencies (all complete): phase 49/53
retryLastTurnredo-in-place, phase 111#banner-retry, phase 48stoppedpersistence +appendStoppedNotepattern, phase 55 auto-save ridingrememberBrainTurn.
Design (shared by all tasks — the executor reads this, not the chat)
- Failed record (task 01, server side): two new OPTIONAL fields on
ChatMessage, the phase-48stoppedprecedent (L786):failed: bool | None = Noneanderror: str | None = Field(default=None, max_length=500)(the persisted error detail; 500 caps a hostile detail string in the phase-83 style).extra="forbid"stays — the keys are now declared, unknown keys still 422. No change inapp/api/chats.pylogic (the schema flows throughSavedChatCreate/Update); the shared-chat shape (SharedChatOut.messages) carries failed records verbatim (text renders as-is on the shared page — no change needed there). - Failed turn = a brain record (LOCKED A1): a failed turn persists
{ who: "brain", text: <detail or fallback>, failed: true, error: <detail> }viarememberBrainTurn— so it lands in localStorage AND the server-side saved chat through the existing phase-55 auto-save ride. There is no separate error table and no new API:retryLastTurn's pop-the-last-brain-record-then-re-ask-the-preceding-question logic works on a failed record UNCHANGED (the invariant holds — the question's user record immediately precedes it). - Live error paths (task 01, frontend): the error catch's
elsebranch (non-abort, non-stop) and the stream-drop guard BOTH funnel into one new helperfinalizeFailedTurn(detail, { acc, thinking, tools }):- Partial exists (
wrapwith streamed text): close the thinking block + tool calls (the stop-finalize pattern),appendFailedNote(wrap, detail)(new, mirrorsappendStoppedNoteL486 — an in-bubble error line with the detail), persist viarememberBrainTurn(acc, { thinking, tools, failed: true, error: detail }, leavePartialIndex),lastBrainWrap = wrap. - No wrap (network error, zero frames): create a brain bubble with a fixed fallback text (a short honest "my answer didn't make it" line — NOT the
EMPTY_ANSWER_FALLBACKanswer text; theappendFailedNotecarries the real detail), persist the same record shape,lastBrainWrap = fwrap. - Then
markLastRetryable()— the in-bubble Retry button appears, andshowErrorBanner's existingopts.retryable && lastBrainWrapcondition (L2222) now holds on a turn error, so the phase-111 banner Retry appears too — no change toshowErrorBanner(it binds() => retryLastTurn(lastBrainWrap)at reveal;lastBrainWrapis set beforesetUiState(UI_STATE.error, …)runs). - The zero-frame-but-stream-completed case keeps its existing fallback bubble (L2663–2673) — now ALSO marked
failed: true+ error note (it is a failed turn; the bubble text staysEMPTY_ANSWER_FALLBACKso the record keeps a meaningfultext).
- Partial exists (
- Restore (task 02):
renderStoredMessage(m)gains the failed branch — am.failedrecord renders as a brain bubble (the persistedtext), getsappendFailedNote(wrap, m.error), and gets NO Save-as-doc button (a note, not an answer — them.stoppedexclusion at L1687 precedent:if (!m.stopped && !m.failed) appendSaveAsDocButton(…)). No other restore change is required: the restore loop'slastBrainWrap = wrap(L1694) +markLastRetryable()(L1708) already target the last.brain-wrap, which is now the failed bubble → the in-bubble Retry button renders on refresh.retryLastTurnneeds no change (the failed record is the last brain record; its preceding user record is the question). - Interaction with
stopped: a turn is either stopped (user engaged, partial kept,stopped: true) or failed (failed: true) — mutually exclusive by construction (the stop path is the catch'sstoppedByUser/AbortErrorbranch, which this phase does not touch). - NOT touched:
retryLastTurnitself, the stop path, the done path, the server save/restore API logic (schema fields only), the shared page rendering, and every non-chatshowErrorBannercaller.
Tasks
01_persist_failed_turn.md—ChatMessage.failed/errorfields + the live error paths persist a failed brain record with a rendered error bubble (banner Retry works on network errors).02_restore_failed_turn.md— restore renders afailedrecord as an error bubble with a working Retry button (the refresh case).03_failed_turn_tests.md— unit + integration + isolated E2Etest_failed_turn_retry.py.
Testing & Quality
- Unit:
tests/unit/test_chat_message_failed.py(new, task 03) —ChatMessageacceptsfailed/error,error>500 chars 422s, unknown keys still 422, omitted keys round-tripNone;tests/unit/test_frontend_failed_turn.py(new, task 03) — house-style source assertions: the error catch + stream-drop guard route through the failed-turn finalize (persistfailed: true, callmarkLastRetryable),appendFailedNoteexists and mirrors the stopped-note structure, the restore branch renders the note and excludes Save-as-doc,showErrorBanneris byte-unchanged (thelastBrainWrapcondition untouched). - Integration:
tests/integration/test_chats_api.py(extend) —POST/PUT /api/chatswith afailed: true+errorrecord round-trips byte-identically (the phase-50 contract); a shared chat carrying a failed record still serves (public shape unchanged). - E2E:
tests/e2e/test_failed_turn_retry.py(new, task 03) — run in isolation per AGENTS.md §4. Scenarios (themock_llm.pyfailure pattern fromtest_llm_retry.py): (A) network-class failure (zero frames) → banner with a visible Retry → click re-asks without re-typing; (B) SSEerrorframe after partial deltas → partial bubble keeps its text + error note + Retry → click re-asks; (C) reload the page after a failed turn → the failed bubble restores with a working Retry button → click re-asks. - Coverage: >90% on
app/(validate.sh gate).
Completion Criteria
- A network-error turn shows a Retry (banner and/or in-bubble); clicking it re-asks the last question without re-typing.
- Reloading the page after a failed turn shows the failed bubble (with the error detail) and a working Retry — no "new chat" required.
- Stopped turns (phase 48) and successful turns behave byte-identically to before.
uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missingTOTAL >90%;uv run ruff check . && uv run pyrightclean.- One
--no-gpg-signcommit; phase dir moved to.agents/phases/complete/by the pipeline gate.
Locked decisions
- A1 — a failed turn persists as a brain record with a
failedmarker (+ cappederrordetail), the phase-48stoppedprecedent; no separate error table, no new API,retryLastTurnreused unchanged (owner-confirmed 2026-09-24, roadmap confirmation). - Banner Retry stays as-is — the phase-111
opts.retryable && lastBrainWrapcondition is kept; this phase makeslastBrainWrapexist on the error paths so the existing button finally appears (owner-confirmed: same mechanism, noshowErrorBannerchange).
Commit
git add app/ frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(chat): persist failed turns so retry works on network errors and survives a refresh"