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:
+198
-10
@@ -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
|
||||
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?";
|
||||
/* 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. */
|
||||
const reducedMotion =
|
||||
@@ -504,6 +513,48 @@ function appendStoppedNote(wrap) {
|
||||
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
|
||||
* the CURRENT last brain record. Set wherever a brain bubble becomes the
|
||||
* 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 thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
|
||||
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
|
||||
* the turn's settle. `leavePartialIndex` is the index in `conversation`
|
||||
* 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,
|
||||
* messages: [{ who: "user"|"brain", text,
|
||||
* sources?, deflected?, suggestions?,
|
||||
* thinking?, tools?, stopped? }] }
|
||||
* thinking?, tools?, stopped?,
|
||||
* failed?, error? }] }
|
||||
*
|
||||
* 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
|
||||
@@ -1680,12 +1745,28 @@ function renderStoredMessage(m) {
|
||||
appendMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
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
|
||||
// persisted). A stopped partial (m.stopped) is a note, not an answer
|
||||
// — no button (the live stop path adds none either).
|
||||
if (!m.stopped) appendSaveAsDocButton(wrap, m.text);
|
||||
// persisted). A stopped partial (m.stopped) or a failed turn
|
||||
// (m.failed) is a note, not an answer — no button (the live
|
||||
// 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
|
||||
// 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
|
||||
// (LAST — after the meta-row claimers, exactly like the live done
|
||||
// path). Pre-phase records carry no `related` → appendRelated
|
||||
@@ -2333,6 +2414,67 @@ async function handleSend(e) {
|
||||
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 —
|
||||
* extracted from handleSend so the retry redo can re-run a question
|
||||
* without re-adding it. `reask` skips (a) the user-bubble append and
|
||||
@@ -2367,6 +2509,7 @@ async function runTurn(text, { reask = false } = {}) {
|
||||
acc = "";
|
||||
thinkingAcc = "";
|
||||
persistedOnLeave = false;
|
||||
leftThePage = false;
|
||||
leavePartialIndex = -1;
|
||||
// Phase 48: a fresh abort owner per turn (cleared in the finally); the
|
||||
// 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
|
||||
// existing empty-answer fallback below.
|
||||
if (!sawDone && !aborted && (acc || thinkingAcc)) {
|
||||
setUiState(
|
||||
UI_STATE.error,
|
||||
"The stream ended before my answer finished — try again?"
|
||||
);
|
||||
// Phase 120 (TODO.md L3–4): a half-answer is a FAILED answer — the
|
||||
// partial persists as failed (its text + the in-bubble error note +
|
||||
// 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) {
|
||||
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);
|
||||
appendTuneButton(fwrap);
|
||||
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
|
||||
appendFailedNote(fwrap, nothing);
|
||||
// Phase 73: correlated like the other settles — unreachable when a
|
||||
// pagehide partial exists (that needs acc, which means a wrap), but
|
||||
// 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;
|
||||
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.message
|
||||
: "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
|
||||
// the banner (setUiState → showErrorBanner's opts.hint); the
|
||||
// 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,
|
||||
* so the partial stays persisted exactly as before. */
|
||||
window.addEventListener("pagehide", () => {
|
||||
leftThePage = true; // the dying fetch's teardown rejection is not a failed turn
|
||||
if (persistedOnLeave) return;
|
||||
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
|
||||
return;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/* 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
|
||||
(both ≥44px). Save = brand button (dark ink 5.2:1), Cancel = ghost. */
|
||||
.tune-form {
|
||||
|
||||
Reference in New Issue
Block a user