phase: 189_chat_save_long_turns
**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:
@@ -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,
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user