# Task 01 — the validated `ChatMessage.segments` field (server) **Phase:** `158_thinking_timeline` · **Story:** n/a ## Objective Saved-chat writes accept the new optional `segments` array (a discriminated `thinking`/`tool` union with phase-83-style caps), omit it when absent so pre-phase records round-trip byte-identically, and reject malformed values at the `extra="forbid"` boundary. ## Work 1. `app/schemas.py`: - `ChatThinkingSegment` (a `BaseModel`, beside `ToolCall`): `kind: Literal["thinking"]`, `text: str = Field(min_length=1, max_length=32_000)` (`HistoryTurn`'s cap, phase 83 — docstring cross-reference). - `ChatToolSegment` (a `BaseModel`): `kind: Literal["tool"]` + the EXACT `ToolCall` field set (name / argument / offset / limit / path / truncated / chars_shown / chars_total with their existing types and caps — mirror, do not weaken; docstring cross-references `ToolCall` as the shape source). - `ChatSegment = Annotated[Union[ChatThinkingSegment, ChatToolSegment], Field(discriminator="kind")]`. - `ChatMessage.segments: list[ChatSegment] | None = Field(default=None, max_length=50)` — with the phase-83 voice comment (the cap sizing: far past any realistic turn — the round cap is 10 → ≤ 20 entries; 50 matches the `tools` cap). - The EXISTING wrap serializer (`_drop_image_when_absent`) gains the omission rule: after the two image pops, `if self.segments is None: data.pop("segments", None)` — ABSENT, never `null` (the phase-50 round-trip contract; the phase-122 `SourceRef.image_url` omission precedent, the phase-113 lesson applied up front: the field EXISTS so the boundary does NOT 422 the done-time auto-save carrying it). - The `ChatMessage` docstring gains the phase-158 paragraph (the shape, the omission rule, the `extra="forbid"` boundary) in the established voice; `HistoryTurn` and everything in `app/rag/` is UNTOUCHED (the history wire stays `{who, text, thinking}`). 2. NO migration, NO router change (`app/api/chats.py` validates through `ChatMessage` — `m.model_dump()` + `ChatMessage.model_validate(m)` already carry the new field end to end, including the public shared read `SharedChatOut.messages`). ## Testing & Quality - Integration (extend `tests/integration/test_chats_api.py` — the file already pins the brain-record contract, phase 83/113/120/123/124): - a record WITH `segments` (one `thinking` entry + two `tool` entries carrying every additive field: `offset/limit`, `path`, `truncated/chars_shown/chars_total`) is accepted by `POST /api/chats` and round-trips through `GET` (the shape preserved, entry order preserved); - `segments` with 51 entries → 422; a thinking-segment `text` of 32 001 chars → 422; a segment with `kind: "bogus"` → 422; a record with a stray top-level key (e.g. `"foo": 1`) still → 422 (the `extra="forbid"` boundary intact); - a record WITHOUT `segments` (a pre-phase brain record, the full phase-113 shape) round-trips with the key ABSENT from the served JSON (never `null` — the byte-identity pin, the phase-50 contract); - the shared read path (the public read endpoint's `messages`) carries the segments of a saved record with them (the public shape, the phase-51 contract). - Coverage: **>90%** on this task's new/modified code (the schema code is exercised by the integration tests). ## Completion Criteria - [ ] the three models + the field + the serializer rule land exactly as designed (a source check) - [ ] the new integration tests are green; the EXISTING `test_chats_api.py` tests stay green unchanged - [ ] `uv run pytest tests/integration -q` green; `uv run ruff check . && uv run pyright` clean - [ ] no change in `app/rag/`, no migration, no router change (a `git diff --stat` scoped check)