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
+20 -15
View File
@@ -174,6 +174,7 @@ def test_tool_line_clock_state_is_turn_scoped_module_state() -> None:
"let toolLineTimer = 0",
"let toolLineStart = 0",
"let toolLineWrap = null",
"let toolLineEl = null",
):
assert decl in js, f"{decl} must be module-scope state"
anchor = js.find("let turnTimeoutCb = null")
@@ -196,11 +197,11 @@ def test_arm_has_exactly_one_live_call_site() -> None:
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
append = branch.find(
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
assert "armToolLineClock(wrap, line)" in branch, (
"the live tool branch arms the clock with the tracked line (phase 158)"
)
arm = branch.find("armToolLineClock(wrap)")
append = branch.find("const line = appendToolLine(")
arm = branch.find("armToolLineClock(wrap, line)")
assert -1 < append < arm, (
"the arm follows the line's append — the baseline resets per line"
)
@@ -281,29 +282,33 @@ def test_suffix_is_textcontent_only_on_the_latest_line() -> None:
Reading src/app.py (12s)" — the typing hint stays bare "Ns") written
with ``textContent`` on a ``createElement`` span — never innerHTML —
appended as a SIBLING after the line's existing children (the pinned
template text + the <code> argument). It targets the LATEST line only
(`.tool-call:last-child` — older lines keep their permanent record),
and a missing container (New-Chat click mid-gap) makes the tick a
no-op (the null-safe chain is the guard)."""
template text + the <code> argument). Phase 158 (D1): it targets
the TRACKED line only (the arm's second param — the
``:last-child`` query is gone; multiple disclosures make it the
wrong line), and a missing reference (New-Chat click mid-gap)
makes the tick a no-op (the null guard)."""
js = _js()
fn = js.find("function armToolLineClock")
assert fn != -1, "armToolLineClock must exist"
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector?.(".tool-calls .tool-call:last-child")' in body, (
"the suffix targets the LATEST line only"
assert ".tool-calls .tool-call:last-child" not in body, (
"the :last-child query is GONE (multi-disclosure — phase 158)"
)
assert "toolLineEl = lineEl" in body, "the tracked line 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, (
"find-or-create the suffix span on the tracked line"
)
assert 'line.querySelector(".tool-elapsed")' in body, "find-or-create the suffix span"
assert 'el.className = "tool-elapsed"' in body
assert 'document.createElement("span")' in body, "the span is createElement'd"
assert "line.appendChild(el)" in body, (
assert "toolLineEl.appendChild(el)" in body, (
"the suffix is a SIBLING appended AFTER the line's existing children"
)
assert "`(${secs}s)`" in body, ("the parenthesized '(Ns)' suffix, textContent-built")
assert "innerHTML" not in body, "no HTML write in the clock — textContent only"
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body, "the tick gates on the named constant"
assert "toolLineWrap?.querySelector?" in body, (
"null-safe: a wrap reset mid-gap (New Chat) makes the tick a no-op"
)
def test_tool_line_template_literals_stay_byte_identical() -> None:
+1 -1
View File
@@ -304,7 +304,7 @@ def test_restore_renders_collapsed_thinking_block() -> None:
assert fn_start != -1
body = js[fn_start : js.find("\n}\n", fn_start)]
assert "if (m.thinking)" in body
assert "ensureThinkingBlock(wrap)" in body
assert "createThinkingBlock()" in body
assert "block.open = false" in body, "restored blocks must be collapsed"
assert "renderMarkdown(m.thinking)" in body
+4 -2
View File
@@ -371,8 +371,10 @@ def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
FAILED_TURN_TEXT bubble). Both shapes end retryable."""
js = _js()
header = (
"function finalizeFailedTurn(detail, "
"{ acc, thinking, tools, wrap, leavePartialIndex }) {"
"function finalizeFailedTurn(\n"
" detail,\n"
" { acc, thinking, tools, segments, wrap, leavePartialIndex }\n"
") {"
)
body = _fn_body(js, header)
assert body.count("appendFailedNote(") == 2, (
+3 -4
View File
@@ -163,10 +163,9 @@ def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
# …the zero-frame shape creates the FAILED_TURN_TEXT bubble…
assert 'addMessage("brain", FAILED_TURN_TEXT);' in body
assert "appendFailedNote(fwrap, error);" in body
# …and the partial shape settles the block + calls closed (the
# stop-finalize pattern) and adds the note.
assert "closeThinkingBlock(wrap);" in body
assert "closeToolCalls(wrap);" in body
# …and the partial shape settles every open run closed (phase 158,
# D5 — the stop-finalize pattern) and adds the note.
assert "closeAllRuns(wrap);" in body
assert "appendFailedNote(wrap, error);" in body
# Both shapes land the record through rememberBrainTurn (local
# storage + the phase-55 auto-save ride) and set lastBrainWrap
+34 -22
View File
@@ -135,32 +135,42 @@ def test_thinking_event_is_a_first_class_turn_branch() -> None:
assert "sawThinking = true" in branch
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 300s guard"
assert "removeTyping()" in branch, "the live block replaces the typing dots"
assert "ensureThinkingBlock(wrap)" in branch
assert "renderMarkdown(thinkingAcc)" in branch, "escape-first renderer (XSS-safe)"
assert "ensureThinkingRun(wrap)" in branch
assert "renderMarkdown(currentRun.run.text)" in branch, (
"escape-first renderer (XSS-safe) — the RUN's text (phase 158, D1)"
)
assert "textEl.scrollTop = textEl.scrollHeight" in branch, "bottom-pinned while open"
def test_thinking_block_helpers_are_idempotent() -> None:
"""ensureThinkingBlock returns the existing `.thinking` details or
creates it OPEN above the .bubble; closeThinkingBlock is a no-op
without a block and never reopens one once the answer started."""
def test_thinking_run_helpers_are_idempotent() -> None:
"""Phase 158 (D1/D5 — the phase-109 D15 pin, replaced by owner
direction 2026-10-04): ``ensureThinkingRun`` returns the active
run's block or creates a NEW one OPEN above the .bubble (a frame
of a new kind closes the previous run first); ``closeCurrentRun``
is a no-op without a run and folds the active one (the D15
re-open of the shared block is gone — a post-delta thinking frame
opens a NEW block)."""
js = _js()
fn = js.find("function ensureThinkingBlock")
assert fn != -1, "ensureThinkingBlock must exist (near addTyping/removeTyping)"
fn = js.find("function ensureThinkingRun")
assert fn != -1, "ensureThinkingRun must exist (near addTyping/removeTyping)"
body = js[fn : js.find("\n}\n", fn)]
assert "block.open = true" in body, "created open — the stream is the show"
assert "closeCurrentRun()" in body, ("a frame of a new kind closes the "
"previous run (D1)")
assert "block.open = true" in body, "D5: created open — the stream is the show"
assert "insertBefore" in body
assert 'querySelector(".bubble")' in body, "the block sits ABOVE the bubble"
fn2 = js.find("function closeThinkingBlock")
assert fn2 != -1, "closeThinkingBlock must exist"
fn2 = js.find("function closeCurrentRun")
assert fn2 != -1, "closeCurrentRun must exist"
body2 = js[fn2 : js.find("\n}\n", fn2)]
assert "block.open = false" in body2
assert "currentRun.el.open = false" in body2
assert "currentRun = null" in body2, "a no-op without a run (idempotent)"
def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None:
"""The first answer delta transitions thinking → streaming (even when
thinking created the wrap first) and auto-collapses the block —
idempotent, and it never reopens once the answer started."""
thinking created the wrap first) and folds the active run — phase 158
(D5): ``closeCurrentRun()`` (a later thinking frame opens a NEW run;
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"')
@@ -168,19 +178,20 @@ def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None:
branch = js[delta_idx:done_idx]
assert "uiState === UI_STATE.thinking" in branch
assert "setUiState(UI_STATE.streaming)" in branch
assert "closeThinkingBlock(wrap)" in branch
assert "closeCurrentRun()" in branch
def test_done_branch_sets_sawdone_and_closes_block() -> None:
"""On `done` the turn marks itself complete (sawDone — the stream-drop
guard keys off it) and settles the thinking block closed."""
guard keys off it) and settles EVERY open run closed (phase 158, D5:
``closeAllRuns``)."""
js = _js()
done_idx = js.find('ev.type === "done"')
error_idx = js.find('ev.type === "error"')
assert -1 < done_idx < error_idx
branch = js[done_idx:error_idx]
assert "sawDone = true" in branch
assert "closeThinkingBlock(wrap)" in branch
assert "closeAllRuns(wrap)" in branch
def test_stream_drop_guard_reports_severed_stream() -> None:
@@ -282,10 +293,11 @@ def test_stop_turn_is_the_user_abort() -> None:
def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
"""The stop path in handleSend's catch: no error state, no error
banner; when answer text streamed the partial is kept on screen
(thinking block closed, Tune + Stopped note appended — admin parity
with the restore path) and persisted with the owner-locked optional
`stopped: true` marker (+ optional thinking/tools); a pre-token stop
persists nothing brain-side (phase-20 convention). The "Answer
(every open run folded — phase 158, D5 — Tune + Stopped note
appended — admin parity with the restore path) and persisted with
the owner-locked optional `stopped: true` marker (+ optional
thinking/tools/segments); a pre-token stop persists nothing
brain-side (phase-20 convention). The "Answer
stopped." live-region confirmation is set in the finally, AFTER the
single settle, so setUiState(idle) can't overwrite it."""
js = _js()
@@ -301,7 +313,7 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
assert "if (wrap && acc && !persistedOnLeave)" in branch, (
"only a partial WITH answer text is persisted (phase-20 dedupe)"
)
assert "closeThinkingBlock(wrap)" in branch
assert "closeAllRuns(wrap)" in branch
assert "appendTuneButton(wrap)" in branch, "admin parity with the restore path"
assert "appendStoppedNote(wrap)" in branch
assert "stopped: true" in branch, "the owner-locked optional marker"
+9 -4
View File
@@ -63,7 +63,7 @@ def test_pagehide_records_the_partial_index_after_the_push() -> None:
assert "if (persistedOnLeave) return;" in body, "phase-20 idempotency guard"
assert "if (!acc) return;" in body, "nothing brain-side yet → no save"
i_guard = body.index("persistedOnLeave = true;")
i_push = body.index("rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });")
i_push = body.index("rememberBrainTurn(acc, {")
i_index = body.index("leavePartialIndex = conversation.length - 1")
assert i_guard < i_push < i_index, (
"flag → push the partial → record its index (the record it just pushed)"
@@ -125,11 +125,16 @@ def test_every_settle_writes_through_the_correlation() -> None:
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch)
stop_branch = js[stop_idx: js.find("} else {", stop_idx)]
assert "leavePartialIndex" in stop_branch, "the stop settle is correlated"
# The pagehide handler pushes the plain two-arg partial (the record
# that the settles replace).
# The pagehide handler pushes the partial through rememberBrainTurn
# (phase 158, D4: + the optional segments — non-empty only).
fn = js.find('window.addEventListener("pagehide"')
body = js[fn: js.find("\n});", fn)]
assert "rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });" in body
assert "rememberBrainTurn(acc, {" in body
assert "thinking: thinkingAcc || undefined" in body
assert (
"segments: pagehideSegments.length ? pagehideSegments : undefined"
in body
), "phase 158: the partial's timeline persists (non-empty only)"
assert "leavePartialIndex" not in body.replace(
"leavePartialIndex = conversation.length - 1", ""
), "the pagehide handler only records the index — it never settles"
+50 -27
View File
@@ -63,8 +63,14 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None:
"label updates only while the state is still thinking"
)
assert (
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
"const line = appendToolLine("
in branch
) and "armToolLineClock(wrap, line)" in branch, (
"the run's disclosure gets the line; the clock targets it"
)
assert "ensureToolRun(wrap)" in branch, (
"phase 158 (D1): the tool frame opens/extends the run's "
"disclosure"
)
# No setUiState in the branch: the state stays "thinking" (never stale).
assert "setUiState" not in branch, (
@@ -131,22 +137,31 @@ def test_calling_tool_label_strings() -> None:
def test_tool_lines_render_into_the_bubble_wrap() -> None:
"""appendToolLine: first frame creates the .tool-calls list (role=list
+ accessible name) BEFORE the bubble — below an existing Thinking
block — and each line is a .tool-call listitem with the exact marks:
🔎 Listing documents / 📄 Reading <code>path</code>. The path goes
through textContent (storage can never inject HTML)."""
"""Phase 158 (D1): the tool lines render into the run's
disclosure — ``createToolDisclosure`` builds the phase-117
structure (a ``.tool-calls`` list, role=list + accessible name
"Tool calls") and ``ensureToolRun`` inserts it BEFORE the bubble
(D2: above the answer, in arrival order); each line is a
.tool-call listitem with the exact marks: 🔎 Listing documents /
📄 Reading <code>path</code>. The path goes through textContent
(storage can never inject HTML)."""
js = _js()
fn = js.find("function createToolDisclosure")
disc = js[fn : js.find("\n}\n", fn)]
assert 'listEl.className = "tool-calls"' in disc
assert 'setAttribute("role", "list")' in disc
assert 'setAttribute("aria-label", "Tool calls")' in disc, (
"the list keeps its exact phase-37 shape"
)
fn2 = js.find("function ensureToolRun")
run = js[fn2 : js.find("\n}\n", fn2)]
assert 'insertBefore(disc, body.querySelector(".bubble"))' in run, (
"the disclosure sits ABOVE the answer, in arrival order"
)
fn = js.find("function appendToolLine")
assert fn != -1, "appendToolLine must exist"
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector(".tool-calls")' in body, "idempotent per wrap"
assert 'className = "tool-calls"' in body
assert 'setAttribute("role", "list")' in body
assert 'setAttribute("aria-label", "Tool calls")' in body
assert 'insertBefore(container, body.querySelector(".bubble"))' in body, (
"the list sits ABOVE the answer"
)
assert 'querySelector(".tool-calls")' in body, "the line appends to the run's list"
assert 'className = "tool-call"' in body
assert 'setAttribute("role", "listitem")' in body
assert 'line.textContent = "📄 Reading "' in body
@@ -237,10 +252,10 @@ def test_tool_lines_re_render_on_restore() -> None:
body = js[fn:end]
assert "Array.isArray(m.tools)" in body
assert (
"appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
"appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
), (
"phase 139: the stored range + the phase-146 stored scope ride the "
"SAME helper — a pre-phase record (no fields) renders byte-identical"
"SAME helper (the caller passes the run's disclosure — phase 158)"
)
thinking_restore = body.find("if (m.thinking)")
tools_restore = body.find("Array.isArray(m.tools)")
@@ -250,20 +265,28 @@ def test_tool_lines_re_render_on_restore() -> None:
assert "typeof t.name !== \"string\"" in body, "malformed entries skipped"
def test_thinking_block_stays_on_top_of_tool_lines() -> None:
"""If a tool frame precedes the first thinking frame, the Thinking
block is still created ABOVE the tool lines (scratchpad on top),
not below them."""
def test_segments_keep_arrival_order_above_the_bubble() -> None:
"""Phase 158 (D2): both run builders insert their element
BEFORE the bubble — later insertions land after earlier ones, so
document order is ARRIVAL order (a tool frame preceding the
first thinking frame lands the scratchpad BELOW the tool lines —
the timeline order; the old "scratchpad on top" anchor chain is
gone)."""
js = _js()
fn = js.find("function ensureThinkingBlock")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector(".tool-calls")' in body, (
"the thinking anchor must account for existing tool lines"
fn = js.find("function ensureThinkingRun")
t = js[fn : js.find("\n}\n", fn)]
fn2 = js.find("function ensureToolRun")
d = js[fn2 : js.find("\n}\n", fn2)]
assert 'insertBefore(block, body.querySelector(".bubble"))' in t, (
"the thinking run inserts before the bubble"
)
assert 'querySelector(".bubble")' in body
assert "insertBefore" in body
assert "block.open = true" in body
assert 'insertBefore(disc, body.querySelector(".bubble"))' in d, (
"the tool run inserts before the bubble"
)
assert "??" not in t, ("the old anchor chain is GONE — the bubble is "
"the only anchor")
assert "block.open = true" in t
assert "disc.open = true" in d
def test_tool_call_style_is_accent_and_contrast_safe() -> None:
+79 -73
View File
@@ -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)"
+43 -25
View File
@@ -111,17 +111,21 @@ def test_read_range_suffix_shape_order() -> None:
def test_append_tool_line_signature_takes_optional_range() -> None:
"""appendToolLine gains the additive ``offset``/``limit`` params
(default ``null`` — a pre-phase 3-arg call renders byte-identical),
and the read arm renders the suffix through the SAME helper.
Phase 146 (task 02) extends the signature one more additive param
(``scope = null`` — the scoped grep's document) — the 5-arg
pre-phase-146 call shape renders byte-identically."""
"""appendToolLine takes the CONTAINER as its first param (phase
158, D1 — the caller passes the run's disclosure; the
single-container derivation is gone) + the additive
``offset``/``limit`` params (default ``null`` — a pre-phase
3-arg call renders byte-identical), and the read arm renders the
suffix through the SAME helper. Phase 146 (task 02) extends the
signature one more additive param (``scope = null`` — the scoped
grep's document) — the 5-arg pre-phase-146 call shape renders
byte-identically."""
body = _extract(_js(), "appendToolLine")
assert (
"function appendToolLine(wrap, name, argument, offset = null, limit = null, "
"scope = null)" in _js()
), "the signature carries the additive offset/limit/scope (default null)"
"function appendToolLine(container, name, argument, offset = null, "
"limit = null, scope = null)" in _js()
), ("the signature carries the container + the additive "
"offset/limit/scope (default null)")
assert "readRangeSuffix(offset, limit)" in body, (
"the read arm renders the suffix through the SAME helper"
)
@@ -190,8 +194,8 @@ def test_live_tool_branch_reads_the_range_with_the_guard() -> None:
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
# Phase 146 (task 02): the scope joins the SAME two pass-throughs.
assert (
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
in branch
"const line = appendToolLine("
in branch and "armToolLineClock(wrap, line)" in branch
)
@@ -243,8 +247,9 @@ def test_restore_path_passes_the_stored_range_through() -> None:
assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body, (
"the stored limit reads through the house guard"
)
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body, (
"the stored range + the phase-146 stored scope ride the SAME helper"
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body, (
"the stored range + the phase-146 stored scope ride the SAME helper "
"(the caller passes the run's disclosure — phase 158)"
)
@@ -371,6 +376,19 @@ function makeWrap() {
wrap.appendChild(body);
return wrap;
}
function makeDisclosure() {
const container = document.createElement("details");
container.className = "tool-calls-disclosure";
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
container.appendChild(summary);
const listEl = document.createElement("div");
listEl.className = "tool-calls";
container.appendChild(listEl);
const wrap = makeWrap();
wrap.querySelector(".msg-body").appendChild(container);
return { wrap, container };
}
function describe(el) {
return {
text: el.textContent,
@@ -427,12 +445,12 @@ def test_plain_line_byte_identical_and_range_line_carries_the_suffix() -> None:
+ "\n"
+ append
+ '\nconst ARG = "samedoc/twice.md";\n'
+ "const w1 = makeWrap();\n"
+ 'appendToolLine(w1, "read", ARG);\n'
+ 'const plain = describe(w1.querySelector(".tool-call"));\n'
+ "const w2 = makeWrap();\n"
+ 'appendToolLine(w2, "read", ARG, 10, 20);\n'
+ 'const range = describe(w2.querySelector(".tool-call"));\n'
+ "const a1 = makeDisclosure();\n"
+ 'appendToolLine(a1.container, "read", ARG);\n'
+ 'const plain = describe(a1.wrap.querySelector(".tool-call"));\n'
+ "const a2 = makeDisclosure();\n"
+ 'appendToolLine(a2.container, "read", ARG, 10, 20);\n'
+ 'const range = describe(a2.wrap.querySelector(".tool-call"));\n'
+ "console.log(JSON.stringify({ plain, range }));"
)
out = json.loads(_run_node(driver))
@@ -477,12 +495,12 @@ def test_shared_page_line_parity_under_node() -> None:
+ append
+ '\nconst ARG = "samedoc/twice.md";\n'
# app.js: a range read + a plain read
+ "const wa = makeWrap();\n"
+ 'appendToolLine(wa, "read", ARG, 10, 20);\n'
+ 'const appRange = describe(wa.querySelector(".tool-call"));\n'
+ "const wp = makeWrap();\n"
+ 'appendToolLine(wp, "read", ARG);\n'
+ 'const appPlain = describe(wp.querySelector(".tool-call"));\n'
+ "const wa = makeDisclosure();\n"
+ 'appendToolLine(wa.container, "read", ARG, 10, 20);\n'
+ 'const appRange = describe(wa.wrap.querySelector(".tool-call"));\n'
+ "const wp = makeDisclosure();\n"
+ 'appendToolLine(wp.container, "read", ARG);\n'
+ 'const appPlain = describe(wp.wrap.querySelector(".tool-call"));\n'
# shared.js: the SAME record (range + no-fields) through addToolLines
+ shared_suffix.replace("function readRangeSuffix", "function sharedSuffix")
+ "\n"
+2 -2
View File
@@ -163,7 +163,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
assert fn != -1
end = js.find("function restoreConversation")
body = js[fn:end]
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
assert "t.truncated && arg" in body, (
"only an argument-bearing (Reading) record with the flag renders"
" the marker"
@@ -173,7 +173,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
" Number(t.chars_total) || 0)" in body
)
# The marker append sits INSIDE the tools loop, after the line append.
line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
line_idx = body.find("appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)")
note_idx = body.find("appendTruncatedNote(wrap, arg,")
assert -1 < line_idx < note_idx
+222
View File
@@ -16,6 +16,9 @@ from pydantic import ValidationError
from app.schemas import (
ChatMessage,
ChatRequest,
ChatSegment,
ChatThinkingSegment,
ChatToolSegment,
SavedChatCreate,
SavedChatUpdate,
SourceRef,
@@ -682,3 +685,222 @@ def test_chat_tool_result_event_counts_reject_negative() -> None:
ChatToolResultEvent(name="read", argument="x", chars_shown=-1, chars_total=5)
with pytest.raises(ValidationError):
ChatToolResultEvent(name="read", argument="x", chars_shown=5, chars_total=-1)
# ---------------------------------------------------------------------------
# Phase 158 (task 01): the 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
# (mirrored, not weakened — ToolCall is the shape source), OMITTED when
# absent (the phase-50 byte-identical round-trip contract — ABSENT,
# never null), the extra="forbid" boundary intact around the new key.
# ---------------------------------------------------------------------------
def test_segments_absent_by_default_and_omitted_in_dump() -> None:
"""A record WITHOUT ``segments`` (every pre-phase shape, every
no-thinking/no-tools turn) validates with ``None`` and dumps
WITHOUT the key — ABSENT, never ``null`` (the phase-50 round-trip
contract; the wrap serializer does the dropping on every surface).
The dump stays byte-identical to the pre-phase shape."""
msg = ChatMessage.model_validate({"who": "brain", "text": "ok"})
assert msg.segments is None
dumped = msg.model_dump()
assert "segments" not in dumped
# Byte-identical to the pre-phase stored shape: every bor.chat.v1
# optional key present (null), the three serializer-popped keys
# (image / answer_image / segments) absent.
assert dumped == {
"who": "brain",
"text": "ok",
"sources": None,
"related": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
"failed": None,
"error": None,
}
# An explicit empty list is a different thing: [] is a value, so it
# round-trips as [] (present, empty — the pop is None-gated only).
empty = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": []}
).model_dump()
assert empty["segments"] == []
def test_segments_thinking_entry_round_trips_and_caps() -> None:
"""The thinking entry: ``min_length=1`` (the zero-char run the
client never stores) + ``max_length=32_000`` (HistoryTurn's cap,
phase 83) — at the cap validates, one past raises naming the
offender; a missing ``text`` too."""
at_cap = ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "t" * TEXT_CAP}],
}
)
assert at_cap.segments == [
ChatThinkingSegment(kind="thinking", text="t" * TEXT_CAP)
]
assert at_cap.model_dump()["segments"] == [
{"kind": "thinking", "text": "t" * TEXT_CAP}
]
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "t" * (TEXT_CAP + 1)}],
}
)
# Pydantic 2.13 names the union variant in the loc — the 422 still
# points at the offender (segments[0].text).
_failed_loc(exc.value, "segments", 0, "thinking", "text")
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [{"kind": "thinking", "text": ""}]}
)
_failed_loc(exc.value, "segments", 0, "thinking", "text")
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [{"kind": "thinking"}]}
)
def test_segments_tool_entry_mirrors_toolcall_shape() -> None:
"""The tool entry is the EXACT ToolCall field set (mirrored, not
weakened — phase-37 pair + phase-95 truncation stamp + phase-139
range + phase-146 scoped path): a full entry round-trips
byte-for-byte; a NON-scoped entry dumps WITHOUT ``path`` (ABSENT,
never ``null`` — the ToolCall omission rule mirrored, so a
``segments`` entry and the matching ``tools`` entry of the same
record stay the same data); a scoped one keeps it."""
full = {
"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",
}
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [full]}
)
assert msg.model_dump()["segments"] == [full]
# Round-trip: the dump re-validates and dumps back to itself.
again = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": msg.model_dump()["segments"]}
)
assert again.model_dump() == msg.model_dump()
unscoped = {
"kind": "tool",
"name": "ls",
"argument": None,
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
dumped = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [unscoped]}
).model_dump()["segments"][0]
assert "path" not in dumped # ABSENT, never null
assert dumped == {
"kind": "tool",
"name": "ls",
"argument": None,
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
def test_segments_caps_mirrored_not_weakened() -> None:
"""The ToolCall caps bind on the segment (mirrored, not
weakened): ``name`` ≤ 100, ``argument`` ≤ 2 000, ``path`` ≤ 2 000,
``offset``/``limit`` ≥ 1, the list ≤ 50 (the phase-83 voice — the
``tools`` cap)."""
bad: list[dict] = [
{"kind": "tool", "name": "n" * (NAME_CAP + 1), "argument": None},
{"kind": "tool", "name": "grep", "argument": "a" * (ARGUMENT_CAP + 1)},
{"kind": "tool", "name": "grep", "argument": "x", "path": "p" * (ARGUMENT_CAP + 1)},
{"kind": "tool", "name": "read", "offset": 0},
{"kind": "tool", "name": "read", "limit": 0},
{"kind": "tool", "name": "read", "chars_shown": -1},
]
for entry in bad:
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [entry]}
)
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "x"}] * (TOOLS_CAP + 1),
}
)
_failed_loc(exc.value, "segments")
def test_unknown_segment_kind_rejected() -> None:
"""The discriminator (``kind`` = ``"thinking"`` | ``"tool"``): an
unknown ``kind`` is a ValidationError naming the offending entry —
nothing outside the union crosses the boundary."""
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "bogus", "text": "x"}],
}
)
assert exc.value.errors()[0]["loc"][:2] == ("segments", 0), exc.value.errors()
# A thinking variant without its ``text`` and a tool variant without
# its ``name`` are both rejected (the required fields are real).
with pytest.raises(ValidationError):
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "segments": [{"kind": "tool"}]}
)
def test_extra_top_level_key_still_forbidden_with_segments() -> None:
"""The ``extra="forbid"`` boundary is INTACT around the new key:
a record with a well-formed ``segments`` AND a stray top-level key
is still rejected — adding the field changed nothing about which
keys are accepted (the phase-83 voice)."""
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"html": "<b>x</b>",
"segments": [{"kind": "thinking", "text": "x"}],
}
)
_failed_loc(exc.value, "html")
def test_segments_alias_is_the_discriminated_union() -> None:
"""``ChatSegment`` is the Annotated discriminated alias (the
``_Chip`` precedent, phase 83): the JSON shape is a flat object —
the discriminator rides as a field, no wrapper."""
from pydantic import TypeAdapter
adapter = TypeAdapter(ChatSegment)
thinking = adapter.validate_python({"kind": "thinking", "text": "x"})
assert isinstance(thinking, ChatThinkingSegment)
tool = adapter.validate_python({"kind": "tool", "name": "ls"})
assert isinstance(tool, ChatToolSegment)
+53 -35
View File
@@ -89,16 +89,20 @@ def _run_node(script: str) -> str:
def test_append_tool_line_signature_takes_optional_scope() -> None:
"""appendToolLine gains the additive ``scope`` param (fifth,
default ``null`` — a pre-phase 5-arg call renders byte-identical),
and the grep arm appends the scope span through the pinned
structure (a ``.grep-scope`` span SIBLING after the pattern
``<code>`` — the phase-139 read-range-suffix precedent)."""
"""appendToolLine takes the CONTAINER as its first param (phase
158, D1 — the caller passes the run's disclosure; the
single-container derivation is gone) + the additive ``scope``
param (fifth, default ``null`` — a pre-phase 5-arg call renders
byte-identical), and the grep arm appends the scope span through
the pinned structure (a ``.grep-scope`` span SIBLING after the
pattern ``<code>`` — the phase-139 read-range-suffix
precedent)."""
js = _js()
assert (
"function appendToolLine(wrap, name, argument, offset = null, "
"function appendToolLine(container, name, argument, offset = null, "
"limit = null, scope = null)" in js
), "the signature carries the additive scope (default null)"
), ("the signature carries the container + the additive scope "
"(default null)")
body = _extract(js, "appendToolLine")
assert "if (typeof scope === \"string\" && scope)" in body, (
"the span is appended ONLY when the scope is a non-empty string"
@@ -184,8 +188,8 @@ def test_live_tool_branch_reads_the_scope_with_the_guard() -> None:
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
# The scope rides the SAME helper (the line's fifth argument).
assert (
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, "
"framePath)" in branch
"const line = appendToolLine(" in branch
and "armToolLineClock(wrap, line)" in branch
)
@@ -229,8 +233,9 @@ def test_restore_path_passes_the_stored_scope_through() -> None:
"typeof t.path === \"string\" && t.path ? t.path : null" in body
), "the stored scope reads through the house string guard"
assert (
"appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
), "the stored scope rides the SAME helper"
"appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
), ("the stored scope rides the SAME helper (the caller passes the "
"run's disclosure — phase 158)")
# --------------------------------------------------------------------------
@@ -380,6 +385,19 @@ function makeWrap() {
wrap.appendChild(body);
return wrap;
}
function makeDisclosure() {
const container = document.createElement("details");
container.className = "tool-calls-disclosure";
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
container.appendChild(summary);
const listEl = document.createElement("div");
listEl.className = "tool-calls";
container.appendChild(listEl);
const wrap = makeWrap();
wrap.querySelector(".msg-body").appendChild(container);
return { wrap, container };
}
function describe(el) {
return {
text: el.textContent,
@@ -418,24 +436,24 @@ def test_scoped_grep_line_and_bare_byte_identity_under_node() -> None:
+ '\nconst PATTERN = "reese-sentinel-42";\n'
+ 'const SCOPE = "search_docs/reese-notes.md";\n'
# bare grep (no scope)
+ "const w0 = makeWrap();\n"
+ 'appendToolLine(w0, "grep", PATTERN);\n'
+ 'const bare = describe(w0.querySelector(".tool-call"));\n'
+ "const a0 = makeDisclosure();\n"
+ 'appendToolLine(a0.container, "grep", PATTERN);\n'
+ 'const bare = describe(a0.wrap.querySelector(".tool-call"));\n'
# bare grep, legacy name (no scope)
+ "const w1 = makeWrap();\n"
+ 'appendToolLine(w1, "search_documents", PATTERN);\n'
+ 'const legacy = describe(w1.querySelector(".tool-call"));\n'
+ "const a1 = makeDisclosure();\n"
+ 'appendToolLine(a1.container, "search_documents", PATTERN);\n'
+ 'const legacy = describe(a1.wrap.querySelector(".tool-call"));\n'
# guarded fallbacks: empty string + non-string scope
+ "const w2 = makeWrap();\n"
+ 'appendToolLine(w2, "grep", PATTERN, null, null, "");\n'
+ 'const emptyScope = describe(w2.querySelector(".tool-call"));\n'
+ "const w3 = makeWrap();\n"
+ 'appendToolLine(w3, "grep", PATTERN, null, null, 42);\n'
+ 'const badScope = describe(w3.querySelector(".tool-call"));\n'
+ "const a2 = makeDisclosure();\n"
+ 'appendToolLine(a2.container, "grep", PATTERN, null, null, "");\n'
+ 'const emptyScope = describe(a2.wrap.querySelector(".tool-call"));\n'
+ "const a3 = makeDisclosure();\n"
+ 'appendToolLine(a3.container, "grep", PATTERN, null, null, 42);\n'
+ 'const badScope = describe(a3.wrap.querySelector(".tool-call"));\n'
# the scoped grep
+ "const w4 = makeWrap();\n"
+ 'appendToolLine(w4, "grep", PATTERN, null, null, SCOPE);\n'
+ 'const scoped = describe(w4.querySelector(".tool-call"));\n'
+ "const a4 = makeDisclosure();\n"
+ 'appendToolLine(a4.container, "grep", PATTERN, null, null, SCOPE);\n'
+ 'const scoped = describe(a4.wrap.querySelector(".tool-call"));\n'
"console.log(JSON.stringify("
"{ bare, legacy, emptyScope, badScope, scoped }));"
)
@@ -511,15 +529,15 @@ def test_non_grep_arms_ignoring_the_scope_stay_byte_identical() -> None:
+ range_suffix
+ "\n"
+ append
+ "const w0 = makeWrap();\n"
+ 'appendToolLine(w0, "read", "s/d.md", null, null, "x");\n'
+ 'const readLine = describe(w0.querySelector(".tool-call"));\n'
+ "const w1 = makeWrap();\n"
+ 'appendToolLine(w1, "ls", "s", null, null, "x");\n'
+ 'const lsLine = describe(w1.querySelector(".tool-call"));\n'
+ "const w2 = makeWrap();\n"
+ 'appendToolLine(w2, "ls", null, null, null, "x");\n'
+ 'const plainLine = describe(w2.querySelector(".tool-call"));\n'
+ "const a0 = makeDisclosure();\n"
+ 'appendToolLine(a0.container, "read", "s/d.md", null, null, "x");\n'
+ 'const readLine = describe(a0.wrap.querySelector(".tool-call"));\n'
+ "const a1 = makeDisclosure();\n"
+ 'appendToolLine(a1.container, "ls", "s", null, null, "x");\n'
+ 'const lsLine = describe(a1.wrap.querySelector(".tool-call"));\n'
+ "const a2 = makeDisclosure();\n"
+ 'appendToolLine(a2.container, "ls", null, null, null, "x");\n'
+ 'const plainLine = describe(a2.wrap.querySelector(".tool-call"));\n'
"console.log(JSON.stringify({ readLine, lsLine, plainLine }));"
)
out = json.loads(_run_node(driver))
+9 -3
View File
@@ -60,11 +60,17 @@ def test_pagehide_guard_requires_in_flight_state_and_streamed_text() -> None:
def test_pagehide_reuses_remember_brain_turn_with_thinking() -> None:
"""The partial is persisted through the EXISTING save-point helper —
raw text, optional thinking field (`undefined` drops the key from the
JSON), no sources/deflection: a plain brain message that restore
re-renders exactly like a completed answer (no '(partial)' marker)."""
JSON) + phase 158 (D4): the optional timeline (non-empty only), no
sources/deflection: a plain brain message that restore re-renders
exactly like a completed answer (no '(partial)' marker)."""
js = _js()
body = _pagehide_body(js)
assert "rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });" in body
assert "rememberBrainTurn(acc, {" in body
assert "thinking: thinkingAcc || undefined" in body
assert (
"segments: pagehideSegments.length ? pagehideSegments : undefined"
in body
), "phase 158: the partial's timeline persists (non-empty only)"
# No duplicated storage code: the handler pushes nothing itself.
assert "conversation.push" not in body
assert "saveConversation()" not in body
+2 -2
View File
@@ -123,7 +123,7 @@ def test_thinking_pin_is_gated_on_window_bottom() -> None:
assert -1 < thinking_idx < delta_idx
thinking_branch = js[thinking_idx:delta_idx]
capture = "block.open && isThinkingNearBottom(textEl)"
render = "textEl.innerHTML = renderMarkdown(thinkingAcc)"
render = "textEl.innerHTML = renderMarkdown(currentRun.run.text)"
gate = "if (pinned)"
assert capture in thinking_branch, (
"the combined gate must be captured (block.open + the window band)"
@@ -160,5 +160,5 @@ def test_restore_path_renders_collapsed_block() -> None:
start = js.find("function renderStoredMessage(m) {")
assert start != -1, "app.js must keep the phase-14 restore renderer"
body = js[start : js.find("\n}", start)]
assert "ensureThinkingBlock(wrap)" in body
assert "createThinkingBlock()" in body
assert "block.open = false" in body, "restored blocks must be collapsed"
+825
View File
@@ -0,0 +1,825 @@
"""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
+92 -64
View File
@@ -5,12 +5,15 @@ per ``tool`` frame — 6+ stacked full-width bordered cards on a phone —
swamped a completed answer, and the flex row broke the label
mid-word. The fix (phase 117, owner decisions D1–D6) wraps the
existing ``.tool-calls`` list in a native ``details`` disclosure with a
plain-text "Tool call(s) (N)" summary — created OPEN on the first live
``tool`` frame, folded at rest (delta/done/stop via ``closeToolCalls``),
and rendered CLOSED on the restore path and the shared page (D3). The
line bytes, the four label literals, and the ``<code>`` textContent
arguments stay byte-identical (D6), so the phase-37/70/87/95 pins
stay green.
plain-text "Tool call(s) (N)" summary — phase 158 (D1): one
disclosure per CONTIGUOUS tool-call run, created OPEN on the first
live ``tool`` frame of the run, folded at rest (delta folds the
active run via ``closeCurrentRun``; done/stop/finalizeFailedTurn fold
every run via ``closeAllRuns``), and rendered CLOSED on the restore
path and the shared page (D3). The count is per run (the per-run
"Tool call(s) (N)", D3). The line bytes, the four label literals, and
the ``<code>`` textContent arguments stay byte-identical (D6), so the
phase-37/70/87/95 pins stay green.
No ``app/`` logic (frontend-only, D1) — like
``test_frontend_tool_states.py``, this module pins the JS/CSS markers
@@ -59,36 +62,46 @@ def _fn(src: str, name: str) -> str:
return src[fn : src.find("\n}\n", fn)]
def test_append_tool_line_builds_an_open_details_disclosure() -> None:
"""appendToolLine (D2/D3/D5): the FIRST frame creates a native
``details.tool-calls-disclosure`` — a ``summary.tool-calls-summary``
(first child) + the existing ``.tool-calls`` list (role=list +
aria-label "Tool calls", second child) — inserted before the bubble
where the list used to be. It starts OPEN while the turn is live;
every append re-renders the plain-text "Tool call(s) (N)" count on
the summary through textContent (no innerHTML anywhere)."""
body = _fn(_js(), "appendToolLine")
assert "document.createElement(\"details\")" in body
assert "className = \"tool-calls-disclosure\"" in body
assert "container.open = true", (
"D3: created OPEN on the first live tool frame — the calls and "
"the phase-87 .tool-elapsed suffix stay visible during the turn"
)
assert "document.createElement(\"summary\")" in body
assert "className = \"tool-calls-summary\"" in body
def test_tool_run_builds_an_open_details_disclosure() -> None:
"""Phase 158 (D1/D2/D5): ``ensureToolRun`` creates a native
``details.tool-calls-disclosure`` via ``createToolDisclosure`` —
a ``summary.tool-calls-summary`` (first child) + the existing
``.tool-calls`` list (role=list + aria-label "Tool calls",
second child) — inserted before the bubble in arrival order (D2).
It starts OPEN while the run is live; every append re-renders the
plain-text per-run "Tool call(s) (N)" count on the summary
through textContent (no innerHTML anywhere)."""
js = _js()
disc = _fn(js, "createToolDisclosure")
assert "document.createElement(\"details\")" in disc
assert "className = \"tool-calls-disclosure\"" in disc
assert "document.createElement(\"summary\")" in disc
assert "className = \"tool-calls-summary\"" in disc
# The list keeps its exact phase-37 shape, nested in the disclosure.
assert "className = \"tool-calls\"" in body
assert 'setAttribute("role", "list")' in body
assert 'setAttribute("aria-label", "Tool calls")' in body
assert 'insertBefore(container, body.querySelector(".bubble"))' in body, (
"the disclosure sits ABOVE the answer, below an existing Thinking block"
assert "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)"
)
run = _fn(js, "ensureToolRun")
assert "createToolDisclosure()" in run
assert "disc.open = true", (
"D5: created OPEN on the first live tool frame of the run — the "
"calls and the phase-87 .tool-elapsed suffix stay visible during "
"the turn"
)
assert 'insertBefore(disc, body.querySelector(".bubble"))' in run, (
"D2: above the answer, in arrival order"
)
# D5: the count rides the summary — the template fragment itself
# (singular for one call, plural otherwise), textContent-built.
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body, (
"the plain-text count template (D5) on every append"
append = _fn(js, "appendToolLine")
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in append, (
"the per-run plain-text count template (D3) on every append"
)
assert "innerHTML" not in body, (
assert "innerHTML" not in append, (
"no HTML injection surface on tool lines — textContent only"
)
@@ -106,55 +119,70 @@ def test_append_tool_line_keeps_the_four_pinned_labels() -> None:
)
def test_close_tool_calls_defined_and_called_at_every_rest_site() -> None:
"""closeToolCalls (D3): defined next to closeThinkingBlock,
idempotent (optional-chaining query → open=false), and called at
ALL rest sites — the delta handler (the answer began), the done
def test_close_all_runs_defined_and_called_at_every_rest_site() -> None:
"""Phase 158 (D5): ``closeAllRuns`` — the terminal settle (the
phase-117 ``closeToolCalls`` + the D15 block close, merged):
closes EVERY open ``details.thinking`` +
``details.tool-calls-disclosure`` in the wrap (defensive; a late
double-close is a no-op), called at ALL rest sites — the done
handler (the turn ended), the stop/abort settle (the turn was
stopped), and the restore path — so the definition + ≥4 calls."""
stopped), and the failed-turn funnel — so the definition + ≥3
calls. The `delta` handler folds only the active run
(``closeCurrentRun``)."""
js = _js()
body = _fn(js, "closeToolCalls")
assert 'querySelector?.(".tool-calls-disclosure")' in body, (
"targets the disclosure of the given wrap only"
body = _fn(js, "closeAllRuns")
assert "details.thinking, details.tool-calls-disclosure" in body, (
"targets both run kinds in the given wrap"
)
assert "disc.open = false" in body, "fold: open=false (idempotent)"
assert js.count("closeToolCalls(wrap)") >= 4, (
"the definition + the delta/done/stop handler sites + the "
"renderStoredMessage restore fold — the disclosure settles "
"exactly where the Thinking block settles"
assert "el.open = false" in body, "fold: open=false (idempotent)"
assert js.count("closeAllRuns(wrap)") >= 3, (
"the definition + the done handler + the stop/abort settle + "
"the failed-turn funnel — the runs settle exactly where the "
"Thinking blocks settle"
)
delta_idx = js.find('ev.type === "delta"')
done_idx = js.find('ev.type === "done"')
delta_branch = js[delta_idx:done_idx]
assert "closeCurrentRun()" in delta_branch, (
"the delta folds the ACTIVE run (not every run — a later "
"thinking frame opens a new one)"
)
def test_restore_path_folds_the_disclosure() -> None:
"""renderStoredMessage (D3): a restored brain record with `tools`
re-renders through the SAME appendToolLine (byte-identical), and
the disclosure is FOLDED right after the loop — a completed turn
from storage is one compact "Tool calls (N)" line, not N stacked
cards (the space fix; mirrors the thinking restore rendering
collapsed)."""
re-renders through the SAME appendToolLine (byte-identical — the
caller passes the run's disclosure, phase 158), and the
disclosure is FOLDED right after the loop (``restoreDisc.open =
false``) — a completed turn from storage is one compact
"Tool calls (N)" line, not N stacked cards (the space fix;
mirrors the thinking restore rendering collapsed)."""
js = _js()
fn = js.find("function renderStoredMessage")
assert fn != -1
body = js[fn : js.find("function restoreConversation", fn)]
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
assert "closeToolCalls(wrap)" in body
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
assert "restoreDisc.open = false" in body
assert (
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
< body.find("closeToolCalls(wrap)")
body.find("appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)")
< body.find("restoreDisc.open = false")
), "the fold sits AFTER the tool-line restore loop"
def test_thinking_anchor_accounts_for_the_disclosure() -> None:
"""ensureThinkingBlock: the anchor is the WHOLE disclosure (a
`thinking` frame after the first `tool` frame lands the scratchpad
ABOVE the disclosure, not inside it); the bare .tool-calls and
.bubble terms stay as fallbacks, and the block still opens while
active (phase-109 toggle behavior untouched)."""
body = _fn(_js(), "ensureThinkingBlock")
assert ".tool-calls-disclosure" in body, "the new anchor term"
assert 'querySelector(".tool-calls")' in body, "the pre-117 fallback"
assert 'querySelector(".bubble")' in body
assert "block.open = true" in body
def test_run_anchors_are_the_bubble_in_arrival_order() -> None:
"""Phase 158 (D2): the anchor is the BUBBLE for both run kinds —
the old ``.tool-calls-disclosure ?? .tool-calls ?? .bubble``
anchor chain is GONE (with per-run disclosures, "above the
tool lines" was the wrong order — arrival order is the
contract), and the runs still open while active (D5)."""
js = _js()
run = _fn(js, "ensureThinkingRun")
assert 'querySelector(".bubble")' in run
assert "block.open = true" in run
assert "??" not in run, "the old anchor chain is GONE"
d = _fn(js, "ensureToolRun")
assert 'querySelector(".bubble")' in d
assert "disc.open = true" in d
def test_shared_page_add_tool_lines_builds_a_closed_disclosure() -> None: