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:
+169
-10
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user