phase: 83_chat_save_payload_limits

All verification criteria are confirmed green. Final report:

**Phase 83 — final verification pass (all tasks already complete; verified, nothing to fix)**

- Verified `app/schemas.py` carries every A1 cap (text/thinking 32 000, sources 20, suggestions 50×200, tools 50, SourceRef 120/1000/500, ToolCall 100/2000, messages ≤200) — boundary-only, `app/api/chats.py` untouched
- Verified all test pins exist and pass: 32 unit boundary tests (both edges of every cap + round-trip), 4 oversized-422 integration pins (text/201-msgs/21-sources/PUT, each with nothing-stored/row-unchanged assertions), A3 SSE done-event pin at column maxima, dedicated E2E (anon 40 000-char POST → 422 + small save → 201)
- `uv run pytest tests/unit/test_schemas.py -v` — 32 passed
- `uv run pytest tests/integration/test_chats_api.py` — exit 0; A3 pin — passed
- `uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov` — 2 passed (isolation); `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` — 5 passed
- `uv run pytest` — exit 0 (~1 704 tests, 0 fail/skip); `uv run pytest --cov=app` — TOTAL 99%, `app/schemas.py` 100% (>90% ✓)
- `uv run ruff check . && uv run pyright` — clean (0 errors)
- `git diff --stat` — only `app/schemas.py`, 3 test files (+`test_chat_api.py` A3 pin, sanctioned by task 02), phase files; no `app/api/chats.py`/`alembic`/`frontend`/`pyproject`/`uv.lock` diff ✓
- All completion criteria met; commit + phase move left to the harness per pipeline rules (changes stay in working tree)
- Deviation note: list caps use `max_length` instead of `max_items` — identical in pydantic 2.13 (`max_items` is a deprecated alias); both boundaries behaviorally pinned
- Next pending phase: `84_docs_push_error_sanitization`
This commit is contained in:
2026-09-08 00:33:21 -04:00
parent e29d68d9f0
commit fa189dede7
21 changed files with 1103 additions and 16 deletions
@@ -0,0 +1,15 @@
All verification criteria are confirmed green. Final report:
**Phase 83 — final verification pass (all tasks already complete; verified, nothing to fix)**
- Verified `app/schemas.py` carries every A1 cap (text/thinking 32 000, sources 20, suggestions 50×200, tools 50, SourceRef 120/1000/500, ToolCall 100/2000, messages ≤200) — boundary-only, `app/api/chats.py` untouched
- Verified all test pins exist and pass: 32 unit boundary tests (both edges of every cap + round-trip), 4 oversized-422 integration pins (text/201-msgs/21-sources/PUT, each with nothing-stored/row-unchanged assertions), A3 SSE done-event pin at column maxima, dedicated E2E (anon 40 000-char POST → 422 + small save → 201)
- `uv run pytest tests/unit/test_schemas.py -v` — 32 passed
- `uv run pytest tests/integration/test_chats_api.py` — exit 0; A3 pin — passed
- `uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov` — 2 passed (isolation); `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` — 5 passed
- `uv run pytest` — exit 0 (~1 704 tests, 0 fail/skip); `uv run pytest --cov=app` — TOTAL 99%, `app/schemas.py` 100% (>90% ✓)
- `uv run ruff check . && uv run pyright` — clean (0 errors)
- `git diff --stat` — only `app/schemas.py`, 3 test files (+`test_chat_api.py` A3 pin, sanctioned by task 02), phase files; no `app/api/chats.py`/`alembic`/`frontend`/`pyproject`/`uv.lock` diff ✓
- All completion criteria met; commit + phase move left to the harness per pipeline rules (changes stay in working tree)
- Deviation note: list caps use `max_length` instead of `max_items` — identical in pydantic 2.13 (`max_items` is a deprecated alias); both boundaries behaviorally pinned
- Next pending phase: `84_docs_push_error_sanitization`
@@ -0,0 +1,84 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................................................ [ 97%]
.............................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 101 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3233 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,16 @@
Task 01 complete. All gates green.
**Implemented**
- `app/schemas.py` (only file touched in `app/`): all A1 caps — `SourceRef` 120/1000/500 (documents column mirror), `ToolCall` name 100 / argument 2000, `ChatMessage` text/thinking 32 000 (HistoryTurn mirror), sources ≤20, suggestions ≤50 of `_Chip` (`Annotated[str, Field(max_length=200)]`), tools ≤50, `SavedChatCreate`/`SavedChatUpdate.messages` ≤200; dense rationale docstrings; `extra="forbid"`/keys untouched.
- `tests/unit/test_schemas.py` (new, 32 tests): every cap pinned at both boundaries with `ValidationError` `loc` assertions, `None`-validity pins, `extra="forbid"` + minimal-shape regressions, and the realistic `bor.chat.v1` round-trip (create + update, `None`-keys preserved).
**Results**
- `uv run pytest tests/unit/test_schemas.py -v` → 32 passed
- `uv run pytest tests/unit/ -q` → 1274 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 1697 passed; `app/schemas.py` 100%, TOTAL 99% (>90% gate)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- `git diff --stat` → `app/schemas.py` only (+ new untracked test file); no commit made
**Notable decision**: used `max_length` for the list caps instead of the design block's `max_items` — pydantic 2.13 deprecates `max_items` (import-time warnings), and the codebase's own precedent (`ChatRequest.history: list[HistoryTurn] = Field(..., max_length=100)`) uses `max_length`. Cap values and the 422 shape (`too_long`, same `loc`) are identical.
**Next pending task**: `.agents/phases/todo/83_chat_save_payload_limits/02_integration_and_e2e.md` (oversized-422 integration pins, dedicated Playwright suite, SSE done-event pin).
@@ -0,0 +1,84 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 72%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 89%]
........................................................................ [ 93%]
........................................................................ [ 97%]
......................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 101 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3233 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,15 @@
All gates green. Final verification of the complete picture:
- Integration: 91 passed (58 in `test_chats_api.py` incl. 4 new pins, 33 in `test_chat_api.py` incl. the A3 SSE pin)
- E2E dedicated suite: 2 passed in isolation; regression `test_chat_history.py`: 5 passed
- Full suite: 1702 passed, 99% coverage on `app/`
- `ruff check .` clean, `pyright` 0 errors
- Diff limited to `app/schemas.py`, `tests/unit/test_schemas.py`, the two integration files, the new E2E file, and phase files — no `app/api/chats.py`, no migration
**Task 02 report**
- Extended `tests/integration/test_chats_api.py`: 4 anonymous oversized-422 pins (32_001-char text, 201 messages, 21-item `sources`, PUT oversized) — each asserting nothing stored / row unchanged via the admin list/GET.
- Added the A3 SSE pin to `tests/integration/test_chat_api.py`: `ChatDoneEvent` built at the column maxima (120/1000/500) `model_dump()`s byte-identical; one-over caps raise (caps sit exactly on the `documents` column lengths).
- New `tests/e2e/test_chat_save_payload_limits.py`: the exact audit vector (40_000-char text, NO session) through `page.request.post` → 422 + title-scoped "nothing stored" proof; in-cap small save → 201 with valid `id` (row cleaned up via admin delete, house convention).
- Notable: Playwright's `APIRequestContext` rejects relative URLs — used `app_url + "/api/chats"` (same network layer, absolute form); ruff import order in `test_chat_api.py` corrected.
- Results: `uv run pytest tests/integration/test_chats_api.py -v` 58 passed · `tests/e2e/test_chat_save_payload_limits.py -v --no-cov` 2 passed (isolated) · `tests/e2e/test_chat_history.py -v --no-cov` 5 passed · `uv run pytest --cov=app` 1702 passed, 99% · `ruff check .` clean · `pyright` 0 errors.
- Next pending task: `03_verify_and_commit.md` (full gate + atomic commit).
@@ -0,0 +1,84 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................................................ [ 97%]
.............................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 101 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3233 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,13 @@
All gates green. Task 03 complete — report:
**Phase 83 gate (task 03) — all green**
- Verified the phase 01/02 implementation: all A1 caps in `app/schemas.py` (text/thinking 32 000, `SourceRef` 120/1000/500, `ToolCall` 100/2000, lists 20/50/50, `_Chip` ≤ 200, messages ≤ 200), boundary unit suite, 4 oversized-422 integration pins + "nothing stored", A3 SSE pin at column maxima, dedicated Playwright suite (anonymous 422 + 201 happy path).
- Investigated a suspected defect (`max_items` vs `max_length` on lists): tried the design's `max_items`, but pydantic 2.13 deprecates it for sequences (warnings in suite output) and the house style (existing `history` field) uses `max_length` — reverted; behavior identical, suite warning-free.
- `uv run pytest` → exit 0, all pass
- `uv run pytest --cov=app --cov-report=term-missing` → 1702 passed; `app/` **99%** (>90%), `app/schemas.py` 100%
- `uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov` → 2 passed (isolation)
- `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` → 5 passed (real-UI save regression)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- Diff scope verified: `app/schemas.py` + 3 test files (+ A3 pin in `test_chat_api.py`, explicitly allowed) + phase-file moves; no `app/api/chats.py`, no `alembic/`, no `frontend/`, no `pyproject.toml`/`uv.lock`.
- Per executor protocol: no commit / no phase-dir move made — all changes left in the working tree for the harness's atomic commit.
- Next pending: phase `84_docs_push_error_sanitization` (phase 83 is fully done).
@@ -0,0 +1,84 @@
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................................................ [ 97%]
.............................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 178 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 101 0 100%
app/api/tokens.py 28 0 100%
app/config.py 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 19 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 64 0 100%
app/models.py 94 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 232 0 100%
--------------------------------------------------
TOTAL 3233 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
+57 -16
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Annotated, Any, Literal
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
@@ -96,9 +96,21 @@ class WhoamiResponse(BaseModel):
class SourceRef(BaseModel): class SourceRef(BaseModel):
source: str """One indexed document as cited in an answer (the SSE ``done``
path: str event's ``sources`` item — PLAN §4 — and, since phase 50, the
title: str ``sources`` list of a saved-chat message).
Phase 83 (SEC-05): the caps mirror the ``documents`` column lengths
(``source`` ``String(120)``, ``path`` ``String(1000)``, ``title``
``String(500)``). A :class:`SourceRef` is built from ``documents``
rows server-side, so every server-built SSE ref fits by construction
(A3: the SSE path is provably unaffected); the cap binds only
client-saved refs — bounded at the boundary with a 422.
"""
source: str = Field(max_length=120)
path: str = Field(max_length=1000)
title: str = Field(max_length=500)
class ChatThinkingEvent(BaseModel): class ChatThinkingEvent(BaseModel):
@@ -432,10 +444,23 @@ class ToolCall(BaseModel):
tool" lines pixel-identical (phase 50). Saved chats persisting the tool" lines pixel-identical (phase 50). Saved chats persisting the
pre-phase-70 tool names still validate — ``name`` is opaque pre-phase-70 tool names still validate — ``name`` is opaque
(no migration, locked). (no migration, locked).
Phase 83 (SEC-05) bounds the anonymous write surface: ``name`` ≤
100 (a tool name longer than that is not a real call — the
``AGENT_TOOLS`` names are short) and ``argument`` ≤ 2000 (the
combined ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is 2×
headroom for a grep pattern).
""" """
name: str name: str = Field(max_length=100)
argument: str | None = None argument: str | None = Field(default=None, max_length=2000)
#: One suggestion chip (phase 83, A1): a short deterministic string —
#: ``derive_suggestions``'s output runs to ~80 chars, so 200 chars is
#: the boundary sanity cap. Annotated alias: the JSON shape stays a
#: plain string (only the value bound is added).
_Chip = Annotated[str, Field(max_length=200)]
class ChatMessage(BaseModel): class ChatMessage(BaseModel):
@@ -449,17 +474,28 @@ class ChatMessage(BaseModel):
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
422, so nothing outside this shape can poison a restored 422, so nothing outside this shape can poison a restored
conversation. conversation.
Phase 83 (SEC-05) bounds the anonymous write surface (``POST/PUT
/api/chats`` is public — the row id is the credential, phase 55 A1):
``text`` / ``thinking`` carry :class:`HistoryTurn`'s 32 000 caps
(a single message longer than that is already rejected on the chat
path, so a saved chat can never legitimately carry more), 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; ``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.
""" """
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
who: Literal["user", "brain"] who: Literal["user", "brain"]
text: str = Field(min_length=1) text: str = Field(min_length=1, max_length=32_000)
sources: list[SourceRef] | None = None sources: list[SourceRef] | None = Field(default=None, max_length=20)
deflected: bool | None = None deflected: bool | None = None
suggestions: list[str] | None = None suggestions: list[_Chip] | None = Field(default=None, max_length=50)
thinking: str | None = None thinking: str | None = Field(default=None, max_length=32_000)
tools: list[ToolCall] | None = None tools: list[ToolCall] | None = Field(default=None, max_length=50)
stopped: bool | None = None stopped: bool | None = None
@@ -470,7 +506,10 @@ class SavedChatCreate(BaseModel):
``title`` is optional: when absent or blank the API auto-titles the ``title`` is optional: when absent or blank the API auto-titles the
row (the first user message's text, whitespace-collapsed, truncated row (the first user message's text, whitespace-collapsed, truncated
to 120 chars — the owner-locked convention). ``messages`` must be to 120 chars — the owner-locked convention). ``messages`` must be
non-empty — a saved chat with nothing to restore is meaningless. non-empty — a saved chat with nothing to restore is meaningless —
and, since phase 83 (SEC-05), at most 200 items: well past any realistic
conversation (the chat history budget itself is 40 turns) and far
below a DoS-sized list on the anonymous write surface.
``share`` (phase 51, owner-locked 2026-08-29): when true, the row is ``share`` (phase 51, owner-locked 2026-08-29): when true, the row is
shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on
@@ -482,7 +521,7 @@ class SavedChatCreate(BaseModel):
""" """
title: str | None = Field(default=None, max_length=500) title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1) messages: list[ChatMessage] = Field(min_length=1, max_length=200)
share: bool = False share: bool = False
@@ -491,12 +530,14 @@ class SavedChatUpdate(BaseModel):
``messages`` is a full replacement (the re-Save upsert semantics — ``messages`` is a full replacement (the re-Save upsert semantics —
re-Saving the same conversation updates the same row, never a new re-Saving the same conversation updates the same row, never a new
one). ``title`` is replaced only when supplied — an absent (or one) and carries the same bounds as :class:`SavedChatCreate.messages`
blank) ``title`` keeps the row's current title. (phase 83, SEC-05: non-empty, ≤ 200 items). ``title`` is replaced
only when supplied — an absent (or blank) ``title`` keeps the row's
current title.
""" """
title: str | None = Field(default=None, max_length=500) title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1) messages: list[ChatMessage] = Field(min_length=1, max_length=200)
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any: def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
+132
View File
@@ -0,0 +1,132 @@
"""Phase 83 E2E (Playwright): the anonymous saved-chat payload
boundary (SEC-05) — the audit vector through a real browser's network
layer.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov
The contract under test (phase 83 — boundary-only hardening of the
PUBLIC write surface ``POST/PUT /api/chats``; the caps live in
``app/schemas.py`` and FastAPI rejects before the handler runs, A2):
* **Oversized save 422s, anonymously** — the exact audit vector (one
40_000-char message, NO session, driven through the page's
``page.request`` context — the browser's own network layer) is
rejected at the schema boundary with a 422 and NOTHING is stored
(the admin list carries no row for the probe's distinctive
auto-title — the shared e2e DB may hold other suites' rows, so the
"nothing stored" proof is title-scoped, the house convention);
* **Small save still 201s** — a normal in-cap save through the same
anonymous network layer lands 201 with a valid ``id``: the boundary
tightened the DoS surface without breaking the real (in-cap) flow
the UI produces.
No LLM dependency — both endpoints are DB-only (the session-scoped
mock LLM stays up as an ``app_server`` dependency but is never
called).
"""
from __future__ import annotations
import uuid
import httpx
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
#: The probe's distinctive marker: if the oversized row had been
#: stored, its auto-title (first user message, whitespace-collapsed,
#: 120-char cap) would start with exactly this string — unique in the
#: shared e2e DB.
PROBE_MARKER = "payload-limit-probe (phase 83 e2e)"
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _settled_anonymous(page: Page, app_url: str) -> None:
"""Land on the chat page in the settled anonymous state (fresh
context — no login): the whoami round-trip has landed, so the
``page.request`` calls below carry no session cookie (the write
surface is public — anonymity is the audit vector)."""
page.goto(app_url + "/")
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
def test_anonymous_oversized_save_422s_and_stores_nothing(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_settled_anonymous(page, app_url)
text = PROBE_MARKER + " " + "x" * (40_000 - len(PROBE_MARKER) - 1)
assert len(text) == 40_000
# The audit vector, end-to-end and anonymous: one 40_000-char
# message through the page's request context (no session cookie).
r = page.request.post(
app_url + "/api/chats",
data={
"messages": [
{"who": "user", "text": text},
{"who": "brain", "text": "ok"},
]
},
)
assert r.status == 422, (
f"the oversized body must 422 at the schema boundary: {r.text()}"
)
# Nothing stored: the list surface is admin-only, so sign in now
# (the 422 above happened BEFORE any session existed — the write
# surface is public, exactly the audit vector) and prove no row
# carries the probe's distinctive auto-title.
login(page, app_url, next="/")
cookies = _admin_cookies(page)
body = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert body.status_code == 200
rows = body.json()["chats"]
assert not any(c["title"].startswith(PROBE_MARKER) for c in rows), (
f"the oversized probe must not have been stored: {rows}"
)
def test_small_anonymous_save_still_201s(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_settled_anonymous(page, app_url)
q = "How do I prune deleted docs? (phase 83 e2e small save)"
created: str | None = None
try:
# The happy path from the same anonymous network layer: the
# boundary tightened the DoS surface without breaking the
# real (in-cap) save flow the UI produces.
r = page.request.post(
app_url + "/api/chats",
data={
"messages": [
{"who": "user", "text": q},
{"who": "brain", "text": "Use --prune. (phase 83 e2e)"},
]
},
)
assert r.status == 201, f"the in-cap save must still land: {r.text()}"
body = r.json()
uuid.UUID(body["id"]) # a valid row id
assert body["title"] == q # auto-title = first user message
assert body["message_count"] == 2
created = body["id"]
finally:
if created is not None:
login(page, app_url, next="/")
httpx.delete(
f"{app_url}/api/chats/{created}", timeout=10, cookies=_admin_cookies(page)
)
+33
View File
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, cast
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pydantic import ValidationError
from sqlalchemy import delete, func, select, text from sqlalchemy import delete, func, select, text
from app.api import chat as chat_api from app.api import chat as chat_api
@@ -32,6 +33,7 @@ from app.rag import agent
from app.rag.agent import AGENT_TOOLS from app.rag.agent import AGENT_TOOLS
from app.rag.importer import import_sources from app.rag.importer import import_sources
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece
from app.schemas import ChatDoneEvent, SourceRef
from tests.conftest import ADMIN_PASSWORD from tests.conftest import ADMIN_PASSWORD
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -1406,3 +1408,34 @@ def test_history_rejects_more_than_100_entries(client, db) -> None:
}, },
) )
assert r.status_code == 422 assert r.status_code == 422
def test_done_event_serializes_column_maximum_source_refs() -> None:
"""Phase 83 A3 pin: ``SourceRef`` is SHARED by the SSE ``done``
event and the saved-chat surface — the boundary caps added there
(``source`` ≤ 120, ``path`` ≤ 1000, ``title`` ≤ 500) mirror the
``documents`` column lengths EXACTLY, so a server-built event from
a full-length row (values at the column maxima) still constructs
and serializes byte-identical: the SSE contract is provably
unaffected. The one-over caps raise — only client-saved refs can
ever trip a cap, never a server-built ref."""
event = ChatDoneEvent(
deflected=False,
sources=[SourceRef(source="s" * 120, path="p" * 1000, title="t" * 500)],
suggestions=[],
)
assert event.model_dump() == {
"type": "done",
"deflected": False,
"sources": [{"source": "s" * 120, "path": "p" * 1000, "title": "t" * 500}],
"suggestions": [],
}
# The caps sit exactly ON the column maxima: one over any of them
# is rejected (a row could never hold such a value in the first
# place — the string columns enforce the same lengths).
with pytest.raises(ValidationError):
SourceRef(source="s" * 121, path="p" * 1000, title="t" * 500)
with pytest.raises(ValidationError):
SourceRef(source="s" * 120, path="p" * 1001, title="t" * 500)
with pytest.raises(ValidationError):
SourceRef(source="s" * 120, path="p" * 1000, title="t" * 501)
+91
View File
@@ -1062,3 +1062,94 @@ def test_page_route_serves_shared_html_when_present(
assert r.status_code == 200 assert r.status_code == 200
assert r.text == "<html>shared page</html>" assert r.text == "<html>shared page</html>"
assert "text/html" in r.headers["content-type"] assert "text/html" in r.headers["content-type"]
# ---------- payload boundary caps (phase 83, SEC-05) — the anonymous
# write surface 422s on oversized bodies and stores NOTHING (the caps
# live in app/schemas.py, A2 boundary-only: FastAPI rejects before the
# handler runs — no route change, nothing ever lands in the JSONB) ----------
def test_post_rejects_text_over_32000_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The audit vector (SEC-05): one 32_001-char message 422s at the
boundary (``ChatMessage.text`` mirrors ``HistoryTurn``'s 32 000
cap) and NOTHING is stored — the admin list is unchanged (the happy
path for in-cap bodies stays the existing guest/admin pins)."""
baseline = admin_client.get("/api/chats").json()["chats"]
r = client.post(
"/api/chats",
json={"messages": [_user("a" * 32_001), _user("follow-up")]},
)
assert r.status_code == 422
listing = admin_client.get("/api/chats").json()["chats"]
assert listing == baseline, "an oversized POST must not store a row"
def test_post_rejects_more_than_200_messages_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The unbounded message list is the second DoS lever (SEC-05):
201 minimally-valid messages 422 (``SavedChatCreate.messages``
``max_items=200``) and nothing lands."""
baseline = len(admin_client.get("/api/chats").json()["chats"])
r = client.post(
"/api/chats", json={"messages": [_user(f"question {i}") for i in range(201)]}
)
assert r.status_code == 422
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
def test_post_rejects_sources_over_20_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""One brain message carrying a 21-item ``sources`` list (all valid
``SourceRef`` shapes) 422 (``ChatMessage.sources`` ``max_items=20``
— top-N docs + agent reads) and nothing lands."""
baseline = len(admin_client.get("/api/chats").json()["chats"])
sources = [
{"source": f"src{i}", "path": f"docs{i}.md", "title": f"Doc {i}"}
for i in range(21)
]
r = client.post(
"/api/chats",
json={
"messages": [
_user("hi"),
{"who": "brain", "text": "answer", "sources": sources},
]
},
)
assert r.status_code == 422
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
def test_put_rejects_oversized_message_and_leaves_row_unchanged(
client: TestClient, admin_client: TestClient
) -> None:
"""The re-Save path is gated by the SAME caps (A1: create AND update
carry the bounds): a 32_001-char message 422s and the row keeps its
original payload byte-for-byte (the stored shape is untouched — the
rejected body never reaches the JSONB)."""
created = client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
r = client.put(
f"/api/chats/{created['id']}",
json={"messages": [_user("b" * 32_001)]},
)
assert r.status_code == 422
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["messages"] == _expect(_simple_conversation())
assert got.json()["message_count"] == 2 # original count, not the rejected 1
+395
View File
@@ -0,0 +1,395 @@
"""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).
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.schemas import (
ChatMessage,
SavedChatCreate,
SavedChatUpdate,
SourceRef,
ToolCall,
)
# --- boundary constants (phase 83, A1 — mirror sources in app/schemas.py) ---
TEXT_CAP = 32_000 # mirrors HistoryTurn.text / .thinking
SOURCES_CAP = 20
SUGGESTIONS_CAP = 50
CHIP_CAP = 200
TOOLS_CAP = 50
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 = 200 # SavedChatCreate/Update.messages
def _source_ref() -> dict:
return {"source": "Homelab", "path": "kubernetes.md", "title": "K8s"}
def _tool_call() -> dict:
return {"name": "read", "argument": "Homelab/kubernetes.md"}
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 / .suggestions / .tools
# ---------------------------------------------------------------------------
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")
# ---------------------------------------------------------------------------
# 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")
# ---------------------------------------------------------------------------
# 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.deflected, msg.suggestions, msg.thinking, msg.tools, msg.stopped) == (
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,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": 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"},
],
"deflected": False,
"suggestions": None,
"thinking": "The kubernetes doc covers the cluster layout…",
"tools": [
{"name": "read", "argument": "Homelab/kubernetes.md"},
{"name": "ls", "argument": None},
],
"stopped": None,
},
{
"who": "user",
"text": "And what ports does Traefik expose?",
"sources": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
},
{
"who": "brain",
"text": "Traefik exposes 80/443 on every node.",
"sources": None,
"deflected": None,
"suggestions": ["What is the Traefik dashboard password?"],
"thinking": None,
"tools": None,
"stopped": True, # the owner stopped the generation mid-answer
},
]
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).
assert dumped["messages"] == messages
assert dumped["share"] is False
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()],
"deflected": None,
"suggestions": ["follow-up?"],
"thinking": "scratchpad",
"tools": [_tool_call()],
"stopped": None,
}
payload = SavedChatUpdate.model_validate({"messages": [msg]})
assert payload.model_dump()["messages"] == [msg]