phase: 158_thinking_timeline
Build and Push Containers / build-and-push-app (push) Successful in 8m38s
Build and Push Containers / build-and-push-db (push) Successful in 32s

All verification complete. Every gate is green and the docs check out. Final report:

## Phase 158 final verification pass — COMPLETE

**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**

**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)

**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**

**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.

**Next pending phase:** none — `todo/` contains only 158.
This commit is contained in:
2026-10-05 02:52:55 -04:00
parent 3f752fb77f
commit 31bd903a93
49 changed files with 4280 additions and 482 deletions
+222
View File
@@ -16,6 +16,9 @@ from pydantic import ValidationError
from app.schemas import (
ChatMessage,
ChatRequest,
ChatSegment,
ChatThinkingSegment,
ChatToolSegment,
SavedChatCreate,
SavedChatUpdate,
SourceRef,
@@ -682,3 +685,222 @@ def test_chat_tool_result_event_counts_reject_negative() -> None:
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 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,
}
# 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=32_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, the list ≤ 50 (the phase-83 voice — the
``tools`` cap)."""
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"}] * (TOOLS_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)