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