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
+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
fallback), the list order (``updated_at desc, id desc``), the
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 +
``updated_at`` bump), the delete 404/204, and (phase 53, task 03) the
sources-version stamp + ``stale`` flag: create and re-Save stamp the
@@ -103,6 +104,12 @@ FULL_BRAIN: dict[str, Any] = {
},
],
"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 = {
@@ -191,6 +198,8 @@ def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
"thinking": m.get("thinking"),
"tools": m.get("tools"),
"stopped": m.get("stopped"),
"failed": m.get("failed"),
"error": m.get("error"),
}
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()["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"]