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.
826 lines
39 KiB
Python
826 lines
39 KiB
Python
"""Unit: the phase-158 per-run timeline contract (tasks 02 + 03).
|
||
|
||
The owner's report (2026-10-04, live shared page): the turn's thinking
|
||
lumped into ONE block and every tool call into ONE disclosure, so the
|
||
DOM always read "thought everything → called all the tools →
|
||
answered" — and the phase-109 D15 re-open made it worse (thinking
|
||
resumed ABOVE the in-progress answer). The fix (phase 158, owner
|
||
decisions D1–D5): each CONTIGUOUS thinking run streams into its own
|
||
``details.thinking`` block and each CONTIGUOUS tool-call run into its
|
||
own ``details.tool-calls-disclosure``, in arrival order, all ABOVE
|
||
the answer bubble (D2, Option B); the runs open live and fold at rest
|
||
(D5); the phase-87 elapsed clock re-targets the TRACKED line
|
||
reference (the ``:last-child`` query breaks with multiple disclosures
|
||
and is gone); the save payloads persist the ``segments`` array
|
||
(D4, non-empty only).
|
||
|
||
Task 03 adds the RESTORE-path and SHARED-page pins: a record WITH
|
||
``segments`` renders the collapsed per-run timeline
|
||
(``restoreTimeline`` in app.js / ``addSegments`` in shared.js — the
|
||
parity copy); a record WITHOUT ``segments`` (every pre-phase record)
|
||
restores byte-identically through the untouched legacy path (the
|
||
dispatch guard is the only change); the builders are
|
||
createElement/textContent for the structure (the only HTML write is
|
||
the escape-first renderMarkdown into ``.thinking-text``); unknown
|
||
``kind`` entries are skipped (never render from storage).
|
||
|
||
No new ``app/`` logic in this phase — the behavior lives in
|
||
``frontend/assets/app.js`` / ``shared.js`` and is E2E-gated by the
|
||
story suite (``tests/e2e/test_thinking_timeline.py``, task 04).
|
||
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.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
APP_JS = FRONTEND / "assets" / "app.js"
|
||
SHARED_JS = FRONTEND / "assets" / "shared.js"
|
||
|
||
#: The four pinned tool-line label literals (phase 37/70/117 D6) —
|
||
#: the emoji guard and the pins key off these exact bytes.
|
||
PINNED_LABELS = (
|
||
'line.textContent = "📄 Reading "',
|
||
'line.textContent = "🔎 Searching for "',
|
||
'line.textContent = "🔎 Listing documents in "',
|
||
'line.textContent = "🔎 Listing documents"',
|
||
)
|
||
|
||
|
||
def _js() -> str:
|
||
return APP_JS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _js_shared() -> str:
|
||
return SHARED_JS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _fn(src: str, name: str) -> str:
|
||
"""The flat function body (house extraction: first ``\n}\n`` after
|
||
the definition — the function must stay flat, no nested
|
||
declarations)."""
|
||
fn = src.find(f"function {name}")
|
||
assert fn != -1, f"{name} must exist"
|
||
return src[fn : src.find("\n}\n", fn)]
|
||
|
||
|
||
def _branch(js: str, open_type: str, close_type: str) -> str:
|
||
"""One turn-handler branch, between the named event types."""
|
||
start = js.find(f'ev.type === "{open_type}"')
|
||
end = js.find(f'ev.type === "{close_type}"')
|
||
assert -1 < start < end, f"the {open_type} branch must exist"
|
||
return js[start:end]
|
||
|
||
|
||
# ---------- the run builders (D1/D2/D3/D5) ----------
|
||
|
||
|
||
def test_ensure_thinking_run_builds_a_new_open_block_before_the_bubble() -> None:
|
||
"""D1/D2/D3: ``ensureThinkingRun`` returns the active thinking
|
||
run's block or — a NEW ``details.thinking`` (the phase-17 static
|
||
structure via ``createThinkingBlock``) created OPEN (D5),
|
||
inserted BEFORE the ``.bubble`` (D2 — the anchor is the bubble,
|
||
in arrival order). The plain ``Thinking`` literal (D3 — the
|
||
phase-17 literal, not a numbered variant) rides the static
|
||
template; the fresh run joins segRuns + currentRun."""
|
||
js = _js()
|
||
body = _fn(js, "ensureThinkingRun")
|
||
assert 'currentRun?.kind === "thinking"' in body, (
|
||
"the active thinking run is extended, not recreated"
|
||
)
|
||
assert "closeCurrentRun()" in body, (
|
||
"a frame of a new kind CLOSES the previous run first (D1)"
|
||
)
|
||
assert "createThinkingBlock()" in body, "the shared builder is reused"
|
||
assert "block.open = true" in body, "D5: created OPEN while its frames stream"
|
||
assert 'insertBefore(block, body.querySelector(".bubble"))' in body, (
|
||
"D2: the anchor is the BUBBLE — above it, in arrival order"
|
||
)
|
||
assert 'kind: "thinking"' in body
|
||
assert "segRuns.push(run)" in body
|
||
assert "currentRun =" in body
|
||
|
||
|
||
def test_create_thinking_block_is_the_phase_17_static_template_without_data() -> None:
|
||
"""D3: the thinking block's STATIC structure is the phase-17
|
||
template — the plain ``Thinking`` summary literal + the
|
||
``.thinking-text`` div — built with NO data in the template (the
|
||
house rule: a static template is fine, turn data must never flow
|
||
through innerHTML; the live run writes
|
||
``renderMarkdown(currentRun.run.text)`` into ``.thinking-text``,
|
||
the restore/shared builders are createElement/textContent, task
|
||
03)."""
|
||
body = _fn(_js(), "createThinkingBlock")
|
||
assert 'block.className = "thinking"' in body
|
||
assert "block.innerHTML" in body, (
|
||
"the phase-17 static-structure convention is kept (the live "
|
||
"builder reuses the template)"
|
||
)
|
||
assert "`<summary>Thinking</summary><div class=\"thinking-text\"></div>`" in body, (
|
||
"the plain Thinking literal (D3 — the phase-17 literal, zero "
|
||
"new literals — position distinguishes the runs)"
|
||
)
|
||
assert "${" not in body, (
|
||
"NO data in the static template — the run's text flows through "
|
||
"renderMarkdown / textContent"
|
||
)
|
||
|
||
|
||
def test_ensure_tool_run_builds_a_new_open_disclosure_before_the_bubble() -> None:
|
||
"""D1/D2/D5: ``ensureToolRun`` returns the active tool run's
|
||
disclosure or — a NEW ``details.tool-calls-disclosure`` (the
|
||
phase-117 structure via ``createToolDisclosure`` —
|
||
summary.tool-calls-summary + the .tool-calls list, role=list +
|
||
aria-label "Tool calls") created OPEN (D5), inserted BEFORE the
|
||
bubble (D2, in arrival order). One disclosure per CONTIGUOUS
|
||
tool-call run; the per-run "Tool call(s) (N)" count rides
|
||
appendToolLine's summary update."""
|
||
js = _js()
|
||
body = _fn(js, "ensureToolRun")
|
||
assert 'currentRun?.kind === "tool"' in body
|
||
assert "closeCurrentRun()" in body
|
||
assert "createToolDisclosure()" in body
|
||
assert "disc.open = true" in body, "D5: created OPEN while its frames stream"
|
||
assert 'insertBefore(disc, body.querySelector(".bubble"))' in body
|
||
assert 'kind: "tool"' in body
|
||
assert "segRuns.push(run)" in body
|
||
disc = _fn(js, "createToolDisclosure")
|
||
assert 'container.className = "tool-calls-disclosure"' in disc
|
||
assert 'summary.className = "tool-calls-summary"' in disc
|
||
assert 'listEl.className = "tool-calls"' in disc
|
||
assert 'setAttribute("role", "list")' in disc
|
||
assert 'setAttribute("aria-label", "Tool calls")' in disc
|
||
assert "innerHTML" not in disc, (
|
||
"the tool builder stays createElement/textContent (phase 117 "
|
||
"style — innerHTML ABSENT)"
|
||
)
|
||
|
||
|
||
def test_the_single_block_and_single_container_derivations_are_gone() -> None:
|
||
"""D1: the phase-109 single shared block and the phase-117
|
||
single-container derivation are GONE — no ``ensureThinkingBlock``
|
||
/ ``closeThinkingBlock`` / ``closeToolCalls`` anywhere, the old
|
||
anchor chain (``.tool-calls-disclosure ?? .tool-calls ??
|
||
.bubble``) is gone, and ``appendToolLine`` no longer derives a
|
||
container (the caller passes the run's disclosure)."""
|
||
js = _js()
|
||
for gone in (
|
||
"function ensureThinkingBlock",
|
||
"function closeThinkingBlock",
|
||
"function closeToolCalls",
|
||
"closeThinkingBlock(wrap)",
|
||
"closeToolCalls(wrap)",
|
||
'body.querySelector(".tool-calls-disclosure") ??',
|
||
'body.querySelector(".tool-calls") ?? body.querySelector(".bubble")',
|
||
):
|
||
assert gone not in js, f"the pre-phase-158 machinery {gone!r} must be gone"
|
||
append = _fn(js, "appendToolLine")
|
||
assert 'querySelector(".tool-calls-disclosure")' not in append, (
|
||
"the single-container derivation is GONE — the caller passes "
|
||
"the run's disclosure"
|
||
)
|
||
assert "document.createElement(\"details\")" not in append, (
|
||
"the disclosure's build moved to createToolDisclosure"
|
||
)
|
||
|
||
|
||
# ---------- appendToolLine: the container signature ----------
|
||
|
||
|
||
def test_append_tool_line_takes_the_container_and_returns_the_line() -> None:
|
||
"""The signature is ``appendToolLine(container, name, argument,
|
||
offset = null, limit = null, scope = null)`` — the CALLER passes
|
||
the run's disclosure (no single-container derivation); everything
|
||
else in the function stays byte-identical (the four pinned label
|
||
literals, the phase-139 range suffix, the phase-146 scope span,
|
||
the per-run ``Tool call(s) (N)`` count), and the function returns
|
||
the appended ``line`` element (the caller arms its clock)."""
|
||
js = _js()
|
||
assert (
|
||
"function appendToolLine(container, name, argument, offset = null, "
|
||
"limit = null, scope = null)" in js
|
||
), "the first param is the container"
|
||
body = _fn(js, "appendToolLine")
|
||
for label in PINNED_LABELS:
|
||
assert label in body, f"pinned label literal changed: {label!r}"
|
||
assert "const list = container.querySelector(\".tool-calls\")" in body
|
||
assert "return line" in body, (
|
||
"the function returns the appended line (armToolLineClock's "
|
||
"second param)"
|
||
)
|
||
assert (
|
||
"Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body
|
||
), "the per-run count template on every append"
|
||
assert "innerHTML" not in body, "no HTML injection surface — textContent only"
|
||
|
||
|
||
# ---------- the phase-87 clock re-target ----------
|
||
|
||
|
||
def test_the_clock_targets_the_tracked_line_reference() -> None:
|
||
"""Phase 158 (D1): the ``:last-child`` query is GONE — it would
|
||
find the last line of the WRONG run's list (multiple
|
||
disclosures). ``armToolLineClock`` takes the line as its SECOND
|
||
param and stores it in the module-scope ``toolLineEl``; the tick
|
||
is null-guarded on the reference (a New-Chat reset mid-gap is a
|
||
no-op); the suffix element + the ``(Ns)`` text + the
|
||
``TOOL_LINE_ELAPSED_AFTER_MS`` gate stay byte-identical."""
|
||
js = _js()
|
||
assert '".tool-calls .tool-call:last-child"' not in js, (
|
||
"the :last-child query is GONE (multi-disclosure)"
|
||
)
|
||
assert "let toolLineEl = null" in js, "the tracked line is module scope"
|
||
body = _fn(js, "armToolLineClock")
|
||
assert "function armToolLineClock(wrap, lineEl)" in body, (
|
||
"the NEW second param"
|
||
)
|
||
assert "toolLineEl = lineEl" in body, "the re-arm resets the reference"
|
||
assert "if (!toolLineEl) return" in body, (
|
||
"null-guarded: a wrap reset mid-gap (New Chat) is a no-op"
|
||
)
|
||
assert 'toolLineEl.querySelector(".tool-elapsed")' in body
|
||
assert 'el.className = "tool-elapsed"' in body
|
||
assert 'document.createElement("span")' in body
|
||
assert "toolLineEl.appendChild(el)" in body
|
||
assert "`(${secs}s)`" in body, "the parenthesized '(Ns)' suffix stays"
|
||
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body
|
||
assert "innerHTML" not in body, "no HTML write in the clock"
|
||
|
||
|
||
def test_settle_resets_the_tracked_line() -> None:
|
||
"""``settleToolLine`` keeps the ``.tool-elapsed`` removal (it
|
||
clears EVERY line in the wrap — correct with multiple
|
||
disclosures) and resets ``toolLineEl = null`` alongside (the
|
||
stop path — ``stopToolLineClock`` — resets via it)."""
|
||
js = _js()
|
||
body = _fn(js, "settleToolLine")
|
||
assert "clearInterval(toolLineTimer)" in body
|
||
assert 'querySelectorAll?.(".tool-elapsed")' in body and "el.remove()" in body
|
||
assert "toolLineEl = null" in body, "the settle forgets the line target"
|
||
stop = _fn(js, "stopToolLineClock")
|
||
assert "settleToolLine();" in stop
|
||
assert "toolLineWrap = null" in stop
|
||
|
||
|
||
def test_the_arm_follows_the_line_append_with_the_new_signature() -> None:
|
||
"""A6 (live-only): the tool branch arms the clock with the NEW
|
||
signature — ``armToolLineClock(wrap, line)`` right after
|
||
``appendToolLine(disc, …)`` (the baseline resets per line), and
|
||
the arm is still the definition + exactly one live call site (the
|
||
restore path never arms)."""
|
||
js = _js()
|
||
assert js.count("armToolLineClock(") == 2, (
|
||
"the arm must be the definition + exactly one live call site"
|
||
)
|
||
branch = _branch(js, "tool", "retry")
|
||
append = branch.find(
|
||
"appendToolLine(\n disc,\n name,\n argument,\n"
|
||
" frameOffset,\n frameLimit,\n framePath\n"
|
||
" )"
|
||
)
|
||
arm = branch.find("armToolLineClock(wrap, line)")
|
||
assert -1 < append < arm, (
|
||
"the arm follows the line's append — the baseline resets per line"
|
||
)
|
||
assert "ensureToolRun(wrap)" in branch, (
|
||
"the tool frame opens/extends the run's disclosure"
|
||
)
|
||
restore_fn = js.find("function renderStoredMessage")
|
||
restore_end = js.find("function restoreConversation")
|
||
assert -1 < restore_fn < restore_end
|
||
assert "armToolLineClock" not in js[restore_fn:restore_end], (
|
||
"the restore path must never arm the clock (A6)"
|
||
)
|
||
|
||
|
||
# ---------- the fold sites ----------
|
||
|
||
|
||
def test_close_current_run_and_close_all_runs_are_defined() -> None:
|
||
"""D5's fold side: ``closeCurrentRun`` folds the ACTIVE run
|
||
(idempotent; a no-op without a run) and ``closeAllRuns`` closes
|
||
EVERY open ``details.thinking`` + ``details.tool-calls-disclosure``
|
||
in the wrap (defensive — a late double-close is a no-op; a no-op
|
||
without a wrap)."""
|
||
js = _js()
|
||
cur = _fn(js, "closeCurrentRun")
|
||
assert "currentRun.el.open = false" in cur
|
||
assert "currentRun = null" in cur, "the run is cleared (a later close is a no-op)"
|
||
all_runs = _fn(js, "closeAllRuns")
|
||
assert "details.thinking, details.tool-calls-disclosure" in all_runs
|
||
assert "el.open = false" in all_runs
|
||
|
||
|
||
def test_the_delta_folds_the_active_run_and_the_terminals_fold_every_run() -> None:
|
||
"""D5: the FIRST `delta` folds the open run (``closeCurrentRun``
|
||
in the delta branch — the D15 ``block.open = true`` re-open is
|
||
gone), and the terminal settles — `done`, the stop/abort path,
|
||
and ``finalizeFailedTurn`` — fold EVERY open run
|
||
(``closeAllRuns(wrap)``)."""
|
||
js = _js()
|
||
delta = _branch(js, "delta", "done")
|
||
assert "closeCurrentRun()" in delta, "the first delta folds the active run"
|
||
assert "block.open = true" not in delta, "the D15 re-open is gone from the delta"
|
||
done = _branch(js, "done", "tool_result")
|
||
assert "closeAllRuns(wrap)" in done, "done folds every open run"
|
||
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"')
|
||
stop = js[stop_idx : js.find("} else {", stop_idx)]
|
||
assert "closeAllRuns(wrap)" in stop, "the stop path folds every open run"
|
||
failed = _fn(js, "finalizeFailedTurn")
|
||
assert "closeAllRuns(wrap)" in failed, "the failed-turn funnel folds every open run"
|
||
|
||
|
||
# ---------- the run tracker + the save payloads (D4) ----------
|
||
|
||
|
||
def test_seg_runs_is_module_scope_and_reset_in_run_turn() -> None:
|
||
"""The run tracker is MODULE scope (the pagehide partial reads it
|
||
while a turn is in flight — like ``thinkingAcc``) and resets with
|
||
the other turn locals at the top of ``runTurn``."""
|
||
js = _js()
|
||
assert "let segRuns = []" in js, "module-scope run tracker"
|
||
assert "let currentRun = null" in js, "the active run is module scope"
|
||
anchor = js.find("let thinkingAcc = \"\"")
|
||
assert anchor != -1
|
||
assert js.find("let segRuns = []") - anchor < 1500, (
|
||
"the run tracker sits next to thinkingAcc (the pagehide "
|
||
"readers)"
|
||
)
|
||
turn_start = js.find("async function runTurn")
|
||
turn_end = js.find('window.addEventListener("pagehide"')
|
||
assert -1 < turn_start < turn_end
|
||
turn = js[turn_start:turn_end]
|
||
assert "segRuns = []" in turn, "reset with the other turn locals"
|
||
assert "currentRun = null" in turn
|
||
|
||
|
||
def test_build_segments_maps_runs_in_order_with_the_empty_run_skip() -> None:
|
||
"""D4: ``buildSegments`` maps the runs IN ORDER — a thinking run
|
||
→ ``{ kind: "thinking", text }`` ONLY when its text is
|
||
non-empty (a zero-char run stores nothing); a tool run → one
|
||
``{ kind: "tool", …record }`` per record, in order (the record
|
||
object is spread — the additive offset/limit/path + the phase-95
|
||
truncation stamp ride through; the records are the SAME toolAcc
|
||
objects, so the stamp flows with no extra wiring). A turn with no
|
||
thinking and no tools returns []. No re-validation on the client
|
||
(the server's ChatMessage boundary validates)."""
|
||
body = _fn(_js(), "buildSegments")
|
||
assert "if (r.kind === \"thinking\")" in body
|
||
assert "if (r.text) out.push({ kind: \"thinking\", text: r.text })" in body, (
|
||
"the EMPTY thinking run stores NOTHING"
|
||
)
|
||
assert "for (const rec of r.records) out.push({ kind: \"tool\", ...rec })" in body, (
|
||
"one entry per record, in order — the record spread"
|
||
)
|
||
assert "return out" in body
|
||
|
||
|
||
def test_the_save_payload_sites_carry_segments_non_empty_only() -> None:
|
||
"""D4: the FIVE save sites persist ``segments`` non-empty only
|
||
(the undefined-drops-the-key house pattern) — the `done` save
|
||
point, ``finalizeFailedTurn`` (the function record + BOTH call
|
||
sites pass ``buildSegments(segRuns)``), the STOP path's partial,
|
||
and the pagehide partial — while the ``thinking`` / ``tools``
|
||
keys stay UNCHANGED at every site. The zero-frame fallback
|
||
save needs nothing (zero frames → no runs → no key)."""
|
||
js = _js()
|
||
# done save point
|
||
done = _branch(js, "done", "tool_result")
|
||
assert "const turnSegments = buildSegments(segRuns)" in done
|
||
assert "segments: turnSegments.length ? turnSegments : undefined" in done
|
||
assert "thinking: thinkingAcc || undefined" in done
|
||
assert "tools: toolAcc.length ? toolAcc : undefined" in done
|
||
# finalizeFailedTurn: the parameter + the record + both call sites
|
||
failed = _fn(js, "finalizeFailedTurn")
|
||
assert "segments" in failed.split(") {")[0], "the parameter object gains segments"
|
||
assert "segments: segments?.length ? segments : undefined" in failed
|
||
assert js.count("segments: buildSegments(segRuns)") >= 2, (
|
||
"both call sites (stream-drop + network error) pass the segments"
|
||
)
|
||
# the stop path
|
||
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"')
|
||
stop = js[stop_idx : js.find("} else {", stop_idx)]
|
||
assert "const stopSegments = buildSegments(segRuns)" in stop
|
||
assert "segments: stopSegments.length ? stopSegments : undefined" in stop
|
||
assert "stopped: true" in stop
|
||
# the pagehide partial
|
||
pagehide_start = js.find('window.addEventListener("pagehide"')
|
||
assert pagehide_start != -1
|
||
pagehide = js[pagehide_start : js.find("});", pagehide_start)]
|
||
assert "const pagehideSegments = buildSegments(segRuns)" in pagehide
|
||
assert (
|
||
"segments: pagehideSegments.length ? pagehideSegments : undefined"
|
||
in pagehide
|
||
)
|
||
|
||
|
||
def test_the_live_thinking_branch_extends_the_run_and_renders_the_run_text() -> None:
|
||
"""D1: the `thinking` branch keeps the phase-17/87 pre-work
|
||
(settleToolLine, thinkingAcc, sawThinking, clearTurnTimeout, the
|
||
wrap get-or-create, removeTyping) and REPLACES the D15
|
||
ensureThinkingBlock + re-open with ``ensureThinkingRun`` — the
|
||
RUN's text accumulates ``ev.text`` and re-renders
|
||
``renderMarkdown(currentRun.run.text)`` into THAT block's
|
||
``.thinking-text`` (per-block — the pre-update pin capture +
|
||
bottom-follow logic moves verbatim, measured BEFORE the
|
||
re-render)."""
|
||
js = _js()
|
||
branch = _branch(js, "thinking", "tool")
|
||
assert "settleToolLine();" in branch
|
||
assert "thinkingAcc += ev.text" in branch
|
||
assert "sawThinking = true" in branch
|
||
assert "clearTurnTimeout()" in branch
|
||
assert 'addMessage("brain", "")' in branch
|
||
assert "removeTyping()" in branch
|
||
assert "const block = ensureThinkingRun(wrap)" in branch
|
||
assert "currentRun.run.text += ev.text || \"\"" in branch, (
|
||
"the RUN's text accumulates (the block renders the run, not "
|
||
"the whole-turn accumulator)"
|
||
)
|
||
capture = 'const pinned = block.open && isThinkingNearBottom(textEl);'
|
||
assert capture in branch, "the pre-update pin capture moves verbatim (per-block)"
|
||
assert "textEl.innerHTML = renderMarkdown(currentRun.run.text)" in branch
|
||
cap_idx = branch.find(capture)
|
||
render_idx = branch.find("textEl.innerHTML = renderMarkdown(currentRun.run.text)")
|
||
assert -1 < cap_idx < render_idx, (
|
||
"the pin state is measured BEFORE the re-render (phase-17 "
|
||
"regression — the 2-newline-gap fix)"
|
||
)
|
||
assert "textEl.scrollTop = textEl.scrollHeight" in branch, (
|
||
"pinned tail-following is intact"
|
||
)
|
||
assert "block.open = true" not in branch, (
|
||
"the D15 re-open assignment is GONE — ensureThinkingRun owns "
|
||
"the open state (a new run opens; the active run stays open)"
|
||
)
|
||
|
||
|
||
def test_the_tool_branch_records_the_run_and_arms_the_tracked_clock() -> None:
|
||
"""D1: the `tool` branch keeps the phase-139/146 guards,
|
||
``toolAcc.push(toolRecord)``, ``clearTurnTimeout()``, the wrap
|
||
get-or-create and the status-label logic UNCHANGED, and REPLACES
|
||
the single-container append + bare arm with:
|
||
``ensureToolRun(wrap)`` → the run's ``records.push(toolRecord)``
|
||
(the SAME object toolAcc holds — the phase-95 stamp flows into
|
||
both) → ``appendToolLine(disc, …)`` →
|
||
``armToolLineClock(wrap, line)``."""
|
||
js = _js()
|
||
branch = _branch(js, "tool", "retry")
|
||
assert (
|
||
"Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null"
|
||
in branch
|
||
)
|
||
assert "Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null" in branch
|
||
assert (
|
||
'typeof ev.path === "string" && ev.path ? ev.path : null' in branch
|
||
), "the phase-146 guard is unchanged"
|
||
assert "const toolRecord = { name, argument }" in branch
|
||
assert "toolAcc.push(toolRecord)" in branch
|
||
assert "clearTurnTimeout()" in branch
|
||
assert 'addMessage("brain", "")' in branch
|
||
assert "const disc = ensureToolRun(wrap)" in branch
|
||
assert "currentRun.run.records.push(toolRecord)" in branch, (
|
||
"the run's records hold the SAME toolAcc objects"
|
||
)
|
||
assert "armToolLineClock(wrap, line)" in branch
|
||
|
||
|
||
def test_the_legacy_restore_path_uses_the_shared_builders() -> None:
|
||
"""The renderStoredMessage LEGACY path (a pre-phase record — no
|
||
``segments``; the segments dispatch is task 03's) restores the
|
||
thinking block COLLAPSED above the bubble through the SHARED
|
||
``createThinkingBlock`` builder, and the tool lines through ONE
|
||
disclosure (``createToolDisclosure``, created with the live
|
||
lifecycle — folded after the loop, phase 117 D3) via the SAME
|
||
``appendToolLine`` container signature. The tOffset/tLimit/tPath
|
||
guards + the phase-95 truncation note stay verbatim."""
|
||
js = _js()
|
||
fn = js.find("function renderStoredMessage")
|
||
end = js.find("function restoreConversation")
|
||
assert -1 < fn < end
|
||
body = js[fn:end]
|
||
assert "if (m.thinking)" in body
|
||
assert "createThinkingBlock()" in body
|
||
assert "block.open = false" in body, "stored blocks restore collapsed"
|
||
assert "block.open = true" not in body, (
|
||
"the restore path must never open a stored block"
|
||
)
|
||
assert "Array.isArray(m.tools)" in body
|
||
assert "createToolDisclosure()" in body
|
||
assert "restoreDisc.open = false" in body, (
|
||
"the restored disclosure folds after the loop (phase 117, D3)"
|
||
)
|
||
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||
assert "Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null" in body
|
||
assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body
|
||
assert "typeof t.path === \"string\" && t.path ? t.path : null" in body
|
||
assert "appendTruncatedNote(wrap, arg" in body, "the phase-95 note is untouched"
|
||
# The established phase-17 render is the ONLY innerHTML write in
|
||
# the function — the new restore builders (createThinkingBlock /
|
||
# createToolDisclosure) add no HTML injection surface.
|
||
assert body.count("innerHTML") == 1
|
||
assert (
|
||
'block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking)'
|
||
in body
|
||
)
|
||
|
||
|
||
def test_the_pre_phase_narrative_is_replaced() -> None:
|
||
"""The narrative flips: the phase-109 D15 re-open contract is
|
||
superseded — "never reopens" survives nowhere in app.js (the
|
||
phase-109 pin), and the section comment names the per-run
|
||
timeline (D1), the arrival-order placement (D2), and the
|
||
clock re-target (the multi-disclosure fix, phase 158 D1)."""
|
||
js = _js()
|
||
assert "never reopens" not in js, (
|
||
"the one-way-door narrative must not survive anywhere"
|
||
)
|
||
comment_start = js.rfind("/*", 0, js.find("function createThinkingBlock"))
|
||
assert comment_start != -1
|
||
section = js[comment_start : js.find("function createThinkingBlock")]
|
||
assert "per-run" in section.lower(), "the contract is named: per-run"
|
||
assert "arrival order" in section, "D2: arrival order is named"
|
||
assert "REPLACED" in section or "superseded" in section.lower(), (
|
||
"the D15 re-open contract is named as replaced"
|
||
)
|
||
clock_comment = js.rfind("/*", 0, js.find("function armToolLineClock"))
|
||
assert clock_comment != -1
|
||
clock_section = js[clock_comment : js.find("function armToolLineClock")]
|
||
assert "re-target" in clock_section, (
|
||
"the clock comment names the re-target (the multi-disclosure "
|
||
"fix, phase 158 D1)"
|
||
)
|
||
|
||
|
||
# ---------- task 03: the restore path (app.js) ----------
|
||
|
||
|
||
def test_restore_timeline_builds_the_collapsed_per_run_structure() -> None:
|
||
"""D5: ``restoreTimeline`` renders the record's ``segments``
|
||
COLLAPSED — a ``kind:"thinking"`` entry gets its OWN
|
||
``details.thinking`` (createElement structure, the plain
|
||
``Thinking`` literal via textContent — D3; NO ``open = true`` —
|
||
created closed), and CONSECUTIVE ``kind:"tool"`` entries group
|
||
into ONE ``details.tool-calls-disclosure`` (the shared
|
||
phase-117 builder — ``createToolDisclosure``, innerHTML ABSENT —
|
||
created closed), each inserted BEFORE the bubble (D2, arrival
|
||
order). The per-run count rides ``appendToolLine``'s summary
|
||
update; a kind change seals the tool run (one disclosure per
|
||
run, D1)."""
|
||
body = _fn(_js(), "restoreTimeline")
|
||
assert "document.createElement(\"details\")" in body, "the structure is createElement"
|
||
assert 'block.className = "thinking"' in body
|
||
assert "block.open = true" not in body, "D5: created COLLAPSED (no open state)"
|
||
assert 'summary.textContent = "Thinking"' in body, (
|
||
"the plain Thinking literal (D3 — the phase-17 literal; "
|
||
"position distinguishes the runs) via textContent"
|
||
)
|
||
assert 'textEl.className = "thinking-text"' in body
|
||
# the consecutive-tool grouping (one disclosure per run — the
|
||
# grouping loop): create on the first entry, seal on a kind
|
||
# change, insert before the bubble.
|
||
assert "createToolDisclosure()" in body, "the shared phase-117 builder (innerHTML ABSENT)"
|
||
assert "toolDisc.open = true" not in body, "D5: created COLLAPSED (no open state)"
|
||
assert "toolDisc.open = false" in body
|
||
assert "if (toolDisc) toolDisc = null" in body, (
|
||
"a thinking entry seals the tool run — ONE disclosure per run (D1)"
|
||
)
|
||
assert "body.insertBefore(block, bubble)" in body, "D2: above the bubble"
|
||
assert "body.insertBefore(toolDisc, bubble)" in body, "D2: above the bubble"
|
||
assert "for (const entry of segments" in body, "iterate the runs in order"
|
||
|
||
|
||
def test_restore_timeline_reuses_the_restore_guards_and_the_truncation_note() -> None:
|
||
"""The line path reuses the SAME ``appendToolLine`` container
|
||
signature with the tOffset/tLimit/tPath well-formedness guards
|
||
(``Number.isInteger(x) && x >= 1`` / the string guard —
|
||
byte-identical, the phase-139/146 restore precedent) and the
|
||
phase-95 ``appendTruncatedNote`` call moved verbatim inside the
|
||
grouping; a malformed entry (a non-string thinking ``text`` / a
|
||
non-string tool ``name``) and an entry with an UNKNOWN ``kind``
|
||
are SKIPPED (never render from storage — textContent only, the
|
||
house rule)."""
|
||
body = _fn(_js(), "restoreTimeline")
|
||
assert (
|
||
"Number.isInteger(entry.offset) && entry.offset >= 1 ? entry.offset : null"
|
||
in body
|
||
), "the phase-139 offset guard is verbatim"
|
||
assert (
|
||
"Number.isInteger(entry.limit) && entry.limit >= 1 ? entry.limit : null"
|
||
in body
|
||
), "the phase-139 limit guard is verbatim"
|
||
assert 'typeof entry.path === "string" && entry.path ? entry.path : null' in body, (
|
||
"the phase-146 path guard is verbatim"
|
||
)
|
||
assert "appendToolLine(toolDisc, entry.name, arg, tOffset, tLimit, tPath)" in body, (
|
||
"the SAME container signature as the live path"
|
||
)
|
||
assert "appendTruncatedNote(wrap, arg" in body, "the phase-95 note moved verbatim"
|
||
# the skips: malformed + unknown kind never render from storage.
|
||
assert 'if (!entry || typeof entry !== "object") continue' in body
|
||
assert 'if (typeof entry.text !== "string") continue' in body, "malformed thinking skipped"
|
||
assert 'if (typeof entry.name !== "string") continue' in body, "malformed tool skipped"
|
||
assert 'else if (entry.kind === "tool")' in body, (
|
||
"an entry whose kind is neither thinking nor tool falls through "
|
||
"— SKIPPED (the guard comment names the skip)"
|
||
)
|
||
assert "unknown" in body, "the skip is named in a guard comment"
|
||
|
||
|
||
def _single_escape_first_write(body: str) -> None:
|
||
"""The house rule for the restore/shared builders (the
|
||
structure is createElement/textContent): the function has
|
||
exactly ONE innerHTML write — the escape-first renderMarkdown
|
||
into ``.thinking-text`` (the existing restore convention;
|
||
renderMarkdown's escape-first contract keeps storage
|
||
XSS-safe)."""
|
||
writes = re.findall(r"\b(\w+)\.innerHTML\s*=\s*([^;\n]+)", body)
|
||
assert len(writes) == 1, (
|
||
f"exactly ONE innerHTML write (the structure is createElement): {writes}"
|
||
)
|
||
assert writes[0][0] == "textEl", "the single write is into .thinking-text"
|
||
assert writes[0][1].strip() == "renderMarkdown(entry.text)", (
|
||
"the single write is the escape-first renderMarkdown"
|
||
)
|
||
|
||
|
||
def test_restore_timeline_structure_is_create_element_and_the_only_write_is_escape_first() -> None:
|
||
"""innerHTML ABSENT from the STRUCTURE — it is built via
|
||
createElement/textContent (the thinking block's summary is
|
||
textContent; the disclosure rides the innerHTML-absent
|
||
``createToolDisclosure`` — the task-02 pin). The ONLY HTML
|
||
write in the function is the escape-first renderMarkdown into
|
||
``.thinking-text`` (the existing restore convention — the
|
||
phase-17 pattern; renderMarkdown's escape-first contract keeps
|
||
storage XSS-safe)."""
|
||
body = _fn(_js(), "restoreTimeline")
|
||
_single_escape_first_write(body)
|
||
assert "document.createElement(\"summary\")" in body, (
|
||
"the summary is createElement + textContent"
|
||
)
|
||
assert "document.createElement(\"div\")" in body, "the .thinking-text is createElement"
|
||
|
||
|
||
def test_the_restore_dispatch_gives_segments_precedence_over_the_legacy_path() -> None:
|
||
"""The ``renderStoredMessage`` dispatch: a record WITH
|
||
``segments`` (a non-empty array) renders ``restoreTimeline`` and
|
||
SKIPS the legacy ``m.thinking`` block + ``m.tools`` loop (the
|
||
runs carry the same data in order — rendering both would double
|
||
them); a record WITHOUT ``segments`` restores byte-identically
|
||
through the legacy path (the dispatch guard is the only change
|
||
— the legacy statements stay verbatim, the task-02 pins).
|
||
``restoreTimeline`` is defined BEFORE renderStoredMessage —
|
||
outside the legacy region the task-02 pin slices (that region's
|
||
single innerHTML write stays the established phase-17 render)."""
|
||
js = _js()
|
||
fn = js.find("function renderStoredMessage")
|
||
end = js.find("function restoreConversation")
|
||
assert -1 < fn < end
|
||
body = js[fn:end]
|
||
assert "Array.isArray(m.segments) && m.segments.length" in body, "the dispatch guard"
|
||
assert "restoreTimeline(wrap, m.segments)" in body
|
||
# precedence: the legacy blocks sit in the ELSE branch.
|
||
guard_i = body.find("Array.isArray(m.segments)")
|
||
else_i = body.find("} else {", guard_i)
|
||
think_i = body.find("if (m.thinking)")
|
||
tools_i = body.find("if (Array.isArray(m.tools))")
|
||
assert -1 < guard_i < else_i < think_i < tools_i, (
|
||
"segments take precedence; the legacy path is the else branch"
|
||
)
|
||
# the legacy path is intact (the task-02 pin's markers).
|
||
assert "createThinkingBlock()" in body
|
||
assert "block.open = false" in body
|
||
assert "renderMarkdown(m.thinking)" in body
|
||
assert "createToolDisclosure()" in body
|
||
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||
# restoreTimeline is OUTSIDE the sliced region (before the
|
||
# renderer) — the legacy region's HTML write stays the ONE the
|
||
# task-02 pin asserts.
|
||
assert js.find("function restoreTimeline") < fn, (
|
||
"restoreTimeline is defined before renderStoredMessage — the "
|
||
"legacy-region pin's innerHTML count is untouched"
|
||
)
|
||
assert body.count("innerHTML") == 1, "the legacy path's single write is unchanged"
|
||
|
||
|
||
# ---------- task 03: the shared page (shared.js) ----------
|
||
|
||
|
||
def test_add_segments_builds_the_collapsed_parity_timeline() -> None:
|
||
"""The shared-page parity copy (D5 — pure render, always
|
||
COLLAPSED): the same structure (a per-run details.thinking with
|
||
the plain ``Thinking`` literal via textContent + the phase-117
|
||
disclosure structure — summary.tool-calls-summary + the
|
||
.tool-calls list, role=list + aria-label "Tool calls"), the same
|
||
LINE BYTES as ``addToolLines`` (the four pinned label literals
|
||
+ the phase-70 legacy names + the phase-139 readRangeSuffix +
|
||
the phase-95 truncation note), the per-run
|
||
``Tool call(s) (N)`` count, and insertion BEFORE the bubble
|
||
(D2, arrival order) — all created CLOSED (no ``open = true``
|
||
anywhere; the addThinkingBlock / addToolLines collapsed
|
||
convention)."""
|
||
body = _fn(_js_shared(), "addSegments")
|
||
assert "document.createElement(\"details\")" in body, "the structure is createElement"
|
||
assert 'block.className = "thinking"' in body
|
||
assert "block.open = true" not in body, "D5: created COLLAPSED"
|
||
assert "block.open = false" in body
|
||
assert 'summary.textContent = "Thinking"' in body, "the plain Thinking literal (D3)"
|
||
assert 'textEl.className = "thinking-text"' in body
|
||
assert "renderMarkdown(entry.text)" in body, "the escape-first renderer (parity)"
|
||
# the phase-117 disclosure structure.
|
||
assert 'toolDisc.className = "tool-calls-disclosure"' in body
|
||
assert "toolDisc.open = true" not in body, "D5: created COLLAPSED"
|
||
assert "toolDisc.open = false" in body
|
||
assert 'summary.className = "tool-calls-summary"' in body
|
||
assert 'container.className = "tool-calls"' in body
|
||
assert 'setAttribute("role", "list")' in body
|
||
assert 'setAttribute("aria-label", "Tool calls")' in body
|
||
# the parity line bytes — the four pinned label literals + the
|
||
# phase-139 suffix + the phase-95 note (the addToolLines parity).
|
||
for label in PINNED_LABELS:
|
||
assert label in body, f"pinned label literal changed: {label!r}"
|
||
assert "readRangeSuffix(entry.offset, entry.limit)" in body, "the phase-139 suffix parity"
|
||
assert '"truncated-note"' in body, "the phase-95 note parity"
|
||
# the per-run count template.
|
||
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body
|
||
# D2: above the bubble, in arrival order.
|
||
assert "body.insertBefore(block, bubble)" in body
|
||
assert "body.insertBefore(toolDisc, bubble)" in body
|
||
# the consecutive-tool grouping (one disclosure per run, D1).
|
||
assert "if (toolDisc) toolDisc = null" in body, (
|
||
"a thinking entry seals the tool run — ONE disclosure per run"
|
||
)
|
||
|
||
|
||
def test_add_segments_skips_unknown_kinds_and_is_create_element() -> None:
|
||
"""An entry with an UNKNOWN ``kind`` (or a malformed shape) is
|
||
SKIPPED — never render from storage (textContent only, the
|
||
house rule; the guard comment names the skip). innerHTML ABSENT
|
||
from the structure — the ONLY HTML write is the escape-first
|
||
renderMarkdown into ``.thinking-text`` (the addThinkingBlock
|
||
convention)."""
|
||
body = _fn(_js_shared(), "addSegments")
|
||
assert 'if (!entry || typeof entry !== "object") continue' in body
|
||
assert 'if (typeof entry.text !== "string") continue' in body, "malformed thinking skipped"
|
||
assert 'if (typeof entry.name !== "string") continue' in body, "malformed tool skipped"
|
||
assert 'else if (entry.kind === "tool")' in body, (
|
||
"an unknown kind falls through — SKIPPED"
|
||
)
|
||
assert "unknown" in body, "the skip is named in a guard comment"
|
||
_single_escape_first_write(body)
|
||
|
||
|
||
def test_the_shared_dispatch_gives_segments_precedence_over_the_legacy_path() -> None:
|
||
"""The ``renderSharedMessage`` dispatch: a record WITH
|
||
``segments`` (a non-empty array) renders ``addSegments`` and
|
||
SKIPS the legacy ``addThinkingBlock`` + ``addToolLines`` (the
|
||
runs carry the same data in order); a record WITHOUT
|
||
``segments`` — every pre-phase record — renders byte-identically
|
||
through the legacy path (the guard is the only change; the
|
||
``addThinkingBlock`` / ``addToolLines`` functions themselves are
|
||
UNCHANGED — the test_shared_page.py pins)."""
|
||
js = _js_shared()
|
||
body = _fn(js, "renderSharedMessage")
|
||
assert "Array.isArray(m.segments) && m.segments.length" in body, "the dispatch guard"
|
||
assert "addSegments(wrap, m.segments)" in body
|
||
# precedence: the legacy calls sit in the ELSE branch.
|
||
guard_i = body.find("Array.isArray(m.segments)")
|
||
else_i = body.find("} else {", guard_i)
|
||
think_i = body.find("addThinkingBlock(wrap, m.thinking)")
|
||
tools_i = body.find("addToolLines(wrap, m.tools)")
|
||
assert -1 < guard_i < else_i < think_i < tools_i, (
|
||
"segments take precedence; the legacy path is the else branch"
|
||
)
|
||
# the legacy path is intact.
|
||
assert 'if (typeof m.thinking === "string" && m.thinking)' in body
|
||
|
||
|
||
def test_the_legacy_shared_builders_are_untouched() -> None:
|
||
"""The ``addThinkingBlock`` / ``addToolLines`` functions are
|
||
UNCHANGED (the legacy path is intact — the shared page's
|
||
pre-phase records render byte-identically; the
|
||
test_shared_page.py pins stay green): the collapsed thinking
|
||
block (the phase-17 convention) + the single-disclosure tool
|
||
lines (the phase-117 structure, the four label literals, the
|
||
phase-95 note, the per-render count)."""
|
||
js = _js_shared()
|
||
think = _fn(js, "addThinkingBlock")
|
||
assert 'block.className = "thinking"' in think
|
||
assert "block.open = false" in think
|
||
assert 'summary.textContent = "Thinking"' in think
|
||
assert "renderMarkdown(thinking)" in think
|
||
tools = _fn(js, "addToolLines")
|
||
for label in PINNED_LABELS:
|
||
assert label in tools, f"pinned label literal changed: {label!r}"
|
||
assert 'container.className = "tool-calls"' in tools
|
||
assert 'disclosure.className = "tool-calls-disclosure"' in tools
|
||
assert "disclosure.open = false" in tools
|
||
assert 'summary.className = "tool-calls-summary"' in tools
|
||
assert '"truncated-note"' in tools
|
||
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in tools
|
||
# no reference to the new builder from the legacy functions.
|
||
assert "addSegments" not in think and "addSegments" not in tools
|