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:
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user