phase: 189_chat_save_long_turns
Build and Push Containers / build-and-push-app (push) Successful in 3m4s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 189 final verification — all green**

- **Verified (all 4 tasks already implemented):** A1 caps in `app/schemas.py` — `sources`/`related`/`tools` 200, `suggestions` 100, `segments` 450; `text`/`thinking` 600 000, `error`/`image` 500, `answer_text` 50, `messages` 500, 8 M budget all UNCHANGED; rationale docstrings rewritten to `merge_cited_docs`/`agent_max_rounds`. A2 `shareCurrentChat` linked branch upserts (PUT, 404→create+re-link) before `POST /{id}/share`, aborts on failure (banner, no token); unlinked branch byte-identical. A3 `savePending` + `finally` re-run. A4 `saveFailedSticky` + one 5 s `setTimeout` retry on network/5xx only, 4xx never retries.
- `uv run pytest --cov=app --cov-report=term-missing` → **4609 passed, 1 warning, EXIT=0**, `TOTAL 99%`, `app/schemas.py 100%` (>90%).
- `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` → **4 passed, EXIT=0** (isolation).
- Regression green & **unedited** (`git status` clean): `test_chat_save_payload_limits.py` 2, `test_chat_history.py` 5, `test_share_chat.py` 4. Extra blast-radius sweep (share/save/persist/segments surface, all EXIT=0): `test_save_share_ux`, `test_stale_saved_chats`, `test_history_pagination`, `test_history_copy`, `test_bottom_chat_actions`, `test_reasoning_bubble`, `test_tool_budget_telemetry`, `test_verbatim_show_text`, `test_smoke`. No leaked `saved_chats` rows (`save-long-%` = 0).
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**.
- **Completion criteria:** caps ✓ · incident payload 22/33/51 → 201/200 anonymously + 201 tools/451 segments/101 suggestions → 422 naming the offender, row unchanged ✓ · share-saves-first + abort + unlinked byte-identical ✓ · overlap re-run + sticky note + one-shot retry ✓ · phase E2E in isolation + 3 regression suites unedited ✓ · full suite / coverage / lint / types ✓ · `git diff --stat` = `app/schemas.py`, `frontend/assets/app.js`, 3 test files, `README.md` + new E2E & phase files (no `app/api/chats.py`, no migration, no new dependency) ✓ · commit + `todo/`→`complete/` move left to the harness (rules forbid git index writes).
- **Docs:** full README review (all 2 258 lines) + machine cross-check — all 85 settings documented in the Configuration reference, all 34 `.env.example` vars documented, documented defaults match `app/config.py`. Fixed 2 inconsistencies: (1) the 117-char prose line task 04 left when reflowing the Saved-chats pager paragraph; (2) the phase-189 sentence implying the 8 M total bounds the widened arrays — now states precisely that the total counts `text`/`thinking`/verbatim-block text only, and that the arrays are bounded by fixed item COUNT (matches `_require_within_total_budget`). AGENTS.md verified consistent — it enumerates no save contract or list cap (rule 5's phase-166 in-flight-turn clause untouched), so **no AGENTS.md change** per the phase mandate.
- **Notable/deviation:** the wider caps raise the *theoretical* anonymous-POST worst case (arrays are outside the 8 M char budget); left as designed (A1 fixes every other bound), documented honestly in README — realistic payloads stay ~10 MB, so the existing `client_max_body_size ≥ 16 MB` guidance still holds. No live-infra changes were made (data-repair of the two stale shared rows stays owner action).
- **Next pending phase:** none — `189_chat_save_long_turns` is the last entry in `.agents/phases/todo/`.
This commit is contained in:
2026-10-17 07:36:28 -04:00
parent f20d40e4fe
commit 140a343b92
36 changed files with 2642 additions and 65 deletions
+183 -10
View File
@@ -1859,16 +1859,18 @@ def test_post_rejects_more_than_500_messages_and_stores_nothing(
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
def test_post_rejects_sources_over_20_and_stores_nothing(
def test_post_rejects_sources_over_200_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""One brain message carrying a 21-item ``sources`` list (all valid
``SourceRef`` shapes) 422 (``ChatMessage.sources`` ``max_items=20``
— top-N docs + agent reads) and nothing lands."""
"""One brain message carrying a 201-item ``sources`` list (all valid
``SourceRef`` shapes) 422 (``ChatMessage.sources`` ``max_length=200``
— phase 189 A1: the cited tier is the agent's READ docs, so the cap
follows ``agent_max_rounds`` (production 50) × calls-per-round, not
``top_n_docs``) and nothing lands."""
baseline = len(admin_client.get("/api/chats").json()["chats"])
sources = [
{"source": f"src{i}", "path": f"docs{i}.md", "title": f"Doc {i}"}
for i in range(21)
for i in range(201)
]
r = client.post(
@@ -1885,6 +1887,176 @@ def test_post_rejects_sources_over_20_and_stores_nothing(
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
# ---------- the phase-189 incident payload (owner incident 2026-10-16) ----------
#
# The shared chat 0101d2ca… (row 8b345ab5…) shows ONLY the question: the
# turn's done frame carried 22 sources (the cited tier is the agent's READ
# docs since phase 119 — merge_cited_docs), 33 tool calls and one thinking
# entry per contiguous thinking run, and the done-time auto-save 422'd at
# the pre-phase caps (sources 20 / tools 50 / segments 50). The quiet
# contract swallowed the failure, nothing retried, and Share minted its
# token on the stale row. These pins are the wire-level regression: the
# same shape saves.
def _incident_source(i: int) -> dict[str, Any]:
return {"source": "Homelab", "path": f"notes/doc{i}.md", "title": f"Doc {i}"}
def _incident_brain() -> dict[str, Any]:
"""The incident turn's brain record: 22 ``sources``, 33 ``tools``, 51
``segments`` (the 33 tool entries + 18 thinking entries — one per
contiguous thinking run of a 34-round ``xhigh`` turn)."""
tools: list[dict[str, Any]] = [
{
"name": "read",
"argument": f"Homelab/notes/doc{i}.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
for i in range(33)
]
segments: list[dict[str, Any]] = []
for i, tool in enumerate(tools):
if i < 18: # one thinking entry opens the first 18 runs
segments.append({"kind": "thinking", "text": f"thinking run {i}"})
segments.append({"kind": "tool", **tool})
assert len([s for s in segments if s["kind"] == "thinking"]) == 18
return {
"who": "brain",
"text": "The whole investigation, across every document I read.",
"sources": [_incident_source(i) for i in range(22)],
"related": [_incident_source(i) for i in range(2)],
"deflected": False,
"suggestions": [f"Tell me more about doc {i}" for i in range(5)],
"thinking": "".join(f"run {i} " for i in range(18)),
"tools": tools,
"segments": segments,
}
def _assert_incident_lists(messages: list[dict[str, Any]]) -> None:
"""The brain record of a saved conversation carries all three lists
intact (order included) — nothing silently truncated on the way in."""
brain = messages[1]
assert len(brain["sources"]) == 22
assert len(brain["related"]) == 2
assert len(brain["tools"]) == 33
assert len(brain["segments"]) == 51
assert brain["sources"] == [_incident_source(i) for i in range(22)]
assert brain["segments"] == _incident_brain()["segments"]
assert brain["tools"] == _incident_brain()["tools"]
def test_post_accepts_the_incident_payload_shape(
client: TestClient, admin_client: TestClient, db: Session
) -> None:
"""THE incident regression at the wire: the 22-source / 33-tool /
51-segment done-time auto-save is **201** (pre-phase-189: 422 into the
quiet failure) and the row round-trips all three lists byte-for-byte
— anonymous, exactly as the browser's auto-save posts it."""
r = client.post(
"/api/chats",
json={
"messages": [
_user("Audit my whole homelab and tell me what needs updating"),
_incident_brain(),
]
},
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
body = r.json()
_assert_incident_lists(body["messages"])
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": body["id"]},
).scalar_one()
_assert_incident_lists(raw)
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
_assert_incident_lists(got.json()["messages"])
def test_put_accepts_the_incident_payload_shape(
client: TestClient, admin_client: TestClient
) -> None:
"""The SAME shape through the re-Save upsert (the path the done-time
auto-save actually rides) is **200** and the row's stored messages
bump to the new array — the pre-turn snapshot is replaced, not
rejected."""
created = client.post(
"/api/chats",
json={"messages": [_user("Audit my whole homelab and tell me what needs updating")]},
).json()
assert created["message_count"] == 1
r = client.put(
f"/api/chats/{created['id']}",
json={
"messages": [
_user("Audit my whole homelab and tell me what needs updating"),
_incident_brain(),
]
},
)
assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}"
_assert_incident_lists(r.json()["messages"])
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["message_count"] == 2 # bumped to the new array
_assert_incident_lists(got.json()["messages"])
def test_put_rejects_over_cap_lists_and_leaves_row_unchanged(
client: TestClient, admin_client: TestClient
) -> None:
"""The over-cap variants at the NEW boundaries (201 ``tools``, 451
``segments``, 101 ``suggestions``) each 422 with the framework's
standard body (phase 83 A2: no custom error copy, no route change) and
the row keeps its stored payload byte-for-byte — the rejected body
never reaches the JSONB."""
created = client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
kept = _expect(_simple_conversation())
over_tools = _incident_brain()
over_tools["tools"] = over_tools["tools"] + [over_tools["tools"][0]] * 168 # 201
assert len(over_tools["tools"]) == 201
over_segments = _incident_brain()
over_segments["segments"] = over_segments["segments"] + [
{"kind": "thinking", "text": "one too many"}
] * 400 # 451
assert len(over_segments["segments"]) == 451
over_suggestions = _incident_brain()
over_suggestions["suggestions"] = [f"chip {i}" for i in range(101)]
for bad, needle in (
(over_tools, "tools"),
(over_segments, "segments"),
(over_suggestions, "suggestions"),
):
r = client.put(
f"/api/chats/{created['id']}",
json={"messages": [_user("hi"), bad]},
)
assert r.status_code == 422, f"{needle}: expected 422, got {r.status_code}: {r.text}"
assert needle in json.dumps(r.json()), f"{needle}: 422 must name the offender"
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["messages"] == kept
assert got.json()["message_count"] == 2
def test_put_rejects_oversized_message_and_leaves_row_unchanged(
client: TestClient, admin_client: TestClient
) -> None:
@@ -2137,10 +2309,11 @@ def test_shared_serve_includes_segments(admin_client: TestClient) -> None:
assert "segments" not in body["messages"][0]
def test_segments_over_50_entries_rejected(admin_client: TestClient) -> None:
"""``max_length=50`` (the phase-83 voice, the ``tools`` cap): 51
entries 422 at the boundary — far past any realistic turn (the
round cap is 10 → ≤ 20 entries)."""
def test_segments_over_450_entries_rejected(admin_client: TestClient) -> None:
"""``max_length=450`` (the phase-189 sizing — ≤ 200 tool entries + ≤
~50 thinking runs + margin, replaces the phase-83 "round cap is 10 →
≤ 20 entries"): 451 entries 422 at the boundary while the
incident-shaped 51 (above) saves."""
r = admin_client.post(
"/api/chats",
json={
@@ -2148,7 +2321,7 @@ def test_segments_over_50_entries_rejected(admin_client: TestClient) -> None:
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "x"}] * 51,
"segments": [{"kind": "thinking", "text": "x"}] * 451,
}
]
},