phase: 158_thinking_timeline
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:
@@ -1543,3 +1543,285 @@ def test_shared_chat_with_failed_record_serves_public_shape(
|
||||
assert body["messages"][1]["failed"] is True
|
||||
assert body["messages"][1]["error"] == FAILED_BRAIN["error"]
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 158 (task 01): the validated 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, OMITTED when absent (the phase-50 byte-identical
|
||||
# round-trip contract — ABSENT, never null), and the extra="forbid"
|
||||
# boundary INTACT around the new key. The router is UNCHANGED (task
|
||||
# 01's spec): POST/PUT validate through ChatMessage and store
|
||||
# m.model_dump(); the admin GET and the public shared read re-validate
|
||||
# the stored JSONB through ChatMessage — so every surface inherits the
|
||||
# field + the omission rule from the schema alone.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The full segment array (task 01's spec): one ``thinking`` entry +
|
||||
#: two ``tool`` entries carrying EVERY additive field (offset/limit,
|
||||
#: path, truncated/chars_shown/chars_total — the phase-95/139/146
|
||||
#: shapes; the tool entries are byte-for-byte the ToolCall record
|
||||
#: shape, the ChatToolSegment mirror).
|
||||
SEGMENTS: list[dict[str, Any]] = [
|
||||
{"kind": "thinking", "text": "Step 1: list the documents to find the reference server config."},
|
||||
{
|
||||
"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",
|
||||
},
|
||||
{
|
||||
"kind": "tool",
|
||||
"name": "grep",
|
||||
"argument": "reese-sentinel-42",
|
||||
"truncated": False,
|
||||
"chars_shown": None,
|
||||
"chars_total": None,
|
||||
"offset": 1,
|
||||
"limit": 20,
|
||||
"path": "deployments/traefik.yaml",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _segmented_brain() -> dict[str, Any]:
|
||||
"""A full ``bor.chat.v1`` brain record (phase 113 shape) PLUS the
|
||||
``segments`` timeline (D4 — ``thinking`` + ``tools`` KEPT, the
|
||||
runs' order ADDITIVE)."""
|
||||
return {**FULL_BRAIN, "segments": SEGMENTS}
|
||||
|
||||
|
||||
def test_create_round_trips_segments(admin_client: TestClient, db: Session) -> None:
|
||||
"""A record WITH ``segments`` (both kinds, every additive tool
|
||||
field) is ACCEPTED by ``POST /api/chats`` (the phase-113 lesson,
|
||||
applied up front: the field exists so the ``extra="forbid"``
|
||||
boundary does not 422 the done-time auto-save carrying it — NOT a
|
||||
silent 422) and round-trips: the 201 body, the RAW stored JSONB
|
||||
(byte-for-byte), and the admin GET all carry the array with the
|
||||
entry ORDER preserved; the user record stays WITHOUT the key.
|
||||
The PUT upsert carries it too (the re-Save path's
|
||||
``m.model_dump()`` stores it unchanged)."""
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user(FIRST_QUESTION), _segmented_brain()]}
|
||||
)
|
||||
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
|
||||
body = r.json()
|
||||
assert body["messages"][1]["segments"] == SEGMENTS
|
||||
assert "segments" not in body["messages"][0]
|
||||
|
||||
# The RAW stored JSONB (the DB is the durable boundary): the full
|
||||
# array, byte-for-byte.
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert raw[1]["segments"] == SEGMENTS
|
||||
assert "segments" not in raw[0]
|
||||
|
||||
# The admin GET round-trips the array losslessly (order included).
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["messages"][1]["segments"] == SEGMENTS
|
||||
|
||||
# The re-Save upsert keeps the array (the stored shape is
|
||||
# re-validated through ChatMessage on every write).
|
||||
updated = admin_client.put(
|
||||
f"/api/chats/{body['id']}",
|
||||
json={"messages": [_user(FIRST_QUESTION), _segmented_brain()]},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["messages"][1]["segments"] == SEGMENTS
|
||||
|
||||
|
||||
def test_shared_serve_includes_segments(admin_client: TestClient) -> None:
|
||||
"""The shared view is faithful (the shared page's ``addSegments``
|
||||
parity copy, task 03, renders from it): the PUBLIC snapshot's
|
||||
brain record carries the segments (the public shape gains no new
|
||||
top-level key — ``SHARED_OUT_KEYS`` is unchanged), with the entry
|
||||
order preserved; the user record stays WITHOUT the key."""
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": EXPLICIT_TITLE, "messages": [_user(FIRST_QUESTION), _segmented_brain()]},
|
||||
).json()
|
||||
share_url = _share(admin_client, created["id"])["share_url"]
|
||||
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
r = anon.get(f"/api{share_url}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
|
||||
assert body["title"] == EXPLICIT_TITLE
|
||||
assert body["messages"][1]["segments"] == SEGMENTS
|
||||
assert "segments" not in body["messages"][0]
|
||||
|
||||
|
||||
def test_segments_over_50_entries_rejected(admin_client: TestClient) -> None:
|
||||
"""``max_length=50`` (the phase-83 voice, the ``tools`` cap): 51
|
||||
entries 422 at the boundary — far past any realistic turn (the
|
||||
round cap is 10 → ≤ 20 entries)."""
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": "x"}] * 51,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_thinking_segment_text_caps_rejected(admin_client: TestClient) -> None:
|
||||
"""The thinking entry's ``text`` cap (HistoryTurn's 32 000, phase
|
||||
83): 32 001 chars 422; the ``min_length=1`` bound — an EMPTY text
|
||||
(the zero-char run the client never stores) 422s too."""
|
||||
over = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": "x" * 32_001}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert over.status_code == 422
|
||||
empty = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": ""}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert empty.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_tool_segment_caps_rejected(admin_client: TestClient) -> None:
|
||||
"""The tool entry mirrors ToolCall's caps (mirrored, not
|
||||
weakened): a ``name`` over 100 and an ``offset`` of 0 (the
|
||||
``ge=1`` whole-line bound) both 422 — the same values that 422 on
|
||||
the ``tools`` entry."""
|
||||
bad_name = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [
|
||||
{"kind": "tool", "name": "n" * 101, "argument": None}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert bad_name.status_code == 422
|
||||
bad_offset = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "tool", "name": "read", "offset": 0}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert bad_offset.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_unknown_segment_kind_rejected(admin_client: TestClient) -> None:
|
||||
"""The discriminator (``kind`` = ``"thinking"`` | ``"tool"``): an
|
||||
unknown ``kind`` 422s at the boundary — nothing outside the union
|
||||
crosses (the ``extra="forbid"`` voice, applied to the variant
|
||||
pick)."""
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "bogus", "text": "x"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_stray_top_level_key_still_rejected_with_segments(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""The ``extra="forbid"`` boundary is INTACT around the new key:
|
||||
a record that carries a well-formed ``segments`` AND a stray
|
||||
top-level key still 422s — adding the field changed nothing about
|
||||
which keys are accepted (the phase-83 voice)."""
|
||||
bad = _segmented_brain()
|
||||
bad["foo"] = 1
|
||||
r = admin_client.post("/api/chats", json={"messages": [bad]})
|
||||
assert r.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_record_without_segments_round_trips_key_absent(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The phase-50 byte-identical contract, pinned: a record WITHOUT
|
||||
``segments`` (the full phase-113 brain shape — every pre-phase
|
||||
chat) round-trips with the key ABSENT from every surface — the
|
||||
201 body, the RAW stored JSONB, and the admin GET — NEVER
|
||||
``null`` (the ``_drop_image_when_absent`` serializer's phase-158
|
||||
pop). An EXPLICIT empty list is a different thing: ``[]`` is a
|
||||
value, so it round-trips as ``[]`` (present, empty — the pop is
|
||||
``None``-gated only)."""
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
|
||||
assert "segments" not in body["messages"][0]
|
||||
assert "segments" not in body["messages"][1]
|
||||
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert "segments" not in raw[0]
|
||||
assert "segments" not in raw[1]
|
||||
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert "segments" not in got.json()["messages"][0]
|
||||
assert "segments" not in got.json()["messages"][1]
|
||||
|
||||
# An explicit empty list round-trips as [] (present, not popped).
|
||||
r2 = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"messages": [{"who": "brain", "text": "ok", "segments": []}]},
|
||||
)
|
||||
assert r2.status_code == 201, r2.text
|
||||
got2 = admin_client.get(f"/api/chats/{r2.json()['id']}")
|
||||
assert got2.json()["messages"][0]["segments"] == []
|
||||
|
||||
Reference in New Issue
Block a user