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:
@@ -1,17 +1,21 @@
|
||||
"""Unit: the phase-109 never-frozen-turn frontend contract (tasks 01-02).
|
||||
"""Unit: the phase-109 never-frozen-turn frontend contract (tasks
|
||||
01-02), updated by phase 158 (task 02).
|
||||
|
||||
No new Python app logic exists for this task — the behavior lives in
|
||||
``frontend/assets/app.js`` and is E2E-gated by the story suite (task 03).
|
||||
Like the other frontend-adjacent unit files (the house
|
||||
read-the-assets-as-text pattern, cf. ``test_frontend_tool_states.py``),
|
||||
this module pins the JS markers the phase depends on: the thinking block
|
||||
becomes a TOGGLE (D15) — ``thinking`` frames open it (idempotent),
|
||||
``delta`` frames close it — while the phase-14 restore path and the
|
||||
phase-17 follow-the-tail pin logic stay byte-for-byte untouched. Task 02
|
||||
pins the persistent in-turn loader (D16): the static ``#turn-loader``
|
||||
markup, ``setUiState`` as its SOLE visibility owner (the structural
|
||||
never-stale guarantee), and the CSS contract (reused typing-dot
|
||||
animation + reduced-motion + provenance).
|
||||
this module pins the JS markers the phase depends on: the phase-109
|
||||
D15 re-open pin is REPLACED by the phase-158 per-run contract (owner
|
||||
direction 2026-10-04 — ``thinking`` frames open a NEW block per
|
||||
contiguous run, ``delta`` frames fold the active run, a later
|
||||
thinking frame opens a NEW run, not a re-open) — while the
|
||||
phase-14 restore path and the phase-17 follow-the-tail pin logic stay
|
||||
byte-for-byte untouched (the capture moves verbatim, per block).
|
||||
Task 02 (phase 109) pins the persistent in-turn loader (D16): the
|
||||
static ``#turn-loader`` markup, ``setUiState`` as its SOLE visibility
|
||||
owner (the structural never-stale guarantee), and the CSS contract
|
||||
(reused typing-dot animation + reduced-motion + provenance).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -48,87 +52,88 @@ def _thinking_branch() -> str:
|
||||
return js[thinking_idx:tool_idx]
|
||||
|
||||
|
||||
def test_thinking_handler_reopens_the_collapsed_block() -> None:
|
||||
"""Phase 109 (D15): the `thinking` handler re-opens the block —
|
||||
``block.open = true`` sits in the handler, positioned AFTER the
|
||||
``ensureThinkingBlock(wrap)`` line (the block must exist before it
|
||||
can be opened). Idempotent: a no-op while already open (the
|
||||
pre-delta live flow), a re-open after a `delta` closed it (the next
|
||||
agent round — the reported freeze, TODO.md L3)."""
|
||||
def test_thinking_handler_opens_a_new_block_per_run() -> None:
|
||||
"""Phase 158 (D1 — the phase-109 D15 re-open pin, replaced by
|
||||
owner direction 2026-10-04): the `thinking` handler opens a NEW
|
||||
block per contiguous run — ``ensureThinkingRun(wrap)`` sits in the
|
||||
handler (a frame of a new kind closes the previous run and opens
|
||||
a new block), the RUN's text accumulates + re-renders into THAT
|
||||
block, and the D15 re-open assignment (``block.open = true``) is
|
||||
GONE from the branch — the open state is owned by the builder
|
||||
(a new run opens; the active run stays open)."""
|
||||
branch = _thinking_branch()
|
||||
ensure_idx = branch.find("ensureThinkingBlock(wrap)")
|
||||
assert ensure_idx != -1, "the handler must ensure the block first"
|
||||
reopen_idx = branch.find("block.open = true")
|
||||
assert reopen_idx != -1, (
|
||||
"the `thinking` handler must open the block (D15: "
|
||||
"open-while-thinking)"
|
||||
ensure_idx = branch.find("ensureThinkingRun(wrap)")
|
||||
assert ensure_idx != -1, (
|
||||
"the handler must open/extend the run's block (D1: per-run — "
|
||||
"a new kind opens a NEW block)"
|
||||
)
|
||||
assert ensure_idx < reopen_idx, (
|
||||
"`block.open = true` must sit AFTER `ensureThinkingBlock(wrap)` "
|
||||
"— the block must exist before it is opened"
|
||||
assert "currentRun.run.text += ev.text || \"\"" in branch, (
|
||||
"the RUN's text accumulates (the block renders the run, not "
|
||||
"the whole-turn accumulator)"
|
||||
)
|
||||
assert "block.open = true" not in branch, (
|
||||
"the D15 re-open assignment is GONE — ensureThinkingRun owns "
|
||||
"the open state"
|
||||
)
|
||||
|
||||
|
||||
def test_delta_handler_still_closes_the_block() -> None:
|
||||
"""Phase 109 (D15): the close side of the toggle survives — the
|
||||
`delta` handler still calls ``closeThinkingBlock(wrap)`` (closed
|
||||
while answering); nothing else about the block's lifecycle changes."""
|
||||
def test_delta_handler_folds_the_active_run() -> None:
|
||||
"""Phase 158 (D5 — the phase-109 D15 close-side pin, updated):
|
||||
the close side of the per-run contract — the `delta` handler calls
|
||||
``closeCurrentRun()`` (folds the active run, idempotent); a later
|
||||
thinking frame opens a NEW run above the bubble (the D15 re-open
|
||||
of the shared block is gone)."""
|
||||
js = _js()
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
done_idx = js.find('ev.type === "done"')
|
||||
assert -1 < delta_idx < done_idx, "the turn handler must branch on delta"
|
||||
branch = js[delta_idx:done_idx]
|
||||
assert "closeThinkingBlock(wrap)" in branch, (
|
||||
"the `delta` handler must keep closing the block (D15: "
|
||||
"closed-while-answering)"
|
||||
assert "closeCurrentRun()" in branch, (
|
||||
"the `delta` handler must fold the active run (D5: "
|
||||
"folded-while-answering)"
|
||||
)
|
||||
|
||||
|
||||
def test_close_thinking_block_docstring_says_toggle_not_one_way() -> None:
|
||||
"""Phase 109 (D15): the narrative flips. The section comment
|
||||
documenting ``closeThinkingBlock`` no longer claims "never reopens"
|
||||
— nowhere in app.js does — and documents the toggle contract instead:
|
||||
open-while-thinking / closed-while-answering, naming the `thinking`
|
||||
handler's re-open and the `delta` handler's close. The function
|
||||
body itself is unchanged (idempotent close, no-op without a
|
||||
block)."""
|
||||
def test_section_comment_says_per_run_not_reopen() -> None:
|
||||
"""Phase 158 (D1 — the phase-109 D15 narrative, replaced): the
|
||||
section comment documenting the run builders no longer claims the
|
||||
D15 toggle contract (the "re-opens" narrative is gone — nowhere
|
||||
in app.js does "never reopens" survive either) — it names the
|
||||
per-run timeline instead: each CONTIGUOUS run its own block,
|
||||
arrival order, D2 placement, and the D15 contract as replaced."""
|
||||
js = _js()
|
||||
fn = js.find("function closeThinkingBlock")
|
||||
assert fn != -1, "closeThinkingBlock must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "block.open = false" in body, "the close side is unchanged"
|
||||
# The block comment that documents the helpers: walk back from the
|
||||
# function to its section comment start.
|
||||
comment_start = js.rfind("/*", 0, fn)
|
||||
assert comment_start != -1
|
||||
section = js[comment_start:fn]
|
||||
assert "never reopens" not in section, (
|
||||
"the one-way-door claim is gone (phase 109, D15)"
|
||||
)
|
||||
assert "never reopens" not in js, (
|
||||
"the 'never reopens' narrative must not survive anywhere in app.js"
|
||||
"the one-way-door narrative must not survive anywhere in app.js"
|
||||
)
|
||||
# The block comment that documents the builders: walk back from
|
||||
# the first builder to its section comment start.
|
||||
comment_start = js.rfind("/*", 0, js.find("function createThinkingBlock"))
|
||||
assert comment_start != -1
|
||||
section = js[comment_start : js.find("function createThinkingBlock")]
|
||||
assert "re-opens" not in section, (
|
||||
"the D15 re-open narrative is gone (phase 158, D1)"
|
||||
)
|
||||
assert "PER-RUN" in section or "per-run" in section, (
|
||||
"the contract is named: per-run"
|
||||
)
|
||||
assert "CONTIGUOUS" in section, "a contiguous run per block is named"
|
||||
assert "arrival order" in section, "D2: arrival order is named"
|
||||
assert "REPLACED" in section or "superseded" in section.lower(), (
|
||||
"the D15 contract is named as replaced"
|
||||
)
|
||||
# The toggle contract is documented — and it names both sides:
|
||||
# the `thinking` handler's re-open and the `delta` handler's close.
|
||||
assert "TOGGLE" in section, "the contract is named: a toggle"
|
||||
assert "open-while-thinking" in section
|
||||
assert "closed-while-answering" in section
|
||||
assert "`thinking`" in section, "the contract names the thinking handler"
|
||||
assert "re-opens" in section, "the contract names the thinking handler's re-open"
|
||||
assert "`delta`" in section, "the contract names the delta handler's close"
|
||||
|
||||
|
||||
def test_restore_path_still_collapses_stored_blocks() -> None:
|
||||
"""Phase-14 contract regression: ``renderStoredMessage`` restores
|
||||
stored thinking blocks COLLAPSED (``block.open = false``) — the
|
||||
phase-109 toggle only changes the LIVE `thinking` handler, the
|
||||
restore path is untouched."""
|
||||
stored thinking blocks COLLAPSED (``block.open = false`` — the
|
||||
shared ``createThinkingBlock`` builder, phase 158 task 02) — the
|
||||
phase-158 per-run live path never opens a restored block."""
|
||||
js = _js()
|
||||
fn = js.find("function renderStoredMessage")
|
||||
assert fn != -1, "renderStoredMessage must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "if (m.thinking)" in body
|
||||
assert "ensureThinkingBlock(wrap)" in body
|
||||
assert "createThinkingBlock()" in body
|
||||
assert "block.open = false" in body, "stored blocks restore collapsed"
|
||||
# The live re-open must NOT have leaked into the restore path:
|
||||
# only the collapse assignment may appear in the function body.
|
||||
@@ -142,10 +147,11 @@ def test_follow_the_tail_pin_logic_is_untouched() -> None:
|
||||
unchanged — ``THINKING_NEAR_BOTTOM_PX`` (32px band) +
|
||||
``isThinkingNearBottom`` and the pre-render capture
|
||||
``const pinned = block.open && isThinkingNearBottom(textEl);``
|
||||
(measured BEFORE the re-render) still sit in the `thinking` handler.
|
||||
The capture already keys off ``block.open``, so a re-opened block
|
||||
resumes pinned tail-following exactly like the live pre-delta
|
||||
block — no pin logic change needed for D15."""
|
||||
(measured BEFORE the re-render) still sit in the `thinking`
|
||||
handler — now operating per block (phase 158: the re-render is
|
||||
the RUN's text, not the whole-turn accumulator). The capture
|
||||
already keys off ``block.open``, so a live-open block resumes
|
||||
pinned tail-following exactly like the phase-17 flow."""
|
||||
js = _js()
|
||||
assert "export const THINKING_NEAR_BOTTOM_PX = 32;" in js, (
|
||||
"the 32px window band is unchanged"
|
||||
@@ -158,12 +164,12 @@ def test_follow_the_tail_pin_logic_is_untouched() -> None:
|
||||
branch = _thinking_branch()
|
||||
capture = "const pinned = block.open && isThinkingNearBottom(textEl);"
|
||||
assert capture in branch, (
|
||||
"the pre-render `block.open &&` guard line is unchanged — D15 "
|
||||
"keeps the pin logic untouched (it already reads block.open "
|
||||
"before the re-render)"
|
||||
"the pre-render `block.open &&` guard line is unchanged — the "
|
||||
"pin logic moves verbatim into the per-run branch (phase 17 "
|
||||
"regression)"
|
||||
)
|
||||
capture_idx = branch.find(capture)
|
||||
render_idx = branch.find("textEl.innerHTML = renderMarkdown(thinkingAcc)")
|
||||
render_idx = branch.find("textEl.innerHTML = renderMarkdown(currentRun.run.text)")
|
||||
assert -1 < capture_idx < render_idx, (
|
||||
"pin state is still measured BEFORE the re-render (phase-17 "
|
||||
"regression — the 2-newline-gap fix)"
|
||||
|
||||
Reference in New Issue
Block a user