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
+60 -38
View File
@@ -30,6 +30,18 @@ SSE is exactly ``delta → tool → thinking → delta → thinking → done``
the owner's repro, and every window is deterministic (the mock's
baked delays, the ``slow_llm.py`` pacing precedent).
Phase 158 supersession (owner direction 2026-10-04, recorded): phase 109's
D15 re-open contract (ONE ``details.thinking`` re-opened by every later
thinking frame) is REPLACED by the per-run timeline (D1/D5): each
contiguous thinking run streams into its OWN block, and the first frame
of a new kind closes the previous run. The SSE stream is unchanged, so
this turn renders ONE ``.tool-calls-disclosure`` + TWO ``.thinking``
blocks (arrival order, all ABOVE the answer bubble — D2), open in their
live windows and folded at rest. The loader pins
(``test_loader_visible_from_send_through_the_tool_gap``,
``test_loader_a11y_and_reduced_motion``) stay unchanged — the
never-stale loader contract (D16) is intact.
Each test sends the marker question in its OWN fresh conversation
(one full turn per test — the ``page`` fixture is a fresh browser
context, ``_reset_db`` reseeds the KB deterministically):
@@ -40,14 +52,18 @@ context, ``_reset_db`` reseeds the KB deterministically):
line lands (the turn is provably in flight), the tool line carries
the phase-87 elapsed counter in the tool gap, and ``#send-status``
carries a state text (not empty) throughout.
* ``test_thinking_block_reopens_after_delta_with_visible_loader`` —
THE reported symptom's state: the thinking block re-opens after the
answer has started (open + non-empty ``.thinking-text`` while the
bubble already carries call 1's content) with the loader STILL
visible (the frozen window is gone); the terminal state is clean
(loader hidden, the scratchpad carries both thinking rounds, the
bubble carries BOTH answers, the send button reads "Send" not
"Stop", ``#send-status`` back to the idle shape).
* ``test_thinking_runs_open_in_live_window_with_visible_loader`` —
THE reported symptom's state, re-pinned for phase 158 (D5 replaces
phase-109 D15 — a NEW block instead of a re-open): the post-delta
thinking frames open a per-run ``.thinking`` block (exactly one
block, open + non-empty ``.thinking-text`` while the bubble already
carries call 1's content — the run starts AFTER the first delta) with
the loader STILL visible (the frozen window is gone — the user sees
the new thinking in its own live block); the terminal state is clean
(loader hidden, the scratchpad holds TWO ``.thinking`` blocks —
EACH carrying its own round's text — BOTH folded at rest, the bubble
carries BOTH answers, the send button reads "Send" not "Stop",
``#send-status`` back to the idle shape).
* ``test_loader_a11y_and_reduced_motion`` — after a full turn: the
loader element is ``aria-hidden="true"`` in the DOM and
``#send-status`` (the sole ``aria-live`` announcer) is back to the
@@ -192,7 +208,7 @@ def test_loader_visible_from_send_through_the_tool_gap(
expect(page.locator("#send-status")).not_to_be_empty()
def test_thinking_block_reopens_after_delta_with_visible_loader(
def test_thinking_runs_open_in_live_window_with_visible_loader(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm, seed=True)
@@ -202,28 +218,33 @@ def test_thinking_block_reopens_after_delta_with_visible_loader(
_send(page)
blocks = page.locator(".msg.brain .thinking")
bubble = _answer_bubble(page)
block = page.locator(".msg.brain .thinking")
text_el = page.locator(".msg.brain .thinking-text")
loader = page.locator("#turn-loader")
# THE reported symptom's state (D15): the thinking block is open
# with non-empty text WHILE the answer bubble already carries
# call 1's content — the post-delta re-open (call 2's thinking
# frames re-opened the block the first delta had closed).
# THE reported symptom's state (phase 158 D5 replaces phase-109
# D15 — the post-delta thinking frames open a NEW per-run block
# instead of re-opening the first delta's block): exactly ONE
# ``.thinking`` block exists, it is OPEN with non-empty text WHILE
# the answer bubble already carries call 1's content — the first
# thinking run starts AFTER the first delta (the owner's "thinking
# after responding" repro), and the loader is STILL visible (the
# frozen window is gone — the user sees the new thinking in its
# own live block).
page.wait_for_function(
"""() => {
const b = document.querySelector('.msg.brain .thinking');
const t = b ? b.querySelector('.thinking-text') : null;
return !!b && b.open && !!t && t.textContent.trim().length > 0;
const b = document.querySelectorAll('.msg.brain .thinking');
const t = b.length === 1 ? b[0].querySelector('.thinking-text') : null;
return b.length === 1 && !!b[0].open && !!t
&& t.textContent.trim().length > 0;
}""",
timeout=30_000,
)
expect(block).to_have_count(1)
expect(block).to_have_attribute("open", "")
expect(text_el).to_contain_text(SENTINEL_THOUGHT)
# The re-open is genuinely AFTER a delta: call 1's content is
# already in the bubble ...
expect(blocks).to_have_count(1)
expect(blocks).to_have_attribute("open", "")
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
# The run is genuinely AFTER a delta: call 1's content is already
# in the bubble ...
expect(bubble).to_contain_text(SENTINEL_FIRST)
# ... and the loader is STILL visible — the frozen window is gone.
expect(loader).to_be_visible()
@@ -231,23 +252,24 @@ def test_thinking_block_reopens_after_delta_with_visible_loader(
# Terminal approach: call 2's distinctive final sentence landed in
# the bubble ...
expect(bubble).to_contain_text(SENTINEL_FINAL, timeout=30_000)
# ... and the FINAL thinking round (the last frames before done —
# thinking AFTER the answer's last delta) re-opened the block
# AGAIN: the scratchpad carries the last round's text and the
# block is open (the `thinking` handler opens it before it
# renders — D15), with the loader still visible (the turn is
# still in flight — no terminal frame yet).
expect(text_el).to_contain_text(SENTINEL_FINAL_THOUGHT, timeout=30_000)
expect(block).to_have_attribute("open", "")
expect(loader).to_be_visible()
# The turn settles (done → idle through setUiState): the loader is
# HIDDEN (its sole owner — D16, no per-handler cleanup) ...
# ... and the turn settles (done → idle through setUiState): the
# loader is HIDDEN (its sole owner — D16, no per-handler cleanup) ...
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(loader).to_be_hidden()
# ... the scratchpad text is intact (BOTH thinking rounds) ...
expect(text_el).to_contain_text(SENTINEL_THOUGHT)
expect(text_el).to_contain_text(SENTINEL_FINAL_THOUGHT)
# ... the scratchpad holds TWO ``.thinking`` blocks (the first
# delta closed run 1; the final round — thinking AFTER the
# answer's last delta — opened its OWN block rather than
# re-opening run 1's) ...
expect(blocks).to_have_count(2)
# ... EACH carrying its OWN round's text (the flow's two distinct
# reasoning streams — separate blocks, not one lump) ...
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
expect(blocks.nth(1).locator(".thinking-text")).to_contain_text(SENTINEL_FINAL_THOUGHT)
# ... and BOTH runs are FOLDED at rest (D5 — the per-run
# fold-at-rest; the phase-109 symptom — the frozen chat — stays
# fixed: a run is open in its live window, never re-opened after
# done) ...
expect(page.locator(".msg.brain .thinking[open]")).to_have_count(0)
# ... the bubble carries BOTH answers (call 1 + call 2) ...
expect(bubble).to_contain_text(SENTINEL_FIRST)
expect(bubble).to_contain_text(SENTINEL_FINAL)