Files
brain-of-reese/tests/unit/test_schemas.py
T
ducoterra 140a343b92
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_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/`.
2026-10-17 07:36:28 -04:00

1404 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: schema boundary caps for the saved-chat surface (phase 83, task 01).
The first schema-boundary suite: every A1 cap in ``app/schemas.py`` is
pinned at BOTH boundaries — a value exactly at the cap validates and one
past it raises a pydantic ``ValidationError`` naming the offending field
(the 422-shaped boundary response, house style, phase 56 precedent).
Plus the regression pin: a realistic ``bor.chat.v1`` payload validates
cleanly and round-trips ``model_dump()`` (the stored-shape contract —
the caps added value bounds only, no key/shape change).
Phase 164 (task 01, locked A1) re-cuts the long-answer chain —
``HistoryTurn.text``/``.thinking``, ``ChatMessage.text``/``.thinking``
and the thinking-segment cap to 600 000 (a FULL-cap answer at
``BOR_MAX_OUTPUT_TOKENS=131_072``, ≈ 524 300 chars), the history list
to 500 and the saved-messages list to 500, and adds the ONE new bound:
the ``SavedChatCreate``/``SavedChatUpdate`` total-character budget
(8 000 000) that keeps the anonymous SEC-05 surface bounded now that a
single message may hold a full-length answer. The chat-path caps are
pinned here too (``HistoryTurn`` + ``ChatRequest.history``) — same
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
from typing import Any
import pytest
from pydantic import ValidationError
from app.schemas import (
SAVED_CHAT_TOTAL_CHARS_BUDGET,
ChatMessage,
ChatRequest,
ChatSegment,
ChatThinkingSegment,
ChatToolSegment,
HistoryTurn,
SavedChatCreate,
SavedChatUpdate,
SourceRef,
ToolCall,
VerbatimBlockRef,
)
# --- boundary constants (phase 83, A1 — mirror sources in app/schemas.py) ---
#: Phase 164 (locked A1): 32 000 → 600 000. ONE constant for the whole
#: mirror — ``HistoryTurn.text``/``.thinking`` (the chat path),
#: ``ChatMessage.text``/``.thinking`` and the ``segments`` thinking
#: entry: the identical-boundary test IS the proof the phase-83 mirror
#: invariant survived the raise (raise one side and these pins fail).
#: 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
#: 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
#: 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)
NAME_CAP = 100 # ToolCall.name
ARGUMENT_CAP = 2000 # ToolCall.argument
MESSAGES_CAP = 500 # SavedChatCreate/Update.messages (phase 164: was 200)
HISTORY_CAP = 500 # ChatRequest.history (phase 164: was 100)
QUESTION_CAP = 4_000 # ChatRequest.message — the cap the composer mirrors (phase 104)
#: The phase-164 anonymous-surface bound, imported from the source (not
#: restated) so the schema and the suite cannot drift apart.
TOTAL_BUDGET = SAVED_CHAT_TOTAL_CHARS_BUDGET
#: A realistic full-cap answer length (131 072 output tokens at ~4
#: chars/token) — the payload the whole chain must now carry.
FULL_CAP_ANSWER = 524_300
def _source_ref() -> dict:
return {"source": "Homelab", "path": "kubernetes.md", "title": "K8s"}
def _tool_call() -> dict:
# Phase 95 (task 02): the truncation record rides the same entry —
# the CURRENT full shape (additive fields, defaults for a plain read).
# Phase 139 (task 02): the read's requested range joins the same
# entry (additive — None for a plain read, the phase-95 pattern).
return {
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
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})
def _failed_loc(exc: ValidationError, *loc: object) -> None:
"""Assert the first ``ValidationError`` names exactly the given
field path (``loc`` tuple, e.g. ``("text",)`` or
``("messages", 0, "text")``) — the 422 must point at the offender."""
assert exc.errors()[0]["loc"] == tuple(loc), exc.errors()
# ---------------------------------------------------------------------------
# ChatMessage.text / .thinking
# ---------------------------------------------------------------------------
def test_chat_message_text_at_cap_validates() -> None:
msg = _user_message("a" * TEXT_CAP)
assert len(msg.text) == TEXT_CAP
def test_chat_message_text_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
_user_message("a" * (TEXT_CAP + 1))
_failed_loc(exc.value, "text")
def test_chat_message_thinking_at_cap_validates() -> None:
msg = ChatMessage.model_validate({"who": "brain", "text": "ok", "thinking": "t" * TEXT_CAP})
assert len(msg.thinking or "") == TEXT_CAP
def test_chat_message_thinking_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "thinking": "t" * (TEXT_CAP + 1)}
)
_failed_loc(exc.value, "thinking")
def test_chat_message_thinking_none_still_valid() -> None:
assert _user_message().thinking is None
assert ChatMessage.model_validate(
{"who": "brain", "text": "ok", "thinking": None}
).thinking is None
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
def test_chat_message_sources_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "sources": [_source_ref() for _ in range(SOURCES_CAP)]}
)
assert len(msg.sources or []) == SOURCES_CAP
def test_chat_message_sources_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"sources": [_source_ref() for _ in range(SOURCES_CAP + 1)],
}
)
_failed_loc(exc.value, "sources")
def test_chat_message_suggestions_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": [f"chip {i}" for i in range(SUGGESTIONS_CAP)]}
)
assert len(msg.suggestions or []) == SUGGESTIONS_CAP
def test_chat_message_suggestions_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"suggestions": [f"chip {i}" for i in range(SUGGESTIONS_CAP + 1)],
}
)
_failed_loc(exc.value, "suggestions")
def test_chat_message_suggestion_chip_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": ["c" * CHIP_CAP]}
)
assert msg.suggestions is not None
assert len(msg.suggestions[0]) == CHIP_CAP
def test_chat_message_suggestion_chip_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": ["c" * (CHIP_CAP + 1)]}
)
# the offender is the ITEM (the list itself is well under max_items).
_failed_loc(exc.value, "suggestions", 0)
def test_chat_message_tools_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "tools": [_tool_call() for _ in range(TOOLS_CAP)]}
)
assert len(msg.tools or []) == TOOLS_CAP
def test_chat_message_tools_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "tools": [_tool_call() for _ in range(TOOLS_CAP + 1)]}
)
_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)
# ---------------------------------------------------------------------------
def test_chat_request_message_at_cap_validates() -> None:
"""EXACTLY 4,000 chars passes — the cap admits "a code example of a
few dozen lines" (owner A3) and the composer's maxlength + counter
mirror this exact boundary (the 4,000/4,000 E2E submit must not
422)."""
req = ChatRequest.model_validate({"message": "a" * QUESTION_CAP})
assert len(req.message) == QUESTION_CAP
def test_chat_request_message_one_over_cap_rejects() -> None:
"""4,001 chars is the 422 the UI now prevents blind: the server cap
stays the backstop (pre-existing, untouched — this phase only pins
it at the boundary, phase-83 pattern), naming ``message`` in the
error loc."""
with pytest.raises(ValidationError) as exc:
ChatRequest.model_validate({"message": "a" * (QUESTION_CAP + 1)})
_failed_loc(exc.value, "message")
# ---------------------------------------------------------------------------
# HistoryTurn + ChatRequest.history — the CHAT path of the long-answer
# chain (phase 164, task 01, locked A1). The live 422 this phase fixes:
# a >32k-char brain answer rode back as ``history`` on the NEXT turn and
# rejected it, so the follow-up of any long answer died with
# "[object Object]". Same boundaries as the saved-chat mirror above.
# ---------------------------------------------------------------------------
def test_history_turn_text_at_cap_validates() -> None:
"""600 000 chars — a full-cap answer (≈ 524 300 at
``BOR_MAX_OUTPUT_TOKENS=131_072``) flows back as ``history``:
the exact request shape the browser sends on the turn AFTER a long
answer."""
turn = HistoryTurn(who="brain", text="a" * TEXT_CAP)
assert len(turn.text) == TEXT_CAP
# And the realistic full-cap answer, not just the boundary value.
assert len(HistoryTurn(who="brain", text="a" * FULL_CAP_ANSWER).text) == FULL_CAP_ANSWER
def test_history_turn_text_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
HistoryTurn(who="brain", text="a" * (TEXT_CAP + 1))
_failed_loc(exc.value, "text")
def test_history_turn_thinking_at_cap_validates() -> None:
turn = HistoryTurn(who="brain", text="ok", thinking="t" * TEXT_CAP)
assert len(turn.thinking or "") == TEXT_CAP
def test_history_turn_thinking_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
HistoryTurn(who="brain", text="ok", thinking="t" * (TEXT_CAP + 1))
_failed_loc(exc.value, "thinking")
def test_history_turn_caps_match_the_saved_chat_mirror() -> None:
"""The phase-83 mirror invariant, verified by identical boundaries
(not by comment): a payload at the cap validates on BOTH models and
one past it fails on BOTH — ``HistoryTurn`` (the chat path) and
``ChatMessage`` (the saved-chat path). Raise one side in
``app/schemas.py`` and this pin fails."""
for length, rejected in ((TEXT_CAP, False), (TEXT_CAP + 1, True)):
payload = {"who": "brain", "text": "a" * length, "thinking": "t" * length}
for model in (HistoryTurn, ChatMessage):
if rejected:
with pytest.raises(ValidationError):
model.model_validate(payload)
else:
model.model_validate(payload)
def test_chat_request_history_at_cap_validates() -> None:
"""500 turns pass — the client sends the WHOLE ``bor.chat.v1``
record minus the current question, so the ceiling is a DoS sanity
bound only (the config budgets trim for real, locked A4)."""
req = ChatRequest.model_validate(
{"message": "hi", "history": [{"who": "user", "text": f"q{i}"} for i in range(HISTORY_CAP)]}
)
assert len(req.history) == HISTORY_CAP
def test_chat_request_history_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatRequest.model_validate(
{
"message": "hi",
"history": [
{"who": "user", "text": f"q{i}"} for i in range(HISTORY_CAP + 1)
],
}
)
_failed_loc(exc.value, "history")
def test_cap_rejection_detail_is_a_list_of_objects() -> None:
"""The server-side half of the phase-164 contract (task 02's input):
a cap rejection is the pydantic 422 ``detail`` — a LIST OF OBJECTS
(``loc``/``msg``/``type``), never a plain string. This is exactly the
shape the client turned into ``[object Object]``; the serializer must
read ``msg`` and never the ``input`` (which carries the whole
conversation)."""
with pytest.raises(ValidationError) as exc:
HistoryTurn(who="brain", text="a" * (TEXT_CAP + 1))
detail = exc.value.errors()
assert isinstance(detail, list) and len(detail) == 1
(entry,) = detail
assert entry["loc"] == ("text",)
assert entry["type"] == "string_too_long"
# The message the UI shows verbatim in the failed-turn note.
assert entry["msg"] == f"String should have at most {TEXT_CAP} characters"
# The raw input DOES ride the error object server-side — which is why
# the client serializer only ever reads ``msg``.
assert entry["input"].startswith("aaaa")
# ---------------------------------------------------------------------------
# SourceRef (documents column-length mirrors)
# ---------------------------------------------------------------------------
def test_source_ref_source_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "s" * SOURCE_CAP, "path": "a.md", "title": "A"}
)
assert len(ref.source) == SOURCE_CAP
def test_source_ref_source_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate({"source": "s" * (SOURCE_CAP + 1), "path": "a.md", "title": "A"})
_failed_loc(exc.value, "source")
def test_source_ref_path_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "Homelab", "path": "p" * PATH_CAP, "title": "A"}
)
assert len(ref.path) == PATH_CAP
def test_source_ref_path_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate(
{"source": "Homelab", "path": "p" * (PATH_CAP + 1), "title": "A"}
)
_failed_loc(exc.value, "path")
def test_source_ref_title_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "Homelab", "path": "a.md", "title": "t" * TITLE_CAP}
)
assert len(ref.title) == TITLE_CAP
def test_source_ref_title_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate(
{"source": "Homelab", "path": "a.md", "title": "t" * (TITLE_CAP + 1)}
)
_failed_loc(exc.value, "title")
# ---------------------------------------------------------------------------
# ToolCall
# ---------------------------------------------------------------------------
def test_tool_call_name_at_cap_validates() -> None:
call = ToolCall.model_validate({"name": "n" * NAME_CAP, "argument": None})
assert len(call.name) == NAME_CAP
def test_tool_call_name_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ToolCall.model_validate({"name": "n" * (NAME_CAP + 1), "argument": None})
_failed_loc(exc.value, "name")
def test_tool_call_argument_at_cap_validates() -> None:
call = ToolCall.model_validate({"name": "read", "argument": "a" * ARGUMENT_CAP})
assert len(call.argument or "") == ARGUMENT_CAP
def test_tool_call_argument_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ToolCall.model_validate({"name": "read", "argument": "a" * (ARGUMENT_CAP + 1)})
_failed_loc(exc.value, "argument")
def test_tool_call_argument_none_still_valid() -> None:
assert ToolCall.model_validate({"name": "ls", "argument": None}).argument is None
assert ToolCall.model_validate({"name": "ls"}).argument is None
# ---------------------------------------------------------------------------
# SavedChatCreate / SavedChatUpdate .messages
# ---------------------------------------------------------------------------
def _minimal_messages(n: int) -> list[dict]:
return [{"who": "user" if i % 2 == 0 else "brain", "text": f"m{i}"} for i in range(n)]
def test_saved_chat_create_messages_at_cap_validates() -> None:
payload = SavedChatCreate.model_validate(
{"title": "big", "messages": _minimal_messages(MESSAGES_CAP)}
)
assert len(payload.messages) == MESSAGES_CAP
def test_saved_chat_create_messages_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SavedChatCreate.model_validate(
{"title": "bigger", "messages": _minimal_messages(MESSAGES_CAP + 1)}
)
_failed_loc(exc.value, "messages")
def test_saved_chat_update_messages_at_cap_validates() -> None:
payload = SavedChatUpdate.model_validate({"messages": _minimal_messages(MESSAGES_CAP)})
assert len(payload.messages) == MESSAGES_CAP
def test_saved_chat_update_messages_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SavedChatUpdate.model_validate({"messages": _minimal_messages(MESSAGES_CAP + 1)})
_failed_loc(exc.value, "messages")
# ---------------------------------------------------------------------------
# The phase-164 total-character budget (locked A1) — the ONE new bound on
# the anonymous saved-chat surface. The per-message caps went to 600 000,
# so the LIST is bounded by total characters instead of by an item count
# that a full-cap answer would blow through: 8 000 000 keeps the worst
# case at the order the phase-83 SEC-05 audit accepted (200 × 64k
# ≈ 12.8 MB) while fitting ~15 full-cap answers.
# ---------------------------------------------------------------------------
def _full_cap_messages(count: int, *, size: int = FULL_CAP_ANSWER) -> list[dict]:
"""``count`` brain answers at the realistic full-cap length."""
return [{"who": "brain", "text": "a" * size} for _ in range(count)]
def test_saved_chat_within_total_budget_of_full_cap_answers_validates() -> None:
"""~15 full-cap answers (15 × 524 300 = 7 864 500 chars, the biggest
whole-count fit under 8 000 000) validate — the budget is a sanity
bound, not a practical limit on a real conversation."""
fits = TOTAL_BUDGET // FULL_CAP_ANSWER
assert fits == 15
payload = SavedChatCreate.model_validate({"messages": _full_cap_messages(fits)})
assert len(payload.messages) == fits
def test_saved_chat_over_total_budget_rejects_with_the_budget_message() -> None:
"""One full-cap answer past the fit → ValidationError whose ``msg``
NAMES the budget (the 422 the anonymous caller sees), on BOTH the
create and the update model — the same validator on both bodies."""
over = _full_cap_messages(TOTAL_BUDGET // FULL_CAP_ANSWER + 1)
for model in (SavedChatCreate, SavedChatUpdate):
with pytest.raises(ValidationError) as exc:
model.model_validate({"messages": over})
(error,) = exc.value.errors()
assert error["loc"] == () # a model-level validator, not a field
assert "8,000,000-character budget" in error["msg"]
def test_saved_chat_one_full_cap_answer_plus_normal_turns_validates() -> None:
"""The realistic case the live report blocked: a saved chat carrying
ONE 525k-char brain answer alongside ordinary turns."""
payload = SavedChatCreate.model_validate(
{
"messages": [
{"who": "user", "text": "Write me the whole runbook."},
{"who": "brain", "text": "a" * 525_000},
{"who": "user", "text": "And the rollback?"},
{"who": "brain", "text": "Short rollback."},
]
}
)
assert len(payload.messages) == 4
def test_saved_chat_total_budget_counts_thinking() -> None:
"""``thinking`` counts against the budget exactly like ``text`` (the
history budget's own accounting, mirrored): 15 turns of 280 000 text
+ 280 000 thinking = 8 400 000 REJECTS even though the text alone is
only 4 200 000 — a text-only accounting would have waved it
through."""
turns = 15
size = 280_000
assert turns * size <= TOTAL_BUDGET < turns * (size * 2)
heavy = [
{"who": "brain", "text": "a" * size, "thinking": "t" * size}
for _ in range(turns)
]
with pytest.raises(ValidationError) as exc:
SavedChatCreate.model_validate({"messages": heavy})
assert "8,000,000-character budget" in exc.value.errors()[0]["msg"]
# One turn fewer fits (14 × 560 000 = 7 840 000).
assert len(SavedChatCreate.model_validate({"messages": heavy[:-1]}).messages) == 14
def test_saved_chat_at_exactly_the_total_budget_validates() -> None:
"""The boundary is INCLUSIVE: exactly 8 000 000 characters across the
messages passes (13 at the 600 000 cap + one of 200 000), one
character more rejects."""
def _conversation(last_size: int) -> list[dict]:
return [{"who": "brain", "text": "a" * TEXT_CAP} for _ in range(13)] + [
{"who": "brain", "text": "b" * last_size}
]
at = SavedChatCreate.model_validate({"messages": _conversation(200_000)})
assert sum(len(m.text) for m in at.messages) == TOTAL_BUDGET
with pytest.raises(ValidationError) as exc:
SavedChatCreate.model_validate({"messages": _conversation(200_001)})
assert "8,000,000-character budget" in exc.value.errors()[0]["msg"]
# ---------------------------------------------------------------------------
# JSON-shape regression: same KEYS as before, value bounds only
# ---------------------------------------------------------------------------
def test_extra_keys_still_forbidden() -> None:
"""``extra="forbid"`` is untouched — a stray key is still a 422-shaped
rejection (the accepted/rejected KEYS did not change with phase 83)."""
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate({"who": "user", "text": "hi", "html": "<b>x</b>"})
_failed_loc(exc.value, "html")
def test_minimal_message_still_validates() -> None:
"""Optional keys may be ABSENT exactly as pre-phase-83."""
msg = ChatMessage.model_validate({"who": "user", "text": "hi"})
assert (
msg.sources,
msg.related,
msg.deflected,
msg.suggestions,
msg.thinking,
msg.tools,
msg.stopped,
msg.failed,
msg.error,
) == (
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
# ---------------------------------------------------------------------------
# Realistic-payload round-trip (the stored-shape contract)
# ---------------------------------------------------------------------------
def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"""A full ``bor.chat.v1``-shaped record (4–8 messages mixing
user/brain, one brain message with ``thinking`` + ``tools`` +
``sources``, one with ``suggestions`` + ``stopped``) validates
cleanly and ``model_dump()`` of the messages equals the input dict —
``None``-keys preserved (the phase-50/51 byte-identical stored shape;
the caps added value bounds only, no key/shape change)."""
messages: list[dict] = [
{
"who": "user",
"text": "How did I install k3s on the new node?",
"sources": None,
"related": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
"failed": None,
"error": None,
"budget_exhausted": None,
},
{
"who": "brain",
"text": "Your k3s cluster runs on three nodes — here's how it happened.",
"sources": [
{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes Cluster"},
{"source": "Deployments", "path": "k3s-install.md", "title": "k3s Install Notes"},
],
# Phase 113 related-doc tier — an ACCEPTED key (the phase-113
# omission of this field made extra="forbid" 422 every
# done-time auto-save carrying it, so grounded turns' brain
# messages never persisted — the A2 quiet failure swallowed
# the 422). Round-trips like sources.
"related": [
{"source": "Homelab", "path": "traefik.md", "title": "Traefik Notes"}
],
"deflected": False,
"suggestions": None,
"thinking": "The kubernetes doc covers the cluster layout…",
"tools": [
# Phase 95 (task 02): the current full shape — one
# truncated read (the marker record) + a plain ls.
# Phase 139 (task 02): the truncated read is a RANGE
# read — the requested offset/limit ride the same entry
# so a save → restore re-renders the "(lines 42–71)"
# suffix; the ls carries no range (None, as serialized).
{
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
"offset": 42,
"limit": 30,
},
{
"name": "ls",
"argument": None,
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
},
],
"stopped": None,
"failed": None,
"error": None,
"budget_exhausted": None,
},
{
"who": "user",
"text": "And what ports does Traefik expose?",
"sources": None,
"related": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
"failed": None,
"error": None,
"budget_exhausted": None,
},
{
"who": "brain",
"text": "Traefik exposes 80/443 on every node.",
"sources": None,
"related": None,
"deflected": None,
"suggestions": ["What is the Traefik dashboard password?"],
"thinking": None,
"tools": None,
"stopped": True, # the owner stopped the generation mid-answer
"failed": None,
"error": None,
"budget_exhausted": None,
},
]
payload = SavedChatCreate.model_validate({"title": "k3s install", "messages": messages})
dumped = payload.model_dump()
# the stored shape is byte-identical: every message dict — None-keys
# and all — round-trips unchanged (and the top-level record keeps its
# ``share`` default). The phase-160 additive ``budget_exhausted``
# marker joins as an explicit null (the phase-120 ``failed`` /
# phase-48 ``stopped`` style — ``None`` flows as null, the
# phase-50 byte-identical contract covers the new key).
assert dumped["messages"] == messages
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."""
msg = {
"who": "brain",
"text": "answer",
"sources": [_source_ref()],
"related": None,
"deflected": None,
"suggestions": ["follow-up?"],
"thinking": "scratchpad",
"tools": [_tool_call()],
"stopped": None,
"failed": None,
"error": None,
"budget_exhausted": None,
}
payload = SavedChatUpdate.model_validate({"messages": [msg]})
# Identical stored shape (the phase-160 additive ``budget_exhausted``
# marker rides as an explicit null — the phase-120/48 style).
assert payload.model_dump()["messages"] == [msg]
# ---------------------------------------------------------------------------
# Phase 95 (task 02): ToolCall truncation fields + ChatToolResultEvent
# ---------------------------------------------------------------------------
def test_tool_call_round_trips_with_truncation_fields() -> None:
"""The CURRENT full shape (phase 95 task 02 + phase 139 task 02):
a truncated read's record (``truncated: True`` + the two non-negative
counts + the additive range fields, absent for a plain read) validates
and round-trips ``model_dump()`` unchanged — the save payload carries
it with zero other change (the UI re-renders the marker — and, for a
range read, the "(lines N–M)" suffix — from it)."""
raw = {
"name": "read",
"argument": "Homelab/big.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
"offset": None,
"limit": None,
}
call = ToolCall.model_validate(raw)
assert call.truncated is True
assert call.chars_shown == 128_000
assert call.chars_total == 204_000
assert call.offset is None and call.limit is None
assert call.model_dump() == raw
def test_tool_call_range_fields_round_trip() -> None:
"""Phase 139 (task 02): a RANGE read's record (``offset`` +
``limit``, the phase-131 wire fields) validates and round-trips
``model_dump()`` unchanged — the saved chat's ``tools`` JSONB keeps
the range, so the chat page's restore path and the shared page
re-render the "(lines N–M)" suffix from the stored row. Only ONE of
the two is also a real shape (the model may pass a start without a
count — the UI's "(from line N)" line)."""
both = ToolCall.model_validate(
{"name": "read", "argument": "Homelab/big.md", "offset": 42, "limit": 30}
)
assert (both.offset, both.limit) == (42, 30)
assert both.model_dump() == {
"name": "read",
"argument": "Homelab/big.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": 42,
"limit": 30,
}
offset_only = ToolCall.model_validate(
{"name": "read", "argument": "Homelab/big.md", "offset": 7}
)
assert (offset_only.offset, offset_only.limit) == (7, None)
limit_only = ToolCall.model_validate(
{"name": "read", "argument": "Homelab/big.md", "limit": 20}
)
assert (limit_only.offset, limit_only.limit) == (None, 20)
def test_tool_call_range_fields_reject_zero_or_negative() -> None:
"""Phase 139 (task 02) bounds (the phase-83 philosophy, the UI's
guard mirrored): a line number is >= 1 (the server's
``_whole_line_number`` bound — 0 / negative are not real ranges, and
the UI never legitimately sends them; the bool exclusion lives on
the SSE-side guard, where frames are minted)."""
for bad_offset, bad_limit in ((0, None), (None, 0), (-3, None), (None, -1)):
with pytest.raises(ValidationError):
ToolCall.model_validate(
{
"name": "read",
"argument": "x",
"offset": bad_offset,
"limit": bad_limit,
}
)
def test_tool_call_old_shape_validates_with_defaults() -> None:
"""Backward-compat (the phase-50 rule): a saved chat written BEFORE
phase 95 — tool records without the truncation fields — validates
UNCHANGED: ``truncated`` defaults to False (the marker is absent),
the counts to None. No migration (``ChatMessage.tools`` is JSON);
only the dump gains the additive keys with their defaults."""
old_shape = {"name": "read", "argument": "Homelab/kubernetes.md"}
call = ToolCall.model_validate(old_shape)
assert call.truncated is False
assert call.chars_shown is None
assert call.chars_total is None
dumped = call.model_dump()
assert dumped["name"] == "read" and dumped["argument"] == "Homelab/kubernetes.md"
assert dumped["truncated"] is False
assert dumped["chars_shown"] is None and dumped["chars_total"] is None
def test_tool_call_old_shape_message_still_round_trips_as_record() -> None:
"""The record-level backward-compat: a pre-phase-95 brain message
(old-shape ``tools``) validates inside ``ChatMessage`` and dumps back
as a VALID record of the same shape (the frontend renders it without
the marker — ``truncated`` falsy)."""
old_message = {
"who": "brain",
"text": "You've got this!",
"sources": None,
"deflected": False,
"suggestions": None,
"thinking": None,
"tools": [
{"name": "read", "argument": "Homelab/kubernetes.md"},
{"name": "read_document", "argument": "Homelab/legacy.md"},
],
"stopped": None,
}
msg = ChatMessage.model_validate(old_message)
assert all(t.truncated is False for t in (msg.tools or []))
# Re-validating the dump is a no-op (lossless record round-trip).
ChatMessage.model_validate(msg.model_dump())
def test_tool_call_counts_reject_negative() -> None:
"""Phase 83 bounds philosophy: the counts are non-negative
(``ge=0``) — a negative count is not a real record."""
with pytest.raises(ValidationError):
ToolCall.model_validate(
{
"name": "read",
"argument": "x",
"truncated": True,
"chars_shown": -1,
"chars_total": 5,
}
)
with pytest.raises(ValidationError):
ToolCall.model_validate(
{
"name": "read",
"argument": "x",
"truncated": True,
"chars_shown": 5,
"chars_total": -1,
}
)
def test_chat_tool_result_event_shape() -> None:
"""The A15 extension's wire shape (phase 95 task 02): the seventh,
OPTIONAL SSE event type — ``{type, name, argument, truncated,
chars_shown, chars_total}`` — with the pinned field order, the
``tool_result`` default, ``truncated`` defaulting True (the emission
trigger), and the non-negative counts."""
from app.schemas import ChatToolResultEvent
ev = ChatToolResultEvent(
name="read",
argument="docs/big.md",
truncated=True,
chars_shown=128_000,
chars_total=204_000,
)
dumped = ev.model_dump()
assert list(dumped) == [
"type",
"name",
"argument",
"truncated",
"chars_shown",
"chars_total",
]
assert dumped == {
"type": "tool_result",
"name": "read",
"argument": "docs/big.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
}
# The emission trigger defaults: the pump always builds the frame from
# a piece, so a frame that ever exists carries the truncation truth.
minimal = ChatToolResultEvent(
name="read", argument=None, chars_shown=0, chars_total=0
).model_dump()
assert minimal["truncated"] is True
def test_chat_tool_result_event_counts_reject_negative() -> None:
"""The frame's counts are non-negative (``ge=0``), like the record's."""
from app.schemas import ChatToolResultEvent
with pytest.raises(ValidationError):
ChatToolResultEvent(name="read", argument="x", chars_shown=-1, chars_total=5)
with pytest.raises(ValidationError):
ChatToolResultEvent(name="read", argument="x", chars_shown=5, chars_total=-1)
# ---------------------------------------------------------------------------
# Phase 158 (task 01): the ChatMessage.segments field — the turn's
# chronological run order (D4/D6, owner direction 2026-10-04): the
# discriminated thinking/tool union (ChatSegment), capped phase-83-style
# (mirrored, not weakened — ToolCall is the shape source), OMITTED when
# absent (the phase-50 byte-identical round-trip contract — ABSENT,
# never null), the extra="forbid" boundary intact around the new key.
# ---------------------------------------------------------------------------
def test_segments_absent_by_default_and_omitted_in_dump() -> None:
"""A record WITHOUT ``segments`` (every pre-phase shape, every
no-thinking/no-tools turn) validates with ``None`` and dumps
WITHOUT the key — ABSENT, never ``null`` (the phase-50 round-trip
contract; the wrap serializer does the dropping on every surface).
The dump stays byte-identical to the pre-phase shape."""
msg = ChatMessage.model_validate({"who": "brain", "text": "ok"})
assert msg.segments is None
dumped = msg.model_dump()
assert "segments" not in dumped
# Byte-identical to the pre-phase stored shape: every bor.chat.v1
# optional key present (null) — the phase-160 additive
# ``budget_exhausted`` marker included (the phase-120 ``failed`` /
# phase-48 ``stopped`` style — explicit null where absent) — and
# the three serializer-popped keys (image / answer_image /
# segments) absent.
assert dumped == {
"who": "brain",
"text": "ok",
"sources": None,
"related": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
"failed": None,
"error": None,
"budget_exhausted": None,
}
# An explicit empty list is a different thing: [] is a value, so it
# round-trips as [] (present, empty — the pop is None-gated only).
empty = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": []}
).model_dump()
assert empty["segments"] == []
def test_segments_thinking_entry_round_trips_and_caps() -> None:
"""The thinking entry: ``min_length=1`` (the zero-char run the
client never stores) + ``max_length=600_000`` (HistoryTurn's cap,
phase 83) — at the cap validates, one past raises naming the
offender; a missing ``text`` too."""
at_cap = ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "t" * TEXT_CAP}],
}
)
assert at_cap.segments == [
ChatThinkingSegment(kind="thinking", text="t" * TEXT_CAP)
]
assert at_cap.model_dump()["segments"] == [
{"kind": "thinking", "text": "t" * TEXT_CAP}
]
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "t" * (TEXT_CAP + 1)}],
}
)
# Pydantic 2.13 names the union variant in the loc — the 422 still
# points at the offender (segments[0].text).
_failed_loc(exc.value, "segments", 0, "thinking", "text")
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [{"kind": "thinking", "text": ""}]}
)
_failed_loc(exc.value, "segments", 0, "thinking", "text")
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [{"kind": "thinking"}]}
)
def test_segments_tool_entry_mirrors_toolcall_shape() -> None:
"""The tool entry is the EXACT ToolCall field set (mirrored, not
weakened — phase-37 pair + phase-95 truncation stamp + phase-139
range + phase-146 scoped path): a full entry round-trips
byte-for-byte; a NON-scoped entry dumps WITHOUT ``path`` (ABSENT,
never ``null`` — the ToolCall omission rule mirrored, so a
``segments`` entry and the matching ``tools`` entry of the same
record stay the same data); a scoped one keeps it."""
full = {
"kind": "tool",
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
"offset": 42,
"limit": 30,
"path": "search_docs/reese-notes.md",
}
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [full]}
)
assert msg.model_dump()["segments"] == [full]
# Round-trip: the dump re-validates and dumps back to itself.
again = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": msg.model_dump()["segments"]}
)
assert again.model_dump() == msg.model_dump()
unscoped = {
"kind": "tool",
"name": "ls",
"argument": None,
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
dumped = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [unscoped]}
).model_dump()["segments"][0]
assert "path" not in dumped # ABSENT, never null
assert dumped == {
"kind": "tool",
"name": "ls",
"argument": None,
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": 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, 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)},
{"kind": "tool", "name": "grep", "argument": "x", "path": "p" * (ARGUMENT_CAP + 1)},
{"kind": "tool", "name": "read", "offset": 0},
{"kind": "tool", "name": "read", "limit": 0},
{"kind": "tool", "name": "read", "chars_shown": -1},
]
for entry in bad:
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [entry]}
)
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "x"}] * (SEGMENTS_CAP + 1),
}
)
_failed_loc(exc.value, "segments")
def test_unknown_segment_kind_rejected() -> None:
"""The discriminator (``kind`` = ``"thinking"`` | ``"tool"``): an
unknown ``kind`` is a ValidationError naming the offending entry —
nothing outside the union crosses the boundary."""
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "bogus", "text": "x"}],
}
)
assert exc.value.errors()[0]["loc"][:2] == ("segments", 0), exc.value.errors()
# A thinking variant without its ``text`` and a tool variant without
# its ``name`` are both rejected (the required fields are real).
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [{"kind": "tool"}]}
)
def test_extra_top_level_key_still_forbidden_with_segments() -> None:
"""The ``extra="forbid"`` boundary is INTACT around the new key:
a record with a well-formed ``segments`` AND a stray top-level key
is still rejected — adding the field changed nothing about which
keys are accepted (the phase-83 voice)."""
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"html": "<b>x</b>",
"segments": [{"kind": "thinking", "text": "x"}],
}
)
_failed_loc(exc.value, "html")
def test_segments_alias_is_the_discriminated_union() -> None:
"""``ChatSegment`` is the Annotated discriminated alias (the
``_Chip`` precedent, phase 83): the JSON shape is a flat object —
the discriminator rides as a field, no wrapper."""
from pydantic import TypeAdapter
adapter = TypeAdapter(ChatSegment)
thinking = adapter.validate_python({"kind": "thinking", "text": "x"})
assert isinstance(thinking, ChatThinkingSegment)
tool = adapter.validate_python({"kind": "tool", "name": "ls"})
assert isinstance(tool, ChatToolSegment)
# ---------------------------------------------------------------------------
# Phase 182 (task 05): VerbatimBlockRef + the saved record's answer_text
# ---------------------------------------------------------------------------
def _verbatim_block(**overrides: Any) -> dict[str, Any]:
"""One resolved block — the done frame's item shape."""
block: dict[str, Any] = {
"source": "Homelab",
"path": "quadlet/gufo.container",
"title": "gufo.container",
"start_line": 3,
"end_line": 9,
"total_lines": 21,
"text": "[Unit]\nDescription=gufo",
"truncated": False,
"locator": "Homelab/quadlet/gufo.container#3-9",
}
block.update(overrides)
return block
def test_verbatim_block_ref_round_trips_every_field() -> None:
"""Every field of the frame's item validates and serializes back
unchanged (the client matches on ``locator`` and renders the
SERVER's ``text`` + extent, so none of them may be dropped)."""
block = VerbatimBlockRef.model_validate(_verbatim_block())
assert block.model_dump() == _verbatim_block()
def test_verbatim_block_ref_carries_the_phase_83_text_cap() -> None:
"""``text`` is capped at the SAME 600 000 as the answer text (so an
operator raising ``BOR_SHOW_TEXT_MAX_CHARS`` never hits the wire
limit), and one character past it rejects."""
assert len(VerbatimBlockRef.model_validate(_verbatim_block(text="x" * 600_000)).text) == (
600_000
)
with pytest.raises(ValidationError):
VerbatimBlockRef.model_validate(_verbatim_block(text="x" * 600_001))
@pytest.mark.parametrize("field", ["start_line", "end_line", "total_lines"])
def test_verbatim_block_ref_refuses_negative_line_numbers(field: str) -> None:
with pytest.raises(ValidationError):
VerbatimBlockRef.model_validate(_verbatim_block(**{field: -1}))
def test_saved_record_answer_text_round_trips_and_stays_optional() -> None:
"""A brain record accepts ``answer_text`` (the phase-113 lesson — the
done-time auto-save must never 422) and drops the key when it is
``None`` or empty (A9: absent, never ``null``, never ``[]``)."""
with_blocks = ChatMessage.model_validate(
{"who": "brain", "text": "shown above", "answer_text": [_verbatim_block()]}
)
assert with_blocks.model_dump()["answer_text"] == [_verbatim_block()]
for value in (None, []):
record = ChatMessage.model_validate(
{"who": "brain", "text": "nothing shown", "answer_text": value}
)
assert "answer_text" not in record.model_dump()
def test_saved_record_answer_text_list_is_capped() -> None:
"""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(
{"who": "brain", "text": "too many", "answer_text": blocks}
)
assert len(
ChatMessage.model_validate(
{"who": "brain", "text": "enough", "answer_text": blocks[:50]}
).answer_text
or []
) == 50
def test_saved_chat_total_budget_counts_verbatim_block_text() -> None:
"""Phase 182 (task 05): the blocks are a SECOND byte channel on the
anonymous save surface, so they count against the same
8 000 000-character total (the SEC-05 invariant) — 15 records whose
blocks total 8 400 000 reject even though their ``text`` is small."""
turns = 15
size = 560_000
assert turns * size > TOTAL_BUDGET
heavy = [
{
"who": "brain",
"text": "short",
"answer_text": [_verbatim_block(text="x" * size)],
}
for _ in range(turns)
]
with pytest.raises(ValidationError) as exc:
SavedChatCreate.model_validate({"messages": heavy})
assert "8,000,000-character budget" in exc.value.errors()[0]["msg"]
# 14 × 560 000 = 7 840 000 fits (plus the small text, still inside).
assert len(SavedChatCreate.model_validate({"messages": heavy[:-1]}).messages) == 14