phase: 158_thinking_timeline
All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user