"""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_div_block_in_the_container() -> None: """D1/D3 + the D5 refinement (owner 2026-10-06): ``ensureThinkingRun`` returns the active thinking run's block or — a NEW plain ``div.thinking`` (the run builder — ``createThinkingRunEl``: the static ``.thinking-summary`` "Thinking" literal + the ``.thinking-text``; NEVER open/foldable — the container is the ONE disclosure, always visible while it is open), appended into the phase-161 reasoning container's ``.reasoning-body`` (D1 — the container sits before the bubble, a new run appends after the previous run's block, so arrival order is preserved; the per-run bubble anchor is superseded). The plain ``Thinking`` literal (D3 — the phase-17 literal, not a numbered variant) rides the builder; 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 ends the previous run first (D1)" ) assert "createThinkingRunEl()" in body, ( "the D5 refinement: a plain div — never collapsible" ) assert ".open =" not in body, ( "the in-container runs are non-collapsible divs" ) assert ( 'ensureReasoningWrap(wrap).querySelector(".reasoning-body")' ".appendChild(block)" in body ), ( "phase 161 (D1): appended into the container's body — the " "container sits above the bubble, arrival order preserved" ) assert "insertBefore" not in body, ( "the per-run bubble anchor is GONE (phase 161 D1)" ) 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 "`Thinking
`" 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_div_disclosure_in_the_container() -> None: """D1 + the D5 refinement (owner 2026-10-06): ``ensureToolRun`` returns the active tool run's disclosure or — a NEW plain ``div.tool-calls-disclosure`` (the run builder — ``createToolRunEl``: the static ``.tool-calls-summary`` + the .tool-calls list, role=list + aria-label "Tool calls"; NEVER open/foldable — the container is the ONE disclosure, always visible while it is open), appended into the phase-161 reasoning container's ``.reasoning-body`` (D1 — the container sits before the bubble, arrival order preserved; the per-run bubble anchor is superseded). 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 "createToolRunEl()" in body, ( "the D5 refinement: a plain div — never collapsible" ) assert ".open =" not in body, ( "the in-container runs are non-collapsible divs" ) assert ( 'ensureReasoningWrap(wrap).querySelector(".reasoning-body")' ".appendChild(disc)" in body ), ( "phase 161 (D1): appended into the container's body — the " "container sits above the bubble, arrival order preserved" ) assert "insertBefore" not in body, ( "the per-run bubble anchor is GONE (phase 161 D1)" ) 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: """The D5 refinement's (owner 2026-10-06) fold side: ``closeCurrentRun`` ENDS the ACTIVE run (nothing folds — the in-container runs are non-collapsible divs; idempotent; a no-op without a run) and ``closeAllRuns`` closes EVERY open ``details.thinking`` + ``details.tool-calls-disclosure`` in the wrap (the DEFENSIVE legacy guard — the pre-phase-161 details blocks the restore path creates, already closed; a no-op in every live path).""" js = _js() cur = _fn(js, "closeCurrentRun") assert ".open" not in cur, ( "D5 refinement: nothing folds — the run simply ends" ) 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`` inside the container COLLAPSED — a ``kind:"thinking"`` entry gets its OWN plain ``div.thinking`` (the D5 refinement — the run builder, never collapsible), and CONSECUTIVE ``kind:"tool"`` entries group into ONE plain ``div.tool-calls-disclosure`` (the run builder — the static label + the .tool-calls list; innerHTML ABSENT). Phase 161 (task 02, D5/D6): the per-run blocks nest inside ONE details.reasoning container — the shared task-01 builder, created CLOSED and inserted ONCE before the bubble (the per-run bubble anchor is superseded; arrival order preserved). 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 "createThinkingRunEl()" in body, ( "the D5 refinement: the in-container run is a plain div " "(never collapsible — the container is the ONE disclosure)" ) assert "createToolRunEl()" in body, ( "the D5 refinement: the in-container run is a plain div " "(never collapsible)" ) assert "block.open = true" not in body, "the in-container runs never open" assert "toolDisc.open" not in body, ( "the in-container runs are non-collapsible divs" ) # the consecutive-tool grouping (one disclosure per run — the # grouping loop): create on the first entry, seal on a kind # change, append into the container's body. assert "if (toolDisc) toolDisc = null" in body, ( "a thinking entry seals the tool run — ONE disclosure per run (D1)" ) # Phase 161 (task 02, D5/D6): the per-run timeline nests inside # ONE details.reasoning container — the shared task-01 builder, # created CLOSED and inserted ONCE before the bubble (the # per-run bubble anchor is superseded; arrival order preserved). assert "createReasoningWrap()" in body, "the shared task-01 builder is reused" assert "cont.open = false" in body, ( "D5: the restore renders collapsed (explicit for the pin)" ) assert "body.insertBefore(cont, bubble)" in body, ( "the container sits above the bubble — the ONE anchor" ) assert "runsEl.appendChild(block)" in body, ( "D1: the per-run block nests into the container's body" ) assert "runsEl.appendChild(toolDisc)" in body, ( "D1: the per-run disclosure nests into the container's body" ) assert "insertBefore(block" not in body, ( "the per-run bubble anchor is GONE (phase 161 D5)" ) assert "insertBefore(toolDisc" not in body, ( "the per-run bubble anchor is GONE (phase 161 D5)" ) 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"([\w.$()\"'-]+\.?)\.innerHTML\s*=\s*([^;\n]+)", body) assert len(writes) == 1, ( f"exactly ONE innerHTML write (the structure is createElement): {writes}" ) assert ( writes[0][0].rstrip(".") == "textEl" or 'querySelector(".thinking-text")' in writes[0][0] ), "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 in-container runs ride the innerHTML-absent D5-refinement run builders — the static label via textContent; the .tool-calls list, role=list + aria-label "Tool calls" — 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 "createThinkingRunEl()" in body, ( "the in-container run rides the innerHTML-absent run builder" ) assert "createToolRunEl()" in body, ( "the in-container run rides the innerHTML-absent run builder" ) 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 plain div.thinking with the static ``.thinking-summary`` "Thinking" label via textContent + the phase-117 disclosure structure as a plain div — ``.tool-calls-summary`` + the .tool-calls list, role=list + aria-label "Tool calls"; the D5 refinement — NEVER collapsible, always visible while the container is open), 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 nesting inside the phase-161 details.reasoning container (the local parity builder, created CLOSED and inserted ONCE before the bubble — the per-run bubble anchor is superseded, arrival order preserved) — all created COLLAPSED (the container is the ONE disclosure; the addThinkingBlock / addToolLines collapsed convention).""" body = _fn(_js_shared(), "addSegments") assert "document.createElement(\"div\")" in body, "the structure is createElement" assert 'block.className = "thinking"' in body assert "block.open" not in body, ( "D5 refinement: the in-container runs are non-collapsible divs" ) assert 'label.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 (the D5 refinement — a plain # div, never collapsible). assert 'toolDisc.className = "tool-calls-disclosure"' in body assert "toolDisc.open" not in body, ( "D5 refinement: the in-container runs are non-collapsible divs" ) 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 # Phase 161 (task 02, D5): the per-run timeline nests inside ONE # details.reasoning container — the local parity builder, created # CLOSED and inserted ONCE before the bubble (the per-run bubble # anchor is superseded; arrival order preserved). assert "createReasoningWrap()" in body, "the local parity builder is reused" assert "cont.open = false" in body, ( "D5: collapsed (the pure-render convention)" ) assert "body.insertBefore(cont, bubble)" in body, ( "inserted ONCE before the bubble" ) assert "runsEl.appendChild(block)" in body, ( "D1: the per-run block nests into the container's body" ) assert "runsEl.appendChild(toolDisc)" in body, ( "D1: the per-run disclosure nests into the container's body" ) assert "insertBefore(block" not in body, ( "the per-run bubble anchor is GONE (phase 161 D5)" ) assert "insertBefore(toolDisc" not in body, ( "the per-run bubble anchor is GONE (phase 161 D5)" ) # 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