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
+137
View File
@@ -1092,6 +1092,88 @@ class ToolCall(BaseModel):
return data
class ChatThinkingSegment(BaseModel):
"""One CONTIGUOUS thinking run in the turn timeline (phase 158,
D1/D4) — the per-run ``details.thinking`` block the chat page
renders in arrival order (the chronological timeline; the
phase-109 lumped single-block layout is replaced, owner direction
2026-10-04).
``text`` is the run's accumulated thinking text — the same text
that streamed live into that run's block. ``min_length=1``: an
EMPTY run stores NOTHING (a zero-char run — e.g. a
``BOR_STREAM_THINKING``-suppressed turn) is absent from the
record, so a stored entry is always non-empty. ``max_length=
32_000`` mirrors :class:`HistoryTurn.thinking`'s cap (phase 83,
SEC-05 — a single turn longer than that is already rejected on
the chat path, so a saved chat can never legitimately carry
more).
"""
kind: Literal["thinking"]
text: str = Field(min_length=1, max_length=32_000)
class ChatToolSegment(BaseModel):
"""One tool call in the turn timeline (phase 158, D1/D4) — the
EXACT :class:`ToolCall` field set, mirrored, not weakened:
``name`` / ``argument`` / ``offset`` / ``limit`` / ``path`` /
``truncated`` / ``chars_shown`` / ``chars_total`` with their
existing types and caps (``ToolCall`` is the shape source — the
phase-37 ``{name, argument}`` pair, the phase-95 truncation
stamp, the phase-139 requested range, the phase-146 scoped grep
``path``). ``kind`` is the discriminator (see
:data:`ChatSegment`); everything else is byte-for-byte the record
the UI's ``toolAcc`` keeps, so a ``segments`` entry and the
matching ``tools`` entry of the same record are the same data
(``tools`` stays for old clients — D4's graceful degradation).
``path`` (the phase-146 additive scope field) follows the
:class:`ToolCall` omission rule: ABSENT, never ``null``, on
every non-scoped record (the :meth:`_serialize` below — the
phase-50 backward-compat rule: a pre-phase-146 entry validates
unchanged and round-trips byte-identically).
"""
kind: Literal["tool"]
name: str = Field(max_length=100)
argument: str | None = Field(default=None, max_length=2000)
truncated: bool = False
chars_shown: int | None = Field(default=None, ge=0)
chars_total: int | None = Field(default=None, ge=0)
offset: int | None = Field(default=None, ge=1)
limit: int | None = Field(default=None, ge=1)
# Phase 146 (task 02, mirrored from ToolCall): grep only — the
# scoped re-search's document (the frame's additive ``path``);
# None on every non-scoped record → the key is ABSENT, never
# null (the serializer below).
path: str | None = Field(default=None, max_length=2000)
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
# Phase 146 (task 02, mirrored from ToolCall): the scope is
# ABSENT (never null) on every non-scoped record — the
# pre-phase saved shape stays byte-identical (the ChatToolEvent
# omission pattern; the phase-50 round-trip pin keys on it).
data = handler(self)
if self.path is None:
data.pop("path", None)
return data
#: One timeline segment (phase 158, D4) — the discriminated union:
#: ``kind`` picks the variant (``"thinking"`` →
#: :class:`ChatThinkingSegment`, ``"tool"`` → :class:`ChatToolSegment`).
#: The JSON shape is a flat object (the discriminator rides as a
#: field — pydantic's ``Field(discriminator="kind")``, no wrapper).
#: An unknown ``kind`` 422s at the boundary (the ``extra="forbid"``
#: voice: nothing outside the shape crosses).
ChatSegment = Annotated[
ChatThinkingSegment | ChatToolSegment,
Field(discriminator="kind"),
]
#: One suggestion chip (phase 83, A1): a short deterministic string —
#: ``derive_suggestions``'s output runs to ~80 chars, so 200 chars is
#: the boundary sanity cap. Annotated alias: the JSON shape stays a
@@ -1147,6 +1229,33 @@ class ChatMessage(BaseModel):
serializer change: ``None`` values flow as absent/None exactly like
``stopped`` today — the phase-50 byte-identical round-trip contract
covers the new keys automatically.
Phase 158 (task 01, D4/D6 — owner direction 2026-10-04): the
turn's CHRONOLOGICAL run order — ``segments``, a list of the
discriminated union :data:`ChatSegment` (``kind`` = ``"thinking"``
→ :class:`ChatThinkingSegment`, ``"tool"`` →
:class:`ChatToolSegment`): one ``thinking`` entry per CONTIGUOUS
thinking run (the run's accumulated text) and one ``tool`` entry
per tool call (the EXACT :class:`ToolCall` record shape — the
phase-37 ``{name, argument}`` pair plus the phase-95 truncation
stamp, the phase-139 requested range, and the phase-146 scoped
grep ``path``). It is ADDITIVE (D4): ``thinking`` (the full
concatenation — feeds the history wire + the per-turn log) and
``tools`` (every call record — old-client graceful degradation)
are KEPT and UNCHANGED; ``segments`` is omitted when ``None`` (the
:meth:`_drop_image_when_absent` serializer below — ABSENT, never
``null`` — the phase-50 round-trip contract), so a
no-thinking/no-tools record — and every pre-phase record —
round-trips byte-identically to pre-phase. The field EXISTS so
the ``extra="forbid"`` boundary does NOT 422 the done-time
auto-save carrying it (the phase-113 lesson, applied up front;
the phase-124 ``answer_image`` precedent). ``max_length=50`` (the
phase-83 voice): far past any realistic turn — the round cap is
10 → ≤ 20 entries; 50 matches the ``tools`` cap. Segments never
travel the history wire (A10 unchanged — the concatenated
``thinking`` travels as ``reasoning_content`` exactly as today;
:class:`HistoryTurn` and
:func:`app.rag.prompts.history_to_messages` are untouched).
"""
model_config = ConfigDict(extra="forbid")
@@ -1159,6 +1268,24 @@ class ChatMessage(BaseModel):
suggestions: list[_Chip] | None = Field(default=None, max_length=50)
thinking: str | None = Field(default=None, max_length=32_000)
tools: list[ToolCall] | None = Field(default=None, max_length=50)
# Phase 158 (task 01, D4/D6 — owner direction 2026-10-04): the
# turn's CHRONOLOGICAL run order — one ``thinking`` entry per
# contiguous thinking run (that run's accumulated text), one
# ``tool`` entry per tool call (the exact ToolCall record shape,
# the ChatSegment discriminated union). ADDITIVE (D4):
# ``thinking`` (the full concatenation — the history wire + the
# per-turn log) and ``tools`` (every call record — old clients)
# stay KEPT and UNCHANGED; ``segments`` is omitted when None (the
# _drop_image_when_absent serializer below — ABSENT, never null,
# the phase-50 round-trip contract), so a no-thinking/no-tools
# record — and every pre-phase record — round-trips
# byte-identically to pre-phase. The field EXISTS so the
# extra="forbid" boundary does NOT 422 the done-time auto-save
# carrying it (the phase-113 lesson, applied up front; the
# phase-124 answer_image precedent). max_length=50 (the phase-83
# voice, the tools cap): far past any realistic turn — the round
# cap is 10 → ≤ 20 entries; 50 matches the tools cap.
segments: list[ChatSegment] | None = Field(default=None, max_length=50)
stopped: bool | None = None
# Phase 120 (task 01, locked A1): the failed-turn marker + the
# persisted error detail (the phase-48 ``stopped`` precedent).
@@ -1218,6 +1345,16 @@ class ChatMessage(BaseModel):
data.pop("image", None)
if self.answer_image is None:
data.pop("answer_image", None)
# Phase 158 (task 01, D4): the timeline is ABSENT (never
# null) when there is no run to render — the pre-phase /
# no-thinking-no-tools shape stays byte-identical (the
# phase-50 round-trip contract; the phase-122
# SourceRef.image_url omission precedent). A record WITH
# segments keeps them (the restore path re-renders the
# timeline from them; shared.js's parity copy does the same
# on the shared page).
if self.segments is None:
data.pop("segments", None)
return data