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