Files
brain-of-reese/tests/unit/test_sse_events.py
T
ducoterra 101156bada
Build and Push Containers / build-and-push-app (push) Successful in 2m30s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 131_read_line_ranges
All 44 progress lines are pure dots, final line at 100%, exit 0. Everything is verified. Final report:

**Phase 131 — final verification pass (all 4 tasks already complete in working tree)**

- **Verified implemented:** `read(offset, limit)` schema + teaching descriptions; rewritten truncation notice; full 6-row dedupe matrix + range validation + `lines: a-b of N` header + slice cap in `_execute_tool`; wire-level `offset`/`limit` on `ChatToolEvent` (absent keys dropped → non-range frames byte-identical); mock-LLM `read the lines` flow; handbook repro (wording + line numbers derived from the real PDF at test time)
- **Tests:** `uv run pytest --no-cov` → 3097 passed, exit 0; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run pytest tests/e2e/test_read_line_ranges.py -v --no-cov` → 2/2 in isolation (47.6 s); `tests/integration/test_read_line_ranges_handbook.py` → 2/2 (3.5 s); 10-file targeted E2E batch pinning pre-phase surfaces (grep/ls/plain-read/ALREADY_IN_CONTEXT/truncation) → 31/31; `ruff check .` clean; `pyright` 0 errors
- **Completion criteria:** all 5 green — (1) exact range lines + `lines:` header, plain reads byte-identical (existing pins pass unchanged) ✅ (2) truncated-then-range succeeds end-to-end on the real handbook; other re-reads get teaching refusals ✅ (3) find-traps answered from real PDF text, no hardcoded wording ✅ (4) coverage + isolated E2E ✅ (5) no behavior change in completed phases ✅
- **Docs:** README + AGENTS reviewed in full. Fixed 3 inconsistencies: README missing `BOR_HISTORY_MAX_TURNS`/`BOR_HISTORY_MAX_CHARS` rows (pre-existing phase-74 gap — env-tunable, unit-pinned, undocumented); `.env.example` missing the same two vars (added); `.env.example` stale `BOR_READ_MAX_CHARS` comment (still said "grep-pointer notice (phase 95)" — updated to the phase-131 workflow notice). AGENTS.md verified consistent — no changes needed. All 67 `.env.example` vars documented, all defaults match `app/config.py`, pages/auth tables match live routes
- **Note:** PLAN.md §7.1 still states the retired 46rem chat column (superseded by phase 100, which AGENTS.md rule 5 records correctly) — PLAN.md is off-limits for this pass, flagged only
- **Next pending phase:** `132_image_upload_feedback`
2026-09-28 11:49:55 -04:00

188 lines
8.1 KiB
Python

"""Unit: SSE frame serialization for POST /api/chat (PLAN §4 contract)."""
from __future__ import annotations
import json
from app.api.chat import sse_event
from app.schemas import (
ChatErrorEvent,
ChatRetryEvent,
ChatThinkingEvent,
ChatToolEvent,
)
def _payload(frame: str) -> dict:
assert frame.startswith("data: ")
assert frame.endswith("\n\n")
return json.loads(frame.removeprefix("data: ").strip())
def test_delta_frame_serializes_exactly() -> None:
frame = sse_event({"type": "delta", "text": "hi"})
assert frame == 'data: {"type": "delta", "text": "hi"}\n\n'
assert _payload(frame) == {"type": "delta", "text": "hi"}
def test_done_frame_carries_full_contract_shape() -> None:
payload = {
"type": "done",
"deflected": False,
"sources": [{"source": "Homelab", "path": "kubernetes.md", "title": "K8s"}],
"suggestions": [],
}
assert _payload(sse_event(payload)) == payload
def test_error_frame_serializes() -> None:
frame = sse_event({"type": "error", "detail": "boom"})
assert _payload(frame) == {"type": "error", "detail": "boom"}
def test_unicode_survives_roundtrip() -> None:
frame = sse_event({"type": "delta", "text": "🧠 café — \"quoted\""})
# ensure_ascii=False keeps the frame readable (no \uXXXX escapes).
assert "🧠 café" in frame
assert _payload(frame)["text"] == "🧠 café — \"quoted\""
def test_multi_line_text_stays_one_frame() -> None:
"""Newlines inside the JSON payload must be escaped so the frame
delimiter ``\\n\\n`` remains unambiguous."""
frame = sse_event({"type": "delta", "text": "line1\nline2\n\n"})
assert frame.count("\n\n") == 1 # only the frame terminator
assert _payload(frame)["text"] == "line1\nline2\n\n"
def test_error_event_model_serializes_exact_frame() -> None:
"""The ``ChatErrorEvent`` model is the wire shape of every server-side
failure the UI's state machine (phase 06) must recover from.
Phase 114: the additive ``hint`` field serializes ``null`` when
absent (old clients ignore the field — PLAN §4; the
``ChatDoneEvent.related`` pattern)."""
frame = sse_event(ChatErrorEvent(detail="boom").model_dump())
assert frame == 'data: {"type": "error", "detail": "boom", "hint": null}\n\n'
assert _payload(frame) == {"type": "error", "detail": "boom", "hint": None}
def test_error_event_shape_is_type_detail_and_optional_hint() -> None:
dumped = ChatErrorEvent(detail="The chat model dropped the connection").model_dump()
assert set(dumped.keys()) == {"type", "detail", "hint"}
assert dumped["type"] == "error" # default — call sites never spell it out
assert dumped["hint"] is None # absent hint serializes null, not dropped
def test_error_event_hint_serializes_verbatim_when_set() -> None:
"""Phase 114 (TODO L6): the too-long frame carries the reachability-
fine hint — it survives the roundtrip byte-identically (em-dash and
all) for the banner to show in place of the default hint."""
hint = "The app reached the embedding model fine — only the question length is the problem."
dumped = ChatErrorEvent(
detail="Question too long — trim it and re-ask.", hint=hint
).model_dump()
assert dumped["hint"] == hint
payload = _payload(sse_event(dumped))
assert payload["hint"] == hint
assert payload["detail"] == "Question too long — trim it and re-ask."
def test_thinking_frame_serializes_exactly() -> None:
"""Phase 17 (PLAN §4 extension): the ``thinking`` frame is exactly
``{type: "thinking", text: str}`` — the sibling shape of ``delta``
the client's readSSE handler will branch on."""
frame = sse_event(ChatThinkingEvent(text="Step 1: check the docs…").model_dump())
assert frame == 'data: {"type": "thinking", "text": "Step 1: check the docs…"}\n\n'
assert _payload(frame) == {"type": "thinking", "text": "Step 1: check the docs…"}
def test_thinking_event_shape_is_type_and_text_only() -> None:
dumped = ChatThinkingEvent(text="hmm").model_dump()
assert set(dumped.keys()) == {"type", "text"}
assert dumped["type"] == "thinking" # default — call sites never spell it out
def test_tool_frame_serializes_exactly() -> None:
"""Phase 37 (PLAN §4 extension): the ``tool`` frame is exactly
``{type: "tool", name: str, argument: str | null}`` — one per
model-requested document tool call, streamed ahead of the ``delta``
frames of the answer."""
frame = sse_event(ChatToolEvent(name="read", argument="S/p.md").model_dump())
assert frame == 'data: {"type": "tool", "name": "read", "argument": "S/p.md"}\n\n'
assert _payload(frame) == {"type": "tool", "name": "read", "argument": "S/p.md"}
def test_tool_frame_argument_is_null_for_parameterless_tools() -> None:
"""``ls`` (unscoped) carries no string argument, so its frame's
``argument`` serializes as JSON null (the client renders the name
alone)."""
dumped = ChatToolEvent(name="ls").model_dump()
assert dumped == {"type": "tool", "name": "ls", "argument": None}
assert _payload(sse_event(dumped))["argument"] is None
def test_tool_event_shape_is_type_name_argument_only() -> None:
dumped = ChatToolEvent(name="read", argument="S/p.md").model_dump()
assert set(dumped.keys()) == {"type", "name", "argument"}
assert dumped["type"] == "tool" # default — call sites never spell it out
def test_tool_frame_range_read_carries_offset_and_limit() -> None:
"""Phase 131 (task 04): a range ``read``'s frame carries the line
range the model requested — the wire's assertion surface for the
owner's grep → read(offset/limit) dance (the story E2E pins the
REAL ints the mock parsed from the grep result)."""
frame = sse_event(
ChatToolEvent(
name="read", argument="S/p.md", offset=1500, limit=30
).model_dump()
)
assert frame == (
'data: {"type": "tool", "name": "read", "argument": "S/p.md", '
'"offset": 1500, "limit": 30}\n\n'
)
assert _payload(frame) == {
"type": "tool",
"name": "read",
"argument": "S/p.md",
"offset": 1500,
"limit": 30,
}
def test_tool_event_range_fields_absent_not_null_when_omitted() -> None:
"""Phase 131 (task 04): the ``offset`` / ``limit`` keys are ABSENT
(never ``null``) when omitted — a plain ``read`` (no range) and a
``grep`` (no range parameters at all) both serialize to the
byte-identical pre-phase shape (the omission pattern of
``ChatDoneEvent.answer_image``). A bad range VALUE never reaches
the frame at all: ``app.api.chat`` applies the whole-line-number
guard before building the event (the E2E suite pins the frame the
endpoint emits)."""
for dumped in (
ChatToolEvent(name="read", argument="S/p.md").model_dump(),
ChatToolEvent(name="read", argument="S/p.md", offset=None, limit=None).model_dump(),
ChatToolEvent(name="grep", argument="trap").model_dump(),
):
assert set(dumped.keys()) == {"type", "name", "argument"}, dumped
def test_retry_frame_serializes_exactly() -> None:
"""Phase 67: the ``retry`` frame is exactly ``{type: "retry",
attempt: int, max_attempts: int}`` — a transient status the client's
readSSE handler branches on ("Communication interrupted — retrying
(n of N)…"), never an error. ``attempt`` is the attempt the server is
about to try next (1-based); the contract the frontend branch and the
E2E suite key off, locked here ahead of the JS implementation."""
frame = sse_event(ChatRetryEvent(attempt=2, max_attempts=4).model_dump())
assert frame == 'data: {"type": "retry", "attempt": 2, "max_attempts": 4}\n\n'
assert _payload(frame) == {"type": "retry", "attempt": 2, "max_attempts": 4}
def test_retry_event_shape_is_type_attempt_max_attempts_only() -> None:
dumped = ChatRetryEvent(attempt=2, max_attempts=4).model_dump()
assert set(dumped.keys()) == {"type", "attempt", "max_attempts"}
assert dumped["type"] == "retry" # default — call sites never spell it out
assert isinstance(dumped["attempt"], int)
assert isinstance(dumped["max_attempts"], int)