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
+49 -5
View File
@@ -285,9 +285,21 @@ List, edit, or remove them here — no conversation required.
### Saved chats (`/history.html`)
Every conversation is saved automatically. Click a title to return to that
chat. Admin-only view. The list is paginated server-side (25 rows per
page) — with more than 25, a Prev / “Page N of M” / Next pager appears
under the table to walk it, and **Refresh** re-fetches the current page.
chat. Admin-only view.
**The auto-save never drops or silently loses a turn** (phase 189). Save points
overlap on a long turn (the send, the answer landing, a page unload), and only
one upsert runs at a time — a save point that arrives mid-upsert is now queued
and re-runs once the in-flight request settles, so the last word always reaches
the row instead of waiting for a next save point that may never come. When a
save does fail, the one-line note under the composer *stays* until the next
successful save (it used to be overwritten and forgotten), and a transient
failure — a network drop or a 5xx — retries by itself after 5 s; a rejected
payload (4xx) never retries, because retrying it would reject it again.
The list is paginated server-side (25 rows per page) — with more than 25, a
Prev / “Page N of M” / Next pager appears under the table to walk it, and
**Refresh** re-fetches the current page.
`GET /api/chats` carries the same contract: `?limit=` (default 25,
clamped 1–100) / `?offset=` and a full `total` on every response.
@@ -325,6 +337,18 @@ file has to let the reader copy it and check it. Both come from the same
owner's), and a guest who follows the viewer link lands on the sign-in page —
the documents API is admin-gated.
**Sharing saves first** (phase 189). **Share** on a conversation that is
already saved writes the CURRENT conversation to its row — the same upsert the
auto-save uses, deleted-row included (a 404 recreates the row and re-links) —
and only then mints the token, so the shared page is the conversation you are
looking at and never a snapshot it has outgrown. The owner incident: a long
research turn whose done-time auto-save was rejected left the row holding only
the question, and the Share froze that loss behind a link. If that save fails
the share **aborts** — the neutral *"Couldn't share the conversation — try
again."* banner, no link, no toast — because a link to content that is not the
current conversation is worse than no link. A conversation that was never
saved still saves **and** shares in one action.
### Conversation payload limits
A conversation has one shape, and it carries the **same static bounds**
@@ -339,10 +363,29 @@ saving a conversation can never be stricter than continuing it (phase
| Turns sent as `history` | 500 | the whole browser conversation (the client sends everything it holds, minus the question being asked) |
| Messages per saved chat | 500 | the same conversation, saved |
| One verbatim block (`answer_text[].text`) | 600 000 chars | the same answer-text cap (phase 182) — comfortably above `BOR_SHOW_TEXT_MAX_CHARS`, so raising that budget never hits the wire limit |
| Verbatim blocks per saved message | 50 | the `tools` / `segments` number (phase 182) — far past the `BOR_SHOW_TEXT_MAX_BLOCKS` default of 5 |
| Verbatim blocks per saved message | 50 | 10× the `BOR_SHOW_TEXT_MAX_BLOCKS` default of 5 (phase 182) — this one follows the display budget, not the round count |
| Cited / related docs per message (`sources`, `related`) | 200 each | **4× the round cap** (production `BOR_AGENT_MAX_ROUNDS=50`) — the citation chips are the documents the agent actually READ, not just the top-N retrieved, so one long research turn cites roughly one document per round and more (phase 189) |
| Tool calls per message (`tools`) | 200 | the same 4× the round cap: a round is one model request, and one-or-more tool calls per round are legal (phase 189) |
| Timeline entries per message (`segments`) | 450 | the thinking/tool timeline of that same turn — ≤ 200 tool entries + one thinking entry per contiguous thinking run (≤ ~50) + margin (phase 189) |
| Suggestion chips per message | 100 | 20× the `BOR_SUGGESTED_DOCS` default of 5; each chip stays ≤ 200 chars (phase 189) |
| **Total chars per saved chat** | **8 000 000** | ~15 full-length answers; the one bound on the anonymous save surface, which stays bounded on purpose — and it counts the verbatim blocks' bytes too (phase 182), so the display blocks cannot become a second, unbounded byte channel |
| One question (`message`) | 4 000 chars | unchanged; the composer counts it live |
**The list caps follow the agent's real round cap** (phase 189). They were
sized for the shipped `BOR_AGENT_MAX_ROUNDS` default of 10, when the citation
row was assumed to be "the top-N docs plus a handful of reads"; since phase
119 the cited tier *is* the agent's read documents, so a long research turn —
like the owner's, on a container running `BOR_AGENT_MAX_ROUNDS=50` — cites two
dozen documents. Such a turn used to be **rejected by its own auto-save** (a
silent 422 at the old 20-source cap, swallowed by the quiet-failure contract),
which is why a shared chat could hold only the question. An operator raising
`BOR_AGENT_MAX_ROUNDS` far past 50 still hits a fixed boundary — the caps
scale with the configuration, not with a guess. The byte bound is unchanged
too: the 8 000 000-character total above counts each message's `text`,
`thinking` and verbatim-block text (never the citation/tool arrays), which is
exactly why those arrays keep a fixed item COUNT instead of a byte count —
neither channel can grow without a limit.
These are sanity ceilings, not the prompt's budget: the real gate is
`BOR_HISTORY_MAX_TURNS` / `BOR_HISTORY_MAX_CHARS`, and a turn that does
not fit the budget is dropped **whole** from later prompts (never cut
@@ -1303,7 +1346,8 @@ The model never retypes quoted text, so a displayed quote cannot drift.
model's, never a silently-substituted hybrid. Buttons appear when the turn
settles, never mid-stream. The share link's copy path delegates to the same
`copyTextWithFallback`, so the http-origin inline select-to-copy degradation
exists once (the owner-locked share strings and behavior are unchanged).
exists once (the owner-locked share strings are unchanged; phase 189 added
the save-before-the-token upsert in front of the same success paths).
- **Parity, and what stays untouched.** The live turn, a reload and an
anonymous shared page call the same three globals in the same order, so one
answer renders one way. Only the two answer renderers (plus the shared