From fa189dede75066d0695d60746a7d28fd8ee4bbb0 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 8 Sep 2026 00:33:21 -0400 Subject: [PATCH] phase: 83_chat_save_payload_limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` --- .../83_chat_save_payload_limits/00_phase.md | 0 .../01_schema_caps.md | 0 .../02_integration_and_e2e.md | 0 .../03_verify_and_commit.md | 0 ..._chat_save_payload_limits__00_phase.a1.err | 0 ...3_chat_save_payload_limits__00_phase.a1.md | 15 + ..._save_payload_limits__00_phase.a1.validate | 84 ++++ ...save_payload_limits__01_schema_caps.a1.err | 0 ..._save_payload_limits__01_schema_caps.a1.md | 16 + ...payload_limits__01_schema_caps.a1.validate | 84 ++++ ...load_limits__02_integration_and_e2e.a1.err | 0 ...yload_limits__02_integration_and_e2e.a1.md | 15 + ...limits__02_integration_and_e2e.a1.validate | 84 ++++ ...ayload_limits__03_verify_and_commit.a1.err | 0 ...payload_limits__03_verify_and_commit.a1.md | 13 + ...d_limits__03_verify_and_commit.a1.validate | 84 ++++ app/schemas.py | 73 +++- tests/e2e/test_chat_save_payload_limits.py | 132 ++++++ tests/integration/test_chat_api.py | 33 ++ tests/integration/test_chats_api.py | 91 ++++ tests/unit/test_schemas.py | 395 ++++++++++++++++++ 21 files changed, 1103 insertions(+), 16 deletions(-) rename .agents/phases/{todo => complete}/83_chat_save_payload_limits/00_phase.md (100%) rename .agents/phases/{todo => complete}/83_chat_save_payload_limits/01_schema_caps.md (100%) rename .agents/phases/{todo => complete}/83_chat_save_payload_limits/02_integration_and_e2e.md (100%) rename .agents/phases/{todo => complete}/83_chat_save_payload_limits/03_verify_and_commit.md (100%) create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.err create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.md create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.validate create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.err create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.md create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.validate create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.err create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.md create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.validate create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.err create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.md create mode 100644 .agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.validate create mode 100644 tests/e2e/test_chat_save_payload_limits.py create mode 100644 tests/unit/test_schemas.py diff --git a/.agents/phases/todo/83_chat_save_payload_limits/00_phase.md b/.agents/phases/complete/83_chat_save_payload_limits/00_phase.md similarity index 100% rename from .agents/phases/todo/83_chat_save_payload_limits/00_phase.md rename to .agents/phases/complete/83_chat_save_payload_limits/00_phase.md diff --git a/.agents/phases/todo/83_chat_save_payload_limits/01_schema_caps.md b/.agents/phases/complete/83_chat_save_payload_limits/01_schema_caps.md similarity index 100% rename from .agents/phases/todo/83_chat_save_payload_limits/01_schema_caps.md rename to .agents/phases/complete/83_chat_save_payload_limits/01_schema_caps.md diff --git a/.agents/phases/todo/83_chat_save_payload_limits/02_integration_and_e2e.md b/.agents/phases/complete/83_chat_save_payload_limits/02_integration_and_e2e.md similarity index 100% rename from .agents/phases/todo/83_chat_save_payload_limits/02_integration_and_e2e.md rename to .agents/phases/complete/83_chat_save_payload_limits/02_integration_and_e2e.md diff --git a/.agents/phases/todo/83_chat_save_payload_limits/03_verify_and_commit.md b/.agents/phases/complete/83_chat_save_payload_limits/03_verify_and_commit.md similarity index 100% rename from .agents/phases/todo/83_chat_save_payload_limits/03_verify_and_commit.md rename to .agents/phases/complete/83_chat_save_payload_limits/03_verify_and_commit.md diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.err b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.md b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.md new file mode 100644 index 0000000..9d8e749 --- /dev/null +++ b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.md @@ -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` diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.validate b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.validate new file mode 100644 index 0000000..d702fb7 --- /dev/null +++ b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.err b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.md b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.md new file mode 100644 index 0000000..10a7281 --- /dev/null +++ b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.md @@ -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). diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.validate b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.validate new file mode 100644 index 0000000..be276aa --- /dev/null +++ b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__01_schema_caps.a1.validate @@ -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 diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.err b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.md b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.md new file mode 100644 index 0000000..3e1bbe9 --- /dev/null +++ b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.md @@ -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). diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.validate b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.validate new file mode 100644 index 0000000..d702fb7 --- /dev/null +++ b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__02_integration_and_e2e.a1.validate @@ -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 diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.err b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.md b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.md new file mode 100644 index 0000000..3b37ccf --- /dev/null +++ b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.md @@ -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). diff --git a/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.validate b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.validate new file mode 100644 index 0000000..d702fb7 --- /dev/null +++ b/.agents/reports/83_chat_save_payload_limits/83_chat_save_payload_limits__03_verify_and_commit.a1.validate @@ -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 diff --git a/app/schemas.py b/app/schemas.py index 140af97..f875b3b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from datetime import datetime -from typing import Any, Literal +from typing import Annotated, Any, Literal from pydantic import ( BaseModel, @@ -96,9 +96,21 @@ class WhoamiResponse(BaseModel): class SourceRef(BaseModel): - source: str - path: str - title: str + """One indexed document as cited in an answer (the SSE ``done`` + event's ``sources`` item — PLAN §4 — and, since phase 50, the + ``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): @@ -432,10 +444,23 @@ class ToolCall(BaseModel): tool" lines pixel-identical (phase 50). Saved chats persisting the pre-phase-70 tool names still validate — ``name`` is opaque (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 - argument: str | None = None + name: str = Field(max_length=100) + 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): @@ -449,17 +474,28 @@ class ChatMessage(BaseModel): payload, e.g. a stray ````-ish extra key) at the boundary with a 422, so nothing outside this shape can poison a restored 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") who: Literal["user", "brain"] - text: str = Field(min_length=1) - sources: list[SourceRef] | None = None + text: str = Field(min_length=1, max_length=32_000) + sources: list[SourceRef] | None = Field(default=None, max_length=20) deflected: bool | None = None - suggestions: list[str] | None = None - thinking: str | None = None - tools: list[ToolCall] | None = None + suggestions: list[_Chip] | None = Field(default=None, max_length=50) + thinking: str | None = Field(default=None, max_length=32_000) + tools: list[ToolCall] | None = Field(default=None, max_length=50) stopped: bool | None = None @@ -470,7 +506,10 @@ class SavedChatCreate(BaseModel): ``title`` is optional: when absent or blank the API auto-titles the row (the first user message's text, whitespace-collapsed, truncated 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 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) - messages: list[ChatMessage] = Field(min_length=1) + messages: list[ChatMessage] = Field(min_length=1, max_length=200) share: bool = False @@ -491,12 +530,14 @@ class SavedChatUpdate(BaseModel): ``messages`` is a full replacement (the re-Save upsert semantics — re-Saving the same conversation updates the same row, never a new - one). ``title`` is replaced only when supplied — an absent (or - blank) ``title`` keeps the row's current title. + one) and carries the same bounds as :class:`SavedChatCreate.messages` + (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) - 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: diff --git a/tests/e2e/test_chat_save_payload_limits.py b/tests/e2e/test_chat_save_payload_limits.py new file mode 100644 index 0000000..ad4827a --- /dev/null +++ b/tests/e2e/test_chat_save_payload_limits.py @@ -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) + ) diff --git a/tests/integration/test_chat_api.py b/tests/integration/test_chat_api.py index 3d6bb1e..0a29067 100644 --- a/tests/integration/test_chat_api.py +++ b/tests/integration/test_chat_api.py @@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, cast import pytest from fastapi.testclient import TestClient +from pydantic import ValidationError from sqlalchemy import delete, func, select, text 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.importer import import_sources from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece +from app.schemas import ChatDoneEvent, SourceRef from tests.conftest import ADMIN_PASSWORD if TYPE_CHECKING: @@ -1406,3 +1408,34 @@ def test_history_rejects_more_than_100_entries(client, db) -> None: }, ) 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) diff --git a/tests/integration/test_chats_api.py b/tests/integration/test_chats_api.py index 48ad9a5..9eb393b 100644 --- a/tests/integration/test_chats_api.py +++ b/tests/integration/test_chats_api.py @@ -1062,3 +1062,94 @@ def test_page_route_serves_shared_html_when_present( assert r.status_code == 200 assert r.text == "shared page" 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 + diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py new file mode 100644 index 0000000..988adea --- /dev/null +++ b/tests/unit/test_schemas.py @@ -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": "x"}) + _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]