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:
+55
-20
@@ -1410,13 +1410,35 @@ class ChatMessage(BaseModel):
|
||||
path accepts", and it survives the raise only if BOTH sides move:
|
||||
raise one and every done-time auto-save of a full-length answer 422s
|
||||
into the phase-113 quiet-failure), and the nested lists get length
|
||||
caps (``max_length``) sized to the realistic ``bor.chat.v1`` record
|
||||
the UI produces (``sources`` ≤ 20 — top-N docs
|
||||
+ agent reads; ``related`` ≤ 20 — the related-doc tier, same ref
|
||||
shape; ``suggestions`` ≤ 50 chips of ≤ 200 chars; ``tools``
|
||||
≤ 50 — one entry per tool call, the round cap is 10). Only value
|
||||
bounds were added — the accepted/rejected KEYS are unchanged. The
|
||||
per-conversation TOTAL is bounded on that same anonymous surface by
|
||||
caps (``max_length``).
|
||||
|
||||
Phase 189 (A1, owner incident 2026-10-16) re-sizes those LIST caps
|
||||
to the real agent configuration. The cited tier has been the agent's
|
||||
READ DOCS since phase 119 (``merge_cited_docs``,
|
||||
``app/api/chat.py`` — retrieval top-N ∪ every document the agent
|
||||
read), so its size is bounded by ``agent_max_rounds`` (production
|
||||
``BOR_AGENT_MAX_ROUNDS=50``; the validator is unbounded above), NOT
|
||||
by ``top_n_docs``. The phase-83 sizing ("sources ≤ 20 — top-N docs +
|
||||
agent reads; tools ≤ 50 — the round cap is 10") was therefore wrong
|
||||
for a long research turn: a 22-source done frame 422'd its own
|
||||
done-time auto-save into the same quiet-failure, and the shared row
|
||||
froze holding only the question. The caps are now
|
||||
``sources`` / ``related`` / ``tools`` **200** (4× the production
|
||||
round cap — a round is ONE model request and one-or-more tool calls
|
||||
per round are legal; ``related`` shares the ``SourceRef``-family cap
|
||||
because ``related_max_docs`` is owner-tunable with no upper bound),
|
||||
``suggestions`` **100** (20× the ``suggested_docs`` default of 5, also
|
||||
tunable unbounded above; the ≤ 200-char chip cap is unchanged) and
|
||||
``segments`` **450** (see the field comment). Only value bounds
|
||||
moved — the accepted/rejected KEYS are unchanged, the 422 stays the
|
||||
framework's standard response and no route changed (the phase-83 A2
|
||||
style). The fixed boundary is what bounds item COUNT (an operator
|
||||
raising ``BOR_AGENT_MAX_ROUNDS`` far past 50 still hits a boundary,
|
||||
never an unbounded list); :data:`SAVED_CHAT_TOTAL_CHARS_BUDGET` is
|
||||
the BYTE bound — the worst-case list bytes (≈ 360 KB of
|
||||
``SourceRef``s, ≈ 440 KB of ``ToolCall``s, ≈ 1 MB of segments) stay
|
||||
orders of magnitude inside the 8 000 000-character total. That
|
||||
per-conversation TOTAL is enforced on the same anonymous surface by
|
||||
:class:`SavedChatCreate` / :class:`SavedChatUpdate`'s character
|
||||
budget (phase 164): the caps above bind per message, so the
|
||||
list-level validator is what keeps the worst case at the audited
|
||||
@@ -1454,9 +1476,11 @@ class ChatMessage(BaseModel):
|
||||
round-trips byte-identically to pre-phase. The field EXISTS so
|
||||
the ``extra="forbid"`` boundary does NOT 422 the done-time
|
||||
auto-save carrying it (the phase-113 lesson, applied up front;
|
||||
the phase-124 ``answer_image`` precedent). ``max_length=50`` (the
|
||||
phase-83 voice): far past any realistic turn — the round cap is
|
||||
10 → ≤ 20 entries; 50 matches the ``tools`` cap. Segments never
|
||||
the phase-124 ``answer_image`` precedent). ``max_length=450`` (the
|
||||
phase-189 sizing, replaces the phase-83 "round cap is 10 → ≤ 20
|
||||
entries"): ≤ 200 ``tool`` entries (the ``tools`` cap — 4× the
|
||||
production round cap of 50) + ≤ ~50 ``thinking`` runs (one per
|
||||
round, the cap-50 worst case) + margin. Segments never
|
||||
travel the history wire (A10 unchanged — the concatenated
|
||||
``thinking`` travels as ``reasoning_content`` exactly as today;
|
||||
:class:`HistoryTurn` and
|
||||
@@ -1467,12 +1491,22 @@ class ChatMessage(BaseModel):
|
||||
|
||||
who: Literal["user", "brain"]
|
||||
text: str = Field(min_length=1, max_length=600_000)
|
||||
sources: list[SourceRef] | None = Field(default=None, max_length=20)
|
||||
related: list[SourceRef] | None = Field(default=None, max_length=20)
|
||||
# Phase 189 (A1): 20 → 200 — the cited tier is the agent's READ DOCS
|
||||
# (``merge_cited_docs``), so its size follows ``agent_max_rounds``
|
||||
# (production 50), not ``top_n_docs``. See the class docstring.
|
||||
sources: list[SourceRef] | None = Field(default=None, max_length=200)
|
||||
# Phase 189 (A1): 20 → 200 — same SourceRef family, same cap
|
||||
# (``related_max_docs`` is owner-tunable with no upper bound).
|
||||
related: list[SourceRef] | None = Field(default=None, max_length=200)
|
||||
deflected: bool | None = None
|
||||
suggestions: list[_Chip] | None = Field(default=None, max_length=50)
|
||||
# Phase 189 (A1): 50 → 100 — 20× the ``suggested_docs`` default of 5
|
||||
# (tunable, unbounded above); the ≤ 200-char chip cap is unchanged.
|
||||
suggestions: list[_Chip] | None = Field(default=None, max_length=100)
|
||||
thinking: str | None = Field(default=None, max_length=600_000)
|
||||
tools: list[ToolCall] | None = Field(default=None, max_length=50)
|
||||
# Phase 189 (A1): 50 → 200 — a round is ONE model request and
|
||||
# one-or-more tool calls per round are legal, so the cap follows
|
||||
# 4× the production round cap, not the round cap itself.
|
||||
tools: list[ToolCall] | None = Field(default=None, max_length=200)
|
||||
# Phase 158 (task 01, D4/D6 — owner direction 2026-10-04): the
|
||||
# turn's CHRONOLOGICAL run order — one ``thinking`` entry per
|
||||
# contiguous thinking run (that run's accumulated text), one
|
||||
@@ -1487,10 +1521,10 @@ class ChatMessage(BaseModel):
|
||||
# byte-identically to pre-phase. The field EXISTS so the
|
||||
# extra="forbid" boundary does NOT 422 the done-time auto-save
|
||||
# carrying it (the phase-113 lesson, applied up front; the
|
||||
# phase-124 answer_image precedent). max_length=50 (the phase-83
|
||||
# voice, the tools cap): far past any realistic turn — the round
|
||||
# cap is 10 → ≤ 20 entries; 50 matches the tools cap.
|
||||
segments: list[ChatSegment] | None = Field(default=None, max_length=50)
|
||||
# phase-124 answer_image precedent). max_length=450 (the phase-189
|
||||
# sizing): ≤ 200 tool entries (the tools cap, 4× the production
|
||||
# round cap of 50) + ≤ ~50 thinking runs (one per round) + margin.
|
||||
segments: list[ChatSegment] | None = Field(default=None, max_length=450)
|
||||
stopped: bool | None = None
|
||||
# Phase 120 (task 01, locked A1): the failed-turn marker + the
|
||||
# persisted error detail (the phase-48 ``stopped`` precedent).
|
||||
@@ -1562,8 +1596,9 @@ class ChatMessage(BaseModel):
|
||||
# :meth:`_drop_image_when_absent` serializer below), so a turn with
|
||||
# no blocks — and every pre-phase record — round-trips
|
||||
# byte-identically (A9). The per-block ``text`` cap is
|
||||
# :class:`VerbatimBlockRef`'s own; the list cap matches the
|
||||
# ``tools``/``segments`` cap (50), far past the
|
||||
# :class:`VerbatimBlockRef`'s own; the list cap stays 50 (the phase-83
|
||||
# number, UNCHANGED by phase 189 — it follows
|
||||
# ``show_text_max_blocks``, not the round count), far past the
|
||||
# ``show_text_max_blocks`` default of 5 — and the block bytes count
|
||||
# toward :data:`SAVED_CHAT_TOTAL_CHARS_BUDGET`, so the display blocks
|
||||
# never become a second, unbounded channel on the anonymous save
|
||||
|
||||
Reference in New Issue
Block a user