All green. Final report: **Phase 160 completion pass — verified (final verification; all 5 tasks already in `complete/`)** - **Verified, no code defects found:** budget trailers (`TOOL_BUDGET_REMAINING/LAST/SPENT`), cap-path rejection + `ROUND_CAP_FALLBACK_ANSWER` + `ROUND_CAP_WRAPUP_INSTRUCTION`, `done.budget_exhausted` (ABSENT-when-false), `rounds=/cap_hit=` log fields, `agent_max_rounds=10` default, frontend Continue button + restore + shared-page exclusion, mock markers. - **Tests/lint:** `uv run pytest` 153/153 green (EXIT 0); `uv run pytest --cov=app --cov-report=term-missing` TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors/0 warnings. - **Criteria:** ① decreasing trailer pinned by unit tests (9→…→spent) ✅ ② cap path ends readable, no tool lines after cap (E2E) ✅ ③ `budget_exhausted` true-only-on-cap, absent otherwise (integration) ✅ ④ Continue button → one fresh `continue` turn, fresh budget, record intact (E2E) ✅ ⑤ flag survives refresh; shared chat never renders it (`shared.js` has no `budget_exhausted` path) ✅ ⑥ `max_rounds=0` byte-identical single `tools=None` request; default still 10 ✅ ⑦ full suite/coverage/lint green ✅ ⑧ `test_tool_budget_telemetry.py` **7/7** in isolation; all 6 regression E2E suites green in isolation ✅ ⑨ commit + phase move = harness (I make no git history/index changes). - **Docs:** README.md — fixed: the "Continue researching" button and cap behavior were undocumented (added a Chat-section paragraph + an Agent-tools-section paragraph, incl. the phase-71 recorded-revision note); env-var table, commands, and architecture all verified consistent otherwise. AGENTS.md — verified consistent, no changes needed. - **Decisions:** no code changes required — phase was fully implemented; only README docs added. No live-infrastructure touched. - **Next pending phase:** `161_reasoning_bubble`.
206 lines
8.4 KiB
Python
206 lines
8.4 KiB
Python
"""Unit: the failed-turn schema boundary (phase 120, task 03).
|
|
|
|
Phase 120 (task 01, locked A1) added two OPTIONAL keys to
|
|
``ChatMessage`` (``app/schemas.py``) — ``failed`` (a bool marker) and
|
|
``error`` (the persisted error detail, capped at 500) — the phase-48
|
|
``stopped`` precedent: a FAILED chat turn (network error, SSE ``error``
|
|
frame, stream drop) persists as a BRAIN record
|
|
``{who: "brain", text: <detail or fallback>, failed: true, error:
|
|
<detail>}`` — no separate error table, no new API (``retryLastTurn``'s
|
|
pop-the-last-brain-record logic works on a failed record UNCHANGED).
|
|
|
|
This module pins the boundary the phase plan names:
|
|
|
|
* a failed record (``failed: true`` + ``error``) validates and
|
|
round-trips losslessly;
|
|
* ``error`` of 501 chars 422s at the boundary (the phase-83
|
|
value-bounds style; exactly 500 passes);
|
|
* an unknown key still 422s (``extra="forbid"`` intact — the new keys
|
|
are DECLARED, they did not loosen the boundary);
|
|
* a record WITHOUT the new keys validates, serializes with the new keys
|
|
as explicit nulls, and — on every pre-phase key — is byte-identical
|
|
to the pre-phase-120 stored shape (the phase-50 contract: the server
|
|
stores ``model_dump()`` without ``exclude_none``, so a pre-phase
|
|
round-trip is untouched apart from the two added nulls).
|
|
|
|
House convention: pure schema tests (no DB, no client) — the API-level
|
|
round-trip pins live in ``tests/integration/test_chats_api.py``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from app.schemas import ChatMessage
|
|
|
|
#: The phase-120 failed record shape (locked A1) — the zero-frame
|
|
#: network-error case exactly as ``finalizeFailedTurn`` persists it.
|
|
FAILED_RECORD: dict = {
|
|
"who": "brain",
|
|
"text": "My answer didn't make it — the connection dropped. Use Retry to ask again.",
|
|
"failed": True,
|
|
"error": "The chat model dropped the connection — try again?",
|
|
}
|
|
|
|
|
|
# ---------- acceptance: the failed record validates + round-trips ----------
|
|
|
|
|
|
def test_failed_record_validates() -> None:
|
|
"""A failed brain record (``failed: true`` + the capped ``error``
|
|
detail) crosses the boundary and the fields survive the trip."""
|
|
msg = ChatMessage.model_validate(FAILED_RECORD)
|
|
assert msg.who == "brain"
|
|
assert msg.text == FAILED_RECORD["text"]
|
|
assert msg.failed is True
|
|
assert msg.error == FAILED_RECORD["error"]
|
|
|
|
|
|
def test_failed_record_round_trips_losslessly() -> None:
|
|
"""The stored shape (``model_dump`` — plain, no ``exclude_none``,
|
|
the phase-50 storage convention) keeps the marker + detail and
|
|
fills the remaining optional keys with explicit nulls (the
|
|
restore path is null-safe)."""
|
|
dumped = ChatMessage.model_validate(FAILED_RECORD).model_dump()
|
|
assert dumped["failed"] is True
|
|
assert dumped["error"] == FAILED_RECORD["error"]
|
|
for key in ("sources", "related", "deflected", "suggestions", "thinking", "tools", "stopped"):
|
|
assert dumped[key] is None, f"{key} must be an explicit null, got {dumped[key]!r}"
|
|
# Re-validate the stored shape — the round-trip is lossless.
|
|
assert ChatMessage.model_validate(dumped).model_dump() == dumped
|
|
|
|
|
|
def test_failed_false_is_an_explicit_marker() -> None:
|
|
"""``failed: false`` is a legal value (a full ``bor.chat.v1`` brain
|
|
record carries the key explicitly — the integration FULL_BRAIN
|
|
round-trip relies on it); it must not be coerced to absent."""
|
|
msg = ChatMessage.model_validate(
|
|
{"who": "brain", "text": "hi", "failed": False, "error": None}
|
|
)
|
|
assert msg.failed is False
|
|
assert msg.error is None
|
|
assert msg.model_dump()["failed"] is False
|
|
|
|
|
|
# ---------- the value bounds: error ≤ 500 (phase-83 style) ----------
|
|
|
|
|
|
def test_error_at_500_chars_passes() -> None:
|
|
"""The bound is inclusive: exactly 500 chars validate."""
|
|
msg = ChatMessage.model_validate(
|
|
{"who": "brain", "text": "hi", "failed": True, "error": "e" * 500}
|
|
)
|
|
assert len(msg.error or "") == 500
|
|
|
|
|
|
def test_error_over_500_chars_is_rejected() -> None:
|
|
"""501 chars 422s at the boundary (the API surfaces this as a 422 —
|
|
the hostile detail string is capped at the schema, the
|
|
``finalizeFailedTurn`` 500-char slice is the UI-side first cut)."""
|
|
with pytest.raises(ValidationError):
|
|
ChatMessage.model_validate(
|
|
{"who": "brain", "text": "hi", "failed": True, "error": "e" * 501}
|
|
)
|
|
|
|
|
|
# ---------- the boundary stays strict: extra="forbid" intact ----------
|
|
|
|
|
|
def test_unknown_key_is_still_rejected() -> None:
|
|
"""``extra="forbid"`` was NOT loosened by the new keys: a stray
|
|
key still rejects at the boundary (the corrupted / HTML-shaped
|
|
payload defense, unchanged)."""
|
|
with pytest.raises(ValidationError):
|
|
ChatMessage.model_validate({"who": "brain", "text": "hi", "foo": 1})
|
|
|
|
|
|
def test_unknown_key_is_rejected_even_with_the_new_keys_present() -> None:
|
|
"""A record carrying the new keys AND an unknown key still
|
|
rejects — the new declarations did not widen the accepted key set
|
|
beyond ``failed``/``error``."""
|
|
with pytest.raises(ValidationError):
|
|
ChatMessage.model_validate(
|
|
{"who": "brain", "text": "hi", "failed": True, "error": "x", "foo": 1}
|
|
)
|
|
|
|
|
|
# ---------- backward compatibility: the pre-phase-120 shape ----------
|
|
|
|
|
|
def test_record_without_the_new_keys_validates_with_none() -> None:
|
|
"""A pre-phase record (no ``failed``/``error`` keys) still
|
|
validates; both new fields default to ``None`` (they round-trip as
|
|
nulls — absent/None, exactly like ``stopped`` today)."""
|
|
msg = ChatMessage.model_validate({"who": "brain", "text": "hi"})
|
|
assert msg.failed is None
|
|
assert msg.error is None
|
|
dumped = msg.model_dump()
|
|
assert dumped["failed"] is None
|
|
assert dumped["error"] is None
|
|
|
|
|
|
def test_pre_phase_record_round_trips_byte_identical_on_existing_keys() -> None:
|
|
"""The phase-50 contract through the new schema: a pre-phase-120
|
|
STORED record (every phase-50 key present, explicit nulls where an
|
|
optional key does not apply — tool entries in the CURRENT full
|
|
shape, the phase-95/139 additive fields as nulls for a plain read)
|
|
validates, and re-serializes byte-identically on EVERY pre-phase
|
|
key — the only diff is the two added keys as explicit nulls. Old
|
|
saved chats and shared links therefore render/restore exactly as
|
|
before."""
|
|
pre_phase: dict = {
|
|
"who": "brain",
|
|
"text": "Your k3s cluster runs on three nodes — you've got this.",
|
|
"sources": [{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes"}],
|
|
"related": [{"source": "Homelab", "path": "traefik.md", "title": "Traefik"}],
|
|
"deflected": False,
|
|
"suggestions": ["What ports does Traefik expose?"],
|
|
"thinking": "scratchpad",
|
|
"tools": [
|
|
{
|
|
"name": "read",
|
|
"argument": "Homelab/kubernetes.md",
|
|
"truncated": False,
|
|
"chars_shown": None,
|
|
"chars_total": None,
|
|
"offset": None,
|
|
"limit": None,
|
|
}
|
|
],
|
|
"stopped": None,
|
|
}
|
|
dumped = ChatMessage.model_validate(pre_phase).model_dump()
|
|
# Every pre-phase key survives byte-identical…
|
|
for key, value in pre_phase.items():
|
|
assert dumped[key] == value, f"pre-phase key {key!r} changed: {dumped[key]!r}"
|
|
# …and the ONLY additions are the new keys as explicit nulls
|
|
# (the phase-120/48 ``failed`` / ``error`` pair, and the phase-160
|
|
# additive ``budget_exhausted`` marker — the client's restore
|
|
# hint, explicit null where absent).
|
|
assert set(dumped) == set(pre_phase) | {"failed", "error", "budget_exhausted"}
|
|
assert dumped["failed"] is None
|
|
assert dumped["error"] is None
|
|
assert dumped["budget_exhausted"] is None
|
|
# The stored shape re-validates (round-trip through the DB JSONB).
|
|
assert ChatMessage.model_validate(dumped).model_dump() == dumped
|
|
|
|
|
|
def test_minimal_user_record_unchanged() -> None:
|
|
"""A user record (no brain metadata at all) is untouched by the
|
|
phase: it validates and carries the new keys as nulls only."""
|
|
dumped = ChatMessage.model_validate({"who": "user", "text": "hi"}).model_dump()
|
|
assert dumped == {
|
|
"who": "user",
|
|
"text": "hi",
|
|
"sources": None,
|
|
"related": None,
|
|
"deflected": None,
|
|
"suggestions": None,
|
|
"thinking": None,
|
|
"tools": None,
|
|
"stopped": None,
|
|
"failed": None,
|
|
"error": None,
|
|
"budget_exhausted": None,
|
|
}
|