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
+169 -10
View File
@@ -21,6 +21,15 @@ boundaries as the saved-chat mirror, and the 422 ``detail`` SHAPE a cap
rejection produces is pinned as the server-side half of the phase-164
serializer contract (a LIST OF OBJECTS — the live ``[object Object]``
report).
Phase 189 (task 01, A1 — the owner incident of 2026-10-16) moves the five
LIST caps from the round-cap-10 era to the real agent configuration
(``sources``/``related``/``tools`` 200, ``suggestions`` 100, ``segments``
450): the cited tier is the agent's READ docs (``merge_cited_docs``,
phase 119), so its size follows ``BOR_AGENT_MAX_ROUNDS`` (production 50,
the validator unbounded above), and a 22-source done frame used to 422
its own auto-save. The character caps are UNCHANGED; the incident payload
(22 sources / 33 tools / 40 segments) joins the round-trip pins.
"""
from __future__ import annotations
@@ -54,10 +63,26 @@ from app.schemas import (
#: 600 000 ≈ a full-cap answer at ``BOR_MAX_OUTPUT_TOKENS=131_072``
#: (≈ 524 300 chars at ~4 chars/token) with margin.
TEXT_CAP = 600_000
SOURCES_CAP = 20
SUGGESTIONS_CAP = 50
#: Phase 189 (A1, owner incident 2026-10-16): the list caps move from the
#: round-cap-10 era to the real configuration. The cited tier is the
#: agent's READ DOCS since phase 119 (``merge_cited_docs``), so its size
#: follows ``agent_max_rounds`` (production ``BOR_AGENT_MAX_ROUNDS=50``,
#: the validator unbounded above) — 200 is 4× that cap. A 22-source done
#: frame used to 422 its own done-time auto-save (the incident).
SOURCES_CAP = 200
#: The related-doc tier — the same ``SourceRef`` family, the same cap
#: (``related_max_docs`` is owner-tunable with no upper bound).
RELATED_CAP = 200
#: 20× the ``suggested_docs`` default of 5 (also tunable unbounded above).
SUGGESTIONS_CAP = 100
CHIP_CAP = 200
TOOLS_CAP = 50
#: 4× the production round cap — a round is ONE model request and
#: one-or-more tool calls per round are legal.
TOOLS_CAP = 200
#: ≤ the ``tools`` cap (200 tool entries) + ≤ ~50 thinking runs (one per
#: round at the production cap) + margin — the phase-158 "50 matches the
#: tools cap" sizing is retired with the round-cap-10 assumption.
SEGMENTS_CAP = 450
SOURCE_CAP = 120 # documents.source String(120)
PATH_CAP = 1000 # documents.path String(1000)
TITLE_CAP = 500 # documents.title String(500)
@@ -94,6 +119,26 @@ def _tool_call() -> dict:
}
def _thinking_segment(text: str = "a thinking run") -> dict:
"""One contiguous thinking run (the phase-158 timeline entry)."""
return {"kind": "thinking", "text": text}
def _tool_segment(index: int) -> dict:
"""One tool entry in the timeline — the exact ToolCall mirror, the
phase-146 scope ABSENT (a plain read, the omission serializer)."""
return {
"kind": "tool",
"name": "read",
"argument": f"Homelab/doc{index}.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
def _user_message(text: str = "How did I install k3s?") -> ChatMessage:
return ChatMessage.model_validate({"who": "user", "text": text})
@@ -142,7 +187,9 @@ def test_chat_message_thinking_none_still_valid() -> None:
# ---------------------------------------------------------------------------
# ChatMessage.sources / .suggestions / .tools
# ChatMessage.sources / .related / .suggestions / .tools / .segments — the
# phase-189 list caps (the character caps above keep their phase-83/164
# numbers; these five follow the agent's real round configuration)
# ---------------------------------------------------------------------------
@@ -216,6 +263,56 @@ def test_chat_message_tools_one_over_cap_rejects() -> None:
_failed_loc(exc.value, "tools")
def test_chat_message_related_at_cap_validates() -> None:
"""Phase 189 (A1): the related-doc tier carries the SAME
``SourceRef``-family cap as ``sources`` (200) — ``related_max_docs``
is owner-tunable with no upper bound, so the tier must clear the same
worst case or a long turn 422s its own auto-save."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "related": [_source_ref() for _ in range(RELATED_CAP)]}
)
assert len(msg.related or []) == RELATED_CAP
assert RELATED_CAP == SOURCES_CAP # one SourceRef-family cap
def test_chat_message_related_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"related": [_source_ref() for _ in range(RELATED_CAP + 1)],
}
)
_failed_loc(exc.value, "related")
def test_chat_message_segments_at_cap_validates() -> None:
"""The list cap at the phase-189 number (450): the worst-case turn at
the production round cap (200 tool entries + ~50 thinking runs) fits
with margin."""
msg = ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [_thinking_segment() for _ in range(SEGMENTS_CAP)],
}
)
assert len(msg.segments or []) == SEGMENTS_CAP
def test_chat_message_segments_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [_thinking_segment() for _ in range(SEGMENTS_CAP + 1)],
}
)
_failed_loc(exc.value, "segments")
# ---------------------------------------------------------------------------
# ChatRequest.message (the 4,000-char question cap — phase 104, A3)
# ---------------------------------------------------------------------------
@@ -701,6 +798,66 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
assert dumped["share"] is False
def test_incident_long_turn_payload_round_trips() -> None:
"""Phase 189 (task 01, owner incident 2026-10-16) EXTENDS the
realistic-payload family with the shape that actually broke: one long
research turn's brain record carrying 22 ``sources`` (the cited tier
is the agent's READ docs since phase 119), 33 ``tools`` and 40
``segments`` (the 33 tool entries + one thinking entry per contiguous
thinking run — 7 runs). At the pre-phase caps (``sources`` 20,
``tools``/``segments`` 50) this record 422'd its own done-time
auto-save, the phase-55 A2 quiet contract swallowed the failure, and
the shared row kept ONLY the question. It now validates and
``model_dump()``s byte-identically — the omission rules untouched (the
tool entries carry no ``path`` key, so none comes back)."""
sources = [
{"source": "Homelab", "path": f"notes/doc{i}.md", "title": f"Doc {i}"}
for i in range(22)
]
# 33 tool entries interleaved with 7 thinking runs (buildSegments'
# chronological shape: one thinking entry per contiguous run).
segments: list[dict] = []
tools: list[dict] = []
for run in range(7):
segments.append(_thinking_segment(f"thinking run {run}"))
for _ in range(5 if run < 3 else 4): # 3*5 + 4*4 = 31 + 2 = 33
segments.append(_tool_segment(len(tools)))
tools.append(_tool_call())
while len(tools) < 33: # the last run's remaining calls
segments.append(_tool_segment(len(tools)))
tools.append(_tool_call())
assert len(sources) == 22 and len(tools) == 33 and len(segments) == 40
assert sum(1 for s in segments if s["kind"] == "thinking") == 7
record = {
"who": "brain",
"text": "The whole investigation, across 22 documents.",
"sources": sources,
"related": sources[:2],
"deflected": False,
"suggestions": [f"Show doc {i}" for i in range(5)],
"thinking": "".join(f"run {r} " for r in range(7)),
"tools": tools,
"segments": segments,
"stopped": None,
"failed": None,
"error": None,
"budget_exhausted": None,
}
saved = SavedChatCreate.model_validate(
{"title": "long research turn", "messages": [record]}
)
dumped = saved.model_dump()["messages"][0]
assert dumped["sources"] == sources
assert dumped["tools"] == tools
assert dumped["segments"] == segments
# Every entry round-trips through the discriminated union unchanged,
# and the scoped-grep key stays ABSENT (never null) on the plain reads.
assert all("path" not in s for s in dumped["segments"] if s["kind"] == "tool")
assert ChatMessage.model_validate(record).model_dump() == dumped
def test_realistic_payload_round_trips_through_update_model() -> None:
"""Same record re-Saved through ``SavedChatUpdate`` (the re-Save
upsert surface) — identical stored shape."""
@@ -1064,8 +1221,9 @@ def test_segments_tool_entry_mirrors_toolcall_shape() -> None:
def test_segments_caps_mirrored_not_weakened() -> None:
"""The ToolCall caps bind on the segment (mirrored, not
weakened): ``name`` ≤ 100, ``argument`` ≤ 2 000, ``path`` ≤ 2 000,
``offset``/``limit`` ≥ 1, the list ≤ 50 (the phase-83 voice — the
``tools`` cap)."""
``offset``/``limit`` ≥ 1, and the list cap is the phase-189
``SEGMENTS_CAP`` (450) — NOT the ``tools`` cap any more (the two
separated when the round-cap-10 sizing was retired)."""
bad: list[dict] = [
{"kind": "tool", "name": "n" * (NAME_CAP + 1), "argument": None},
{"kind": "tool", "name": "grep", "argument": "a" * (ARGUMENT_CAP + 1)},
@@ -1084,7 +1242,7 @@ def test_segments_caps_mirrored_not_weakened() -> None:
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "x"}] * (TOOLS_CAP + 1),
"segments": [{"kind": "thinking", "text": "x"}] * (SEGMENTS_CAP + 1),
}
)
_failed_loc(exc.value, "segments")
@@ -1205,9 +1363,10 @@ def test_saved_record_answer_text_round_trips_and_stays_optional() -> None:
def test_saved_record_answer_text_list_is_capped() -> None:
"""The list cap (the phase-83 SEC-05 style, the ``tools`` /
``segments`` number): 51 blocks reject, 50 fit — far past the
``show_text_max_blocks`` default of 5."""
"""The list cap (the phase-83 SEC-05 style): 51 blocks reject, 50 fit
— far past the ``show_text_max_blocks`` default of 5. The cap stays 50
through phase 189 (it follows ``show_text_max_blocks``, not the round
count)."""
blocks = [_verbatim_block(locator=f"Homelab/a{i}.md") for i in range(51)]
with pytest.raises(ValidationError):
ChatMessage.model_validate(