fix(rag): stop the per-run blocks from folding — the container is the one disclosure
The owner refinement (2026-10-06): the per-run thinking and tool-call blocks inside the Reasoning bubble were individually collapsible details elements — a fold inside the fold. A tool frame closed the previous thinking block, so the visible thinking vanished mid-turn; and the pinned-tail follow died on the append of every run after the first (the pin was captured AFTER the new block's height growth contaminated the 32px reading). - app.js: the in-container runs are now plain divs — createThinkingRunEl (div.thinking + static .thinking-summary label + .thinking-text) and createToolRunEl (div.tool-calls-disclosure + static .tool-calls-summary label + the .tool-calls list); they never open or fold (the container is the ONE open/fold control, open through the turn, folded at settle — unchanged); the pin capture moves BEFORE ensureThinkingRun/ensureToolRun append (plus the first-frame edge: no container yet -> always pinned); closeCurrentRun just ends the run; closeAllRuns stays the defensive legacy guard; restoreTimeline/addSegments (shared.js) use the div builders; the legacy no-segments restore path keeps the old details-based blocks byte-identical - styles.css: the in-container runs are distinct by label + rail (brand-ink Thinking label on a brand-soft rail; accent-ink Tool call(s) (N) label on a neutral rail); the interactive treatment (cursor + chevron) is scoped to the legacy <summary>; the inner windows stay disabled (the container's .reasoning-body is the ONE live scroll surface) - unit + E2E: the D5-refinement contract is pinned — one details.reasoning disclosure, per-run blocks as plain divs, zero nested <details> inside the container, the container as the only open/fold control, the .reasoning-body window auto-follows the tail while streaming unit+integration 3738 passed (coverage 99%), ruff + pyright clean, all five affected E2E suites pass in isolation (reasoning_bubble 6, thinking_timeline 6, thinking_display 5, thinking_scroll 8, tool_call_compact 4), verified live: the follow holds through a long thinking stream, the tool-frame transition, and the second thinking run; restore renders the div structure without client-side errors LLM-Generated: true
This commit is contained in:
@@ -23,12 +23,15 @@ further tool call). The client renders the ENTIRE per-run timeline
|
||||
inside ONE ``details.reasoning`` container (D1): open from creation
|
||||
until the turn settles (D2 — it STAYS OPEN through a mid-round
|
||||
delta; only ``done`` / stop / the failed-turn funnel fold it),
|
||||
condensed in its bounded 320px ``.reasoning-body`` live window (D3),
|
||||
the summary carrying the D6 status literals (``Thinking…`` /
|
||||
``Calling <name>…`` live, ``N steps`` at rest). The restore path and
|
||||
the shared page render the SAME container COLLAPSED (D5) with the
|
||||
at-rest step count (D6). A turn with no thinking and no tools creates
|
||||
NO container (D1/D4 — the byte-identical DOM).
|
||||
condensed in its bounded 320px ``.reasoning-body`` live window (D3,
|
||||
the pinned-tail follow — the window follows the live tail while the
|
||||
user stays near the bottom). The per-run blocks inside the container
|
||||
are plain divs — NOT collapsible (the D5 refinement, owner
|
||||
2026-10-06: the container is the ONE disclosure; a nested fold
|
||||
inside the fold used to hide the live reasoning mid-turn). The
|
||||
restore path and the shared page render the SAME container COLLAPSED
|
||||
(D5) with the at-rest step count (D6). A turn with no thinking and
|
||||
no tools creates NO container (D1/D4 — the byte-identical DOM).
|
||||
|
||||
KB fixture — mirrored from ``test_thinking_timeline.py`` (the
|
||||
deterministic two-document pair): the marker question cosines
|
||||
@@ -53,9 +56,9 @@ Test → phase mapping (Playwright Mapping Rule):
|
||||
``Stop`` control), the container is STILL ``open`` with a run
|
||||
visible.
|
||||
3. ``test_container_folds_at_done`` — D2/D5/D6: after ``done`` the
|
||||
container has NO ``open`` attribute, its summary is visible, EVERY
|
||||
inner run has no ``open`` attribute (the phase-158 fold-at-rest
|
||||
contract preserved inside the fold), and the status is ``3 steps``
|
||||
container has NO ``open`` attribute, its summary is visible, the
|
||||
inner runs are NOT collapsible (zero ``<details>`` inside the
|
||||
container — the D5 refinement), and the status is ``3 steps``
|
||||
(the flow's contiguous-run count — ``thinking (Step 1)``,
|
||||
``tool (ls)``, ``thinking (Step 2)`` — the D6 live-settle
|
||||
literal); the bubble carries the mid-round + closing answer.
|
||||
@@ -281,8 +284,8 @@ CONTAINER_LIVE_JS = """
|
||||
const live = st === 'Thinking…' || st.indexOf('Calling ') === 0;
|
||||
if (!live) return false;
|
||||
return !!cont.querySelector(
|
||||
'.reasoning-body details.thinking, ' +
|
||||
'.reasoning-body details.tool-calls-disclosure');
|
||||
'.reasoning-body div.thinking, ' +
|
||||
'.reasoning-body div.tool-calls-disclosure');
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -305,8 +308,8 @@ MIDROUND_STILL_OPEN_JS = """
|
||||
const cont = wrap.querySelector('details.reasoning');
|
||||
if (!cont || !cont.hasAttribute('open')) return false;
|
||||
return !!cont.querySelector(
|
||||
'.reasoning-body details.thinking, ' +
|
||||
'.reasoning-body details.tool-calls-disclosure');
|
||||
'.reasoning-body div.thinking, ' +
|
||||
'.reasoning-body div.tool-calls-disclosure');
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -322,7 +325,7 @@ TOOL_ONLY_LIVE_JS = """
|
||||
const cont = wrap.querySelector('details.reasoning');
|
||||
if (!cont || !cont.hasAttribute('open')) return false;
|
||||
return !!cont.querySelector(
|
||||
'.reasoning-body details.tool-calls-disclosure');
|
||||
'.reasoning-body div.tool-calls-disclosure');
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -567,17 +570,13 @@ def test_container_folds_at_done(
|
||||
_wait_settled(page)
|
||||
|
||||
# D2/D5: at rest the container has NO ``open`` attribute (the
|
||||
# done settle folded it) — and EVERY inner run has no ``open``
|
||||
# attribute either (the phase-158 fold-at-rest contract
|
||||
# preserved inside the fold).
|
||||
# done settle folded it) — and the inner runs are NOT
|
||||
# collapsible (zero ``<details>`` inside the container — the D5
|
||||
# refinement, owner 2026-10-06: the per-run blocks are plain
|
||||
# divs, always visible while the container is open).
|
||||
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.tool-calls-disclosure[open]"
|
||||
)
|
||||
page.locator("#messages .msg.brain details.reasoning details")
|
||||
).to_have_count(0)
|
||||
|
||||
# The collapsed summary is still visible (the bubble stays
|
||||
@@ -649,19 +648,17 @@ def test_restored_container_collapsed_with_step_count(
|
||||
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
|
||||
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
|
||||
|
||||
# The SAME per-run timeline, nested in the container (all
|
||||
# collapsed — the D5 restore convention).
|
||||
# The SAME per-run timeline, nested in the container (plain divs
|
||||
# — the D5 refinement; the at-rest fold is the container's).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning details.thinking")
|
||||
page.locator("#messages .msg.brain details.reasoning div.thinking")
|
||||
).to_have_count(2)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.thinking[open]"
|
||||
)
|
||||
page.locator("#messages .msg.brain details.reasoning details")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
|
||||
"#messages .msg.brain details.reasoning div.tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
texts = page.locator(
|
||||
@@ -737,30 +734,23 @@ def test_shared_page_container_collapsed(
|
||||
|
||||
# D5 (the shared-page half): ONE ``details.reasoning``, NO
|
||||
# ``open`` attribute (the pure-render convention — the
|
||||
# shared page is a finished conversation), wrapping the SAME
|
||||
# per-run timeline, all collapsed.
|
||||
# shared page is a finished conversation), wrapping the
|
||||
# SAME per-run timeline as plain divs (the D5 refinement —
|
||||
# the inner runs are not collapsible).
|
||||
expect(anon.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.reasoning[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.reasoning details.thinking[open]"
|
||||
)
|
||||
anon.locator("#messages .msg.brain details.reasoning details")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.reasoning "
|
||||
"details.tool-calls-disclosure[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.reasoning details.thinking")
|
||||
anon.locator("#messages .msg.brain details.reasoning div.thinking")
|
||||
).to_have_count(2)
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.reasoning "
|
||||
"details.tool-calls-disclosure"
|
||||
"div.tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
anon_texts = anon.locator(
|
||||
@@ -807,9 +797,9 @@ def test_no_reasoning_turn_has_no_container(
|
||||
_submit(page, PLAIN_ANSWER_QUESTION)
|
||||
_wait_settled(page)
|
||||
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(0)
|
||||
expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0)
|
||||
expect(page.locator("#messages .msg.brain .thinking")).to_have_count(0)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.tool-calls-disclosure")
|
||||
page.locator("#messages .msg.brain .tool-calls-disclosure")
|
||||
).to_have_count(0)
|
||||
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("")
|
||||
|
||||
@@ -851,13 +841,18 @@ def test_no_reasoning_turn_has_no_container(
|
||||
).to_have_text("1 steps")
|
||||
|
||||
# The phase-117 single-disclosure behavior, preserved and now
|
||||
# wrapped: ONE disclosure with the 3 ``.tool-call`` lines, zero
|
||||
# thinking blocks, folded at rest.
|
||||
# wrapped: ONE disclosure — a plain div (the D5 refinement —
|
||||
# not collapsible; zero ``<details>`` inside the container) with
|
||||
# the 3 ``.tool-call`` lines, zero thinking blocks, folded at
|
||||
# rest (the container's fold).
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
|
||||
"#messages .msg.brain details.reasoning div.tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning details")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .tool-call"
|
||||
@@ -869,12 +864,7 @@ def test_no_reasoning_turn_has_no_container(
|
||||
)
|
||||
).to_have_text("Tool calls (3)")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning details.thinking")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.thinking[open]"
|
||||
)
|
||||
page.locator("#messages .msg.brain details.reasoning div.thinking")
|
||||
).to_have_count(0)
|
||||
|
||||
# The phase-37 answer echo (the read document reached the model).
|
||||
|
||||
@@ -11,7 +11,7 @@ regression test (scenario 4) — the deterministic mock's ``think out loud``
|
||||
trigger (mock_llm.py) keeps all five scenarios reproducible.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_thinking_block_streams_open_then_collapses``
|
||||
1. ``test_thinking_block_streams_in_the_open_container``
|
||||
2. ``test_thinking_toggle_after_done``
|
||||
3. ``test_thinking_restored_after_reload``
|
||||
4. ``test_no_thinking_block_without_trigger``
|
||||
@@ -123,12 +123,15 @@ def send_and_wait(page: Page, question: str) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Streaming: the block attaches OPEN at the first thinking event, then
|
||||
# auto-collapses when the first answer token lands
|
||||
# 1. Streaming: the block attaches at the first thinking event (nested
|
||||
# in the open reasoning container — D1); the container STAYS OPEN
|
||||
# through the first answer token (D2) and the block is ALWAYS
|
||||
# visible (the D5 refinement — a plain div, not collapsible); the
|
||||
# container folds at done.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_thinking_block_streams_open_then_collapses(
|
||||
def test_thinking_block_streams_in_the_open_container(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
@@ -137,25 +140,35 @@ def test_thinking_block_streams_open_then_collapses(
|
||||
page.evaluate("() => document.querySelector('#composer').requestSubmit()")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(THINK_QUESTION)
|
||||
|
||||
# The block attaches at the FIRST thinking event — before any answer
|
||||
# token — and is created OPEN.
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
# The ~2 700-char thinking stream (≈4.5s, phase 21) keeps the block
|
||||
# open right after attach — assert while it is still streaming.
|
||||
expect(details).to_have_attribute("open", "")
|
||||
expect(details.locator(".thinking-text")).not_to_have_text("")
|
||||
# The block attaches at the FIRST thinking event — before any
|
||||
# answer token — nested in the container created OPEN (D1).
|
||||
block = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
block.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(container).to_have_attribute("open", "")
|
||||
# The ~2 700-char thinking stream (≈4.5s, phase 21) keeps the
|
||||
# container open right after attach — assert while it is still
|
||||
# streaming (the D5 refinement: the container is the ONE open
|
||||
# disclosure — zero <details> inside it).
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(
|
||||
page.locator(".msg.brain").last.locator("details.reasoning details")
|
||||
).to_have_count(0)
|
||||
expect(block.locator(".thinking-text")).not_to_have_text("")
|
||||
|
||||
# First answer token: the block auto-collapses and stays closed.
|
||||
# First answer token: the container STAYS OPEN (D2 — the mid-round
|
||||
# delta does not fold it; only done / stop / the failed-turn
|
||||
# funnel do).
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).not_to_have_text("", timeout=30_000)
|
||||
expect(details).not_to_have_attribute("open")
|
||||
expect(container).to_have_attribute("open", "")
|
||||
|
||||
# Settled: full scratchpad, grounded mock answer, ZERO citation
|
||||
# chips (phase 119, LOCKED A1 — the zero-read turn chips nothing;
|
||||
# the retired phase-118 A4 union is gone), and the re-enabled send
|
||||
# button.
|
||||
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
# Settled: the container FOLDS (done); the block is not
|
||||
# collapsible (a div — the D5 refinement); the full scratchpad is
|
||||
# intact, the grounded mock answer is present, ZERO citation chips
|
||||
# (phase 119, LOCKED A1), and the re-enabled send button.
|
||||
expect(container).not_to_have_attribute("open")
|
||||
expect(block.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
@@ -163,13 +176,12 @@ def test_thinking_block_streams_open_then_collapses(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Toggle: after settle the block is closed; the summary re-opens it
|
||||
# (a real keyboard-focusable control) and closes it again.
|
||||
# Phase 161 (D2): at rest the run sits INSIDE the folded reasoning
|
||||
# container — the container's summary is the at-rest focusable
|
||||
# surface (native <details> nesting semantics: a closed details
|
||||
# hides its content, so the inner summary is focusable only after
|
||||
# the container opens).
|
||||
# 2. Toggle: after settle the container is folded; its summary
|
||||
# re-opens it (a real keyboard-focusable control) and closes it
|
||||
# again. The D5 refinement (owner 2026-10-06): the inner run is a
|
||||
# plain div — NOT collapsible — so the container's summary is the
|
||||
# ONE toggle (the per-run "summary" click is gone; the block is
|
||||
# always visible once the container opens).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -179,12 +191,14 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
|
||||
send_and_wait(page, THINK_QUESTION)
|
||||
|
||||
last = page.locator(".msg.brain").last
|
||||
details = last.locator("details.thinking")
|
||||
expect(details).to_have_count(1)
|
||||
expect(details).not_to_have_attribute("open") # auto-collapsed at first token
|
||||
block = last.locator("div.thinking")
|
||||
expect(block).to_have_count(1)
|
||||
# The D5 refinement: the inner run is not a <details> at all (zero
|
||||
# <details> inside the container — the block is a plain div).
|
||||
expect(last.locator("details.reasoning details")).to_have_count(0)
|
||||
# Phase 161 (D2): the run nests in the reasoning container, which
|
||||
# folds at done (the at-rest focusable surface is the container's
|
||||
# summary — open it to reach the inner run's summary).
|
||||
# summary — the ONE toggle).
|
||||
container = last.locator("details.reasoning")
|
||||
expect(container).to_have_count(1)
|
||||
expect(container).not_to_have_attribute("open")
|
||||
@@ -193,21 +207,18 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
|
||||
container.locator("summary.reasoning-summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
|
||||
# The inner run's summary is a real, keyboard-focusable control.
|
||||
details.locator("summary").focus()
|
||||
assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY"
|
||||
|
||||
# Open: the full scratchpad is visible.
|
||||
details.locator("summary").click()
|
||||
expect(details).to_have_attribute("open", "")
|
||||
text_el = details.locator(".thinking-text")
|
||||
# Open: the full scratchpad is visible — ONE toggle (the D5
|
||||
# refinement — no second, per-run toggle; the block is always
|
||||
# visible once the container opens).
|
||||
text_el = block.locator(".thinking-text")
|
||||
expect(text_el).to_be_visible()
|
||||
expect(text_el).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(text_el).to_contain_text("nothing is invented")
|
||||
|
||||
# Closed again — user control in both directions.
|
||||
details.locator("summary").click()
|
||||
expect(details).not_to_have_attribute("open")
|
||||
# Closed again — user control in both directions (the container's
|
||||
# summary hides the always-visible block with it).
|
||||
container.locator("summary.reasoning-summary").click()
|
||||
expect(container).not_to_have_attribute("open")
|
||||
expect(text_el).not_to_be_visible()
|
||||
|
||||
|
||||
@@ -222,11 +233,12 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non
|
||||
login(page, app_url, next="/")
|
||||
send_and_wait(page, THINK_QUESTION)
|
||||
|
||||
# The live block is collapsed; capture what it shows and what the
|
||||
# turn persisted (raw text, same as what was rendered).
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
expect(details).not_to_have_attribute("open")
|
||||
captured = details.locator(".thinking-text").text_content()
|
||||
# The live block is a plain div (the D5 refinement — always visible
|
||||
# while the container is live-open); capture what it shows and what
|
||||
# the turn persisted (raw text, same as what was rendered).
|
||||
block = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
expect(block).to_have_count(1)
|
||||
captured = block.locator(".thinking-text").text_content()
|
||||
assert captured
|
||||
# The persisted raw text is the same scratchpad (renderMarkdown turns
|
||||
# the line breaks into <br>, which textContent drops — compare without
|
||||
@@ -239,9 +251,15 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non
|
||||
page.reload()
|
||||
expect(page.locator("#empty-state")).to_be_hidden()
|
||||
|
||||
restored = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
restored = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
expect(restored).to_have_count(1)
|
||||
expect(restored).not_to_have_attribute("open") # restored COLLAPSED
|
||||
# Restored COLLAPSED — the at-rest fold is the container's (the
|
||||
# D5 refinement — the inner block is not a <details> at all).
|
||||
restored_wrap = page.locator(".msg.brain").last
|
||||
expect(
|
||||
restored_wrap.locator("details.reasoning")
|
||||
).not_to_have_attribute("open")
|
||||
expect(restored_wrap.locator("details.reasoning details")).to_have_count(0)
|
||||
expect(restored.locator(".thinking-text")).to_have_text(captured)
|
||||
|
||||
# Answer bubble is intact after the restore (phase-14 restore path)
|
||||
@@ -264,7 +282,7 @@ def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb:
|
||||
send_and_wait(page, PLAIN_QUESTION)
|
||||
|
||||
# No trigger → no thinking events → no block anywhere on the page.
|
||||
expect(page.locator("details.thinking")).to_have_count(0)
|
||||
expect(page.locator(".thinking")).to_have_count(0)
|
||||
|
||||
# The turn itself is complete and grounded, exactly as before phase
|
||||
# 17 — with ZERO citation chips (phase 119, LOCKED A1: the zero-read
|
||||
@@ -293,8 +311,10 @@ def test_thinking_with_deflection(page: Page, app_url: str, seeded_kb: None) ->
|
||||
expect(chips.first).to_be_visible()
|
||||
assert chips.count() >= 2
|
||||
|
||||
# And the thinking block came along, closed, with its scratchpad.
|
||||
details = last.locator("details.thinking")
|
||||
expect(details).to_have_count(1)
|
||||
expect(details).not_to_have_attribute("open")
|
||||
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
# And the thinking block came along — a plain div (the D5
|
||||
# refinement — not collapsible; the container is the ONE fold) —
|
||||
# with its scratchpad.
|
||||
block = last.locator("div.thinking")
|
||||
expect(block).to_have_count(1)
|
||||
expect(last.locator("details.reasoning details")).to_have_count(0)
|
||||
expect(block.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
|
||||
@@ -14,11 +14,16 @@ scratchpad (phase-21 length: ``compose_thinking`` ≈ 2 700 chars ≈ 4.5s of
|
||||
|
||||
This suite REPLACES the deleted phase-21 ``test_thinking_no_scroll.py``
|
||||
(owner direction 2026-08-27, ``TODO.md`` L7, roadmap A2 — the 2026-08-24
|
||||
no-scroll choice is reversed): ``details.thinking .thinking-text`` is
|
||||
user-scrollable again (``overflow-y: auto``, 320px clip kept), and the
|
||||
phase-17 bottom-pin is GATED in app.js (``THINKING_NEAR_BOTTOM_PX = 32``)
|
||||
— the window follows the live tail only while the user is pinned near
|
||||
its bottom; scrolling up pauses the follow, returning resumes it.
|
||||
no-scroll choice is reversed): the ONE live window is the reasoning
|
||||
container's ``.reasoning-body`` (phase 161 — the per-run blocks nest
|
||||
inside it; the D5 refinement, owner 2026-10-06 — the per-run blocks
|
||||
are plain divs, always visible, and the container is the ONE
|
||||
disclosure) — user-scrollable (``overflow-y: auto``, 320px clip
|
||||
kept), and the bottom-pin is GATED in app.js (the phase-17
|
||||
``THINKING_NEAR_BOTTOM_PX = 32`` band, reused on the container
|
||||
window as ``REASONING_NEAR_BOTTOM_PX``) — the window follows the live
|
||||
tail only while the user is pinned near its bottom; scrolling up
|
||||
pauses the follow, returning resumes it.
|
||||
|
||||
Determinism notes:
|
||||
* Tests 1–4 key off the mock's ``think out loud then hesitate`` trigger
|
||||
@@ -119,7 +124,11 @@ THINKING_FRAGMENT = "Step 2: Check my notes"
|
||||
PARAS_QUESTION = "think in paragraphs — how is my kubernetes cluster set up?"
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
|
||||
SELECTOR = ".msg.brain details.thinking .thinking-text"
|
||||
#: The ONE live window: the reasoning container's bounded 320px
|
||||
#: ``.reasoning-body`` (phase 161 — the per-run blocks nest inside
|
||||
#: it; the D5 refinement — the per-run blocks are plain divs, always
|
||||
#: visible; the container is the ONE disclosure).
|
||||
SELECTOR = ".msg.brain details.reasoning .reasoning-body"
|
||||
|
||||
#: The story's "within 1px" tolerance for tail-pin claims.
|
||||
TAIL_TOL = 1
|
||||
@@ -264,11 +273,14 @@ def _rendered_len(page: Page) -> int:
|
||||
def _first_text_node(page: Page) -> dict[str, Any]:
|
||||
"""Rect of the scratchpad's FIRST text node vs the window's box — the
|
||||
"earlier content is visible" probe (the mock's text starts with
|
||||
``Step 1: Read the question carefully``)."""
|
||||
``Step 1: Read the question carefully``; the probe walks the
|
||||
scratchpad's own first ``.thinking-text`` — not the window's raw
|
||||
first node, which is the "Thinking" label)."""
|
||||
return page.evaluate(
|
||||
f"""() => {{ const el = document.querySelector('{SELECTOR}');
|
||||
const box = el.getBoundingClientRect();
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
const t = el.querySelector('.thinking-text');
|
||||
const walker = document.createTreeWalker(t, NodeFilter.SHOW_TEXT);
|
||||
const first = walker.nextNode();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(first);
|
||||
@@ -293,11 +305,11 @@ def test_thinking_window_user_scrollable(
|
||||
submit(page, HESITATE_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Premise: the long scratchpad overflows the 320px clip (the story's
|
||||
# "window" is only real once it clips).
|
||||
# Premise: the long scratchpad overflows the 320px clip (the
|
||||
# story's "window" is only real once it clips).
|
||||
page.wait_for_function(
|
||||
f"() => {{ const el = document.querySelector('{SELECTOR}');"
|
||||
" return !!el && el.scrollHeight > el.clientHeight; }",
|
||||
@@ -308,7 +320,7 @@ def test_thinking_window_user_scrollable(
|
||||
# pin frames, the block is still open, and the tail is frozen — any
|
||||
# user scroll persists and is observable here.
|
||||
_wait_text_stable(page)
|
||||
expect(details).to_have_attribute("open", "")
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
|
||||
# Precondition: the gated pin left the window at the live tail.
|
||||
@@ -316,7 +328,7 @@ def test_thinking_window_user_scrollable(
|
||||
assert before["height"] > before["client"], "the window must overflow"
|
||||
assert _at_tail(before), "the pin must have left the window at the tail"
|
||||
|
||||
box = details.locator(".thinking-text").bounding_box()
|
||||
box = container.locator(".reasoning-body").bounding_box()
|
||||
assert box
|
||||
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
|
||||
page.mouse.move(cx, cy)
|
||||
@@ -337,7 +349,7 @@ def test_thinking_window_user_scrollable(
|
||||
# it so (test scaffolding), give it REAL focus with a click, and press
|
||||
# Home: the window must jump to its top (proven 1:1 on headless
|
||||
# Chromium 151: click + Home -> 0, End -> maxScroll).
|
||||
details.locator(".thinking-text").evaluate(
|
||||
container.locator(".reasoning-body").evaluate(
|
||||
"el => { el.setAttribute('tabindex', '0'); }"
|
||||
)
|
||||
page.mouse.click(cx, cy)
|
||||
@@ -376,8 +388,9 @@ def test_thinking_window_user_scrollable(
|
||||
)
|
||||
assert not _at_tail(after), "the window must stay scrolled up (no re-pin)"
|
||||
|
||||
# Still inside the pure-thinking window.
|
||||
expect(details).to_have_attribute("open", "")
|
||||
# Still inside the live-thinking window (the container is the ONE
|
||||
# open disclosure — the D5 refinement).
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
|
||||
|
||||
@@ -396,8 +409,8 @@ def test_thinking_window_follows_while_pinned(
|
||||
submit(page, HESITATE_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# The exact deterministic scratchpad the mock will stream, sliced the
|
||||
# same way the mock's _sse_stream does (12-char chunks). The user
|
||||
@@ -424,7 +437,7 @@ def test_thinking_window_follows_while_pinned(
|
||||
# the block still open — sample the tail pin, then the geometry.
|
||||
_wait_text_contains(page, marker_last)
|
||||
_wait_text_stable(page)
|
||||
expect(details).to_have_attribute("open", "")
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
sample = _scroll_sample(page)
|
||||
assert _at_tail(sample), f"not at the tail after the last chunk: {sample}"
|
||||
@@ -467,8 +480,8 @@ def test_thinking_window_stops_on_scroll_up(
|
||||
submit(page, HESITATE_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Mid-stream: the window has REAL headroom (≥100px of hidden content
|
||||
# — the first overflowing frame, a few px, is not the story's
|
||||
@@ -508,9 +521,10 @@ def test_thinking_window_stops_on_scroll_up(
|
||||
f"{top0} -> {after['top']}"
|
||||
)
|
||||
|
||||
# Still inside the pure-thinking window (the stability claim is only
|
||||
# meaningful while the block is open and no content frame has landed).
|
||||
expect(details).to_have_attribute("open", "")
|
||||
# Still inside the live-thinking window (the stability claim is only
|
||||
# meaningful while the container is open and no content frame has
|
||||
# landed).
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
|
||||
|
||||
@@ -528,8 +542,8 @@ def test_thinking_window_resumes_on_return(
|
||||
submit(page, HESITATE_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Mid-stream: real window headroom (≥100px) + pinned tail (test-3
|
||||
# premises).
|
||||
@@ -581,8 +595,8 @@ def test_thinking_window_resumes_on_return(
|
||||
assert _at_tail(after), f"the window must be re-pinned on the next chunk: {after}"
|
||||
assert after["top"] > top0, "the window must have returned toward the tail"
|
||||
|
||||
# Still inside the pure-thinking window.
|
||||
expect(details).to_have_attribute("open", "")
|
||||
# Still inside the live-thinking window.
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
|
||||
|
||||
@@ -599,10 +613,10 @@ def test_thinking_window_css_contract(
|
||||
login(page, app_url, next="/")
|
||||
send_and_wait(page, THINK_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
expect(details).not_to_have_attribute("open") # auto-collapsed
|
||||
details.locator("summary").click() # open for measurement
|
||||
expect(details).to_have_attribute("open", "")
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(container).not_to_have_attribute("open") # folded at done
|
||||
container.locator("summary.reasoning-summary").click() # open for measurement
|
||||
expect(container).to_have_attribute("open", "")
|
||||
|
||||
style = page.evaluate(
|
||||
f"""() => {{ const el = document.querySelector('{SELECTOR}');
|
||||
@@ -694,23 +708,29 @@ def test_restored_collapsed_thinking_unaffected(
|
||||
login(page, app_url, next="/")
|
||||
send_and_wait(page, THINK_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
expect(details).not_to_have_attribute("open") # auto-collapsed
|
||||
captured = details.locator(".thinking-text").text_content()
|
||||
block = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
expect(block).to_have_count(1)
|
||||
captured = block.locator(".thinking-text").text_content()
|
||||
assert captured
|
||||
|
||||
page.reload()
|
||||
expect(page.locator("#empty-state")).to_be_hidden()
|
||||
|
||||
restored = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
restored = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
expect(restored).to_have_count(1)
|
||||
expect(restored).not_to_have_attribute("open") # restored COLLAPSED
|
||||
# Restored COLLAPSED — the at-rest fold is the container's (the
|
||||
# D5 refinement — the inner block is a plain div, not a <details>).
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(container).not_to_have_attribute("open")
|
||||
expect(
|
||||
page.locator(".msg.brain").last.locator("details.reasoning details")
|
||||
).to_have_count(0)
|
||||
expect(restored.locator(".thinking-text")).to_have_text(captured)
|
||||
|
||||
# Opening the restored block still shows the full scratchpad, and the
|
||||
# answer + persistence are intact.
|
||||
restored.locator("summary").click()
|
||||
expect(restored).to_have_attribute("open", "")
|
||||
# Opening the restored container still shows the full scratchpad,
|
||||
# and the answer + persistence are intact.
|
||||
container.locator("summary.reasoning-summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
raw = json.loads(page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')"))[
|
||||
@@ -739,8 +759,8 @@ def test_thinking_window_follows_across_paragraph_breaks(
|
||||
submit(page, PARAS_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(PARAS_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Two preconditions, in one poll: the window is REAL (content past
|
||||
# the 320px clip — a "window" only exists once it clips) AND the
|
||||
@@ -773,13 +793,13 @@ def test_thinking_window_follows_across_paragraph_breaks(
|
||||
# paragraph break is present, and the pin survived it.
|
||||
sample = page.evaluate(
|
||||
f"""() => {{ const el = document.querySelector('{SELECTOR}');
|
||||
const block = document.querySelector('details.thinking');
|
||||
const block = document.querySelector('details.reasoning');
|
||||
const wrap = block ? block.closest('.msg.brain') : null;
|
||||
const bubble = wrap ? wrap.querySelector('.bubble') : null;
|
||||
return {{ top: el.scrollTop, height: el.scrollHeight,
|
||||
client: el.clientHeight,
|
||||
p: el.querySelectorAll('p').length,
|
||||
open: !!(block && block.open),
|
||||
open: !!(block && block.hasAttribute('open')),
|
||||
bubble: bubble ? bubble.innerText.length : 0 }}; }}"""
|
||||
)
|
||||
assert sample["p"] >= 2, "a real paragraph break must be in the scratchpad"
|
||||
@@ -796,5 +816,5 @@ def test_thinking_window_follows_across_paragraph_breaks(
|
||||
# disabled, so to_be_enabled no longer blocks until the turn ends).
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
expect(details.locator(".thinking-text")).to_contain_text("Step 3")
|
||||
expect(container.locator(".thinking-text").first).to_contain_text("Step 3")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
|
||||
@@ -21,17 +21,18 @@ shape (list → drill → read → the quoted answer) with a DISTINCT
|
||||
``reasoning_content`` run streamed AHEAD of every tool call and ahead
|
||||
of the final answer — the ``Step N`` markers make each run
|
||||
individually assertable. The client renders each CONTIGUOUS run in its
|
||||
own ``details.thinking`` / ``details.tool-calls-disclosure`` (D1), all
|
||||
above the answer bubble in arrival order (D2 — Option B,
|
||||
owner-chosen), open while the run's frames stream and folded at rest
|
||||
(D5), and persists the run order in the record's ``segments`` array
|
||||
(D4). Phase 161 (the documented supersession, owner TODO direction
|
||||
2026-10-05): the per-run blocks are NESTED inside ONE
|
||||
own per-run block (D1) in arrival order (D2 — Option B,
|
||||
owner-chosen) and persists the run order in the record's ``segments``
|
||||
array (D4). Phase 161 (the documented supersession, owner TODO
|
||||
direction 2026-10-05): the per-run blocks are NESTED inside ONE
|
||||
``details.reasoning`` container (``.reasoning-body``) that sits
|
||||
before the bubble — open during the turn, folded at done / on
|
||||
restore / on the shared page; the per-run open/fold behavior itself
|
||||
is unchanged. The sequence pin is shape-unchanged, read from the
|
||||
container's ``.reasoning-body`` children (the container is the one
|
||||
restore / on the shared page. The D5 refinement (owner 2026-10-06):
|
||||
the per-run blocks are plain divs — NOT collapsible (the container
|
||||
is the ONE disclosure; a nested fold inside the fold used to hide
|
||||
the live reasoning mid-turn), always visible while the turn is live.
|
||||
The sequence pin is shape-unchanged, read from the container's
|
||||
``.reasoning-body`` children (the container is the one
|
||||
new parent — the pre-bubble ``.msg-body`` children are exactly
|
||||
``["details.reasoning"]``).
|
||||
|
||||
@@ -54,15 +55,17 @@ Test → phase mapping (Playwright Mapping Rule):
|
||||
one lump); each disclosure summary is ``Tool call (1)``; the
|
||||
answer bubble carries the ``Read <source/path>`` echo.
|
||||
2. ``test_runs_fold_at_rest`` — D5 (phase 161 D2): after ``done``
|
||||
the container AND every ``.thinking`` /
|
||||
``.tool-calls-disclosure`` inside it has NO ``open`` attribute
|
||||
(the settle folded the runs + the container); the at-rest visible
|
||||
line is the container's ``Reasoning`` summary (``7 steps``);
|
||||
opening it reveals the folded run summaries.
|
||||
the container has NO ``open`` attribute (the settle folded it) and
|
||||
the inner runs are NOT collapsible (zero ``<details>`` inside the
|
||||
container — the D5 refinement); the at-rest visible line is the
|
||||
container's ``Reasoning`` summary (``7 steps``); opening it
|
||||
reveals the always-visible run blocks.
|
||||
3. ``test_live_runs_open_while_streaming`` — D5 (the live half):
|
||||
DURING the turn (the mock's pacing window — the 0.1 s tool frames +
|
||||
the thinking stream, the answer bubble still EMPTY) the active run's
|
||||
block is ``open`` (the live feedback the owner keeps).
|
||||
the thinking stream, the answer bubble still EMPTY) the container
|
||||
is ``open`` with a run visible in its ``.reasoning-body`` (the
|
||||
live feedback the owner keeps — the container is the ONE open
|
||||
disclosure).
|
||||
4. ``test_restored_timeline_and_record`` — the restore path + D4: a
|
||||
same-context RELOAD re-renders the same 7-element sequence in ONE
|
||||
collapsed reasoning container (``7 steps``, phase 161 D5/D6), and
|
||||
@@ -75,8 +78,9 @@ Test → phase mapping (Playwright Mapping Rule):
|
||||
5. ``test_shared_page_timeline`` — the shared-page parity (phase 51
|
||||
save + share flow): a FRESH context on ``/shared/<token>`` renders
|
||||
the same 7-element sequence in ONE collapsed reasoning container
|
||||
(``addSegments``), the ``Step N`` markers visible on container +
|
||||
summary click (expand ONE each), the answer bubble carries the
|
||||
(``addSegments``), the ``Step N`` markers visible on container
|
||||
click (the D5 refinement — the inner blocks are always visible
|
||||
once the container opens), the answer bubble carries the
|
||||
``Read <source/path>`` echo.
|
||||
6. ``test_no_thinking_turn_single_disclosure`` — the run-grouping
|
||||
contract for no-thinking turns (the phase-117 behavior preserved,
|
||||
@@ -192,13 +196,13 @@ STEP_MARKERS = ("Step 1", "Step 2", "Step 3", "Step 4")
|
||||
#: sequence is shape-unchanged — the container is the one new parent)
|
||||
#: — tests 1/4 (chat page) and 5 (shared page) pin it.
|
||||
TIMELINE_SEQ = [
|
||||
"details.thinking",
|
||||
"details.tool-calls-disclosure",
|
||||
"details.thinking",
|
||||
"details.tool-calls-disclosure",
|
||||
"details.thinking",
|
||||
"details.tool-calls-disclosure",
|
||||
"details.thinking",
|
||||
"div.thinking",
|
||||
"div.tool-calls-disclosure",
|
||||
"div.thinking",
|
||||
"div.tool-calls-disclosure",
|
||||
"div.thinking",
|
||||
"div.tool-calls-disclosure",
|
||||
"div.thinking",
|
||||
]
|
||||
|
||||
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||
@@ -325,18 +329,23 @@ BODY_SEQUENCE_JS = """
|
||||
}
|
||||
"""
|
||||
|
||||
#: D5's live-open predicate — the active run's block is ``open`` while
|
||||
#: the answer bubble is STILL EMPTY (the turn is in flight; the mock's
|
||||
#: pacing window — the 0.1 s tool frames + the thinking stream — keeps
|
||||
#: the window comfortably open for a 100 ms poll).
|
||||
#: D5's live predicate (the D5 refinement — the container is the ONE
|
||||
#: open disclosure): the container is ``open`` with a per-run block
|
||||
#: nested in its ``.reasoning-body`` while the answer bubble is STILL
|
||||
#: EMPTY (the turn is in flight; the mock's pacing window — the 0.1 s
|
||||
#: tool frames + the thinking stream — keeps the window comfortably
|
||||
#: open for a 100 ms poll).
|
||||
LIVE_OPEN_JS = """
|
||||
() => {
|
||||
const wrap = document.querySelector('#messages .msg.brain');
|
||||
if (!wrap) return false;
|
||||
const bubble = wrap.querySelector('.msg-body .bubble');
|
||||
if (!bubble || bubble.textContent !== '') return false;
|
||||
return !!wrap.querySelector(
|
||||
'details.thinking[open], details.tool-calls-disclosure[open]');
|
||||
const cont = wrap.querySelector('details.reasoning');
|
||||
if (!cont || !cont.hasAttribute('open')) return false;
|
||||
return !!cont.querySelector(
|
||||
'.reasoning-body div.thinking, ' +
|
||||
'.reasoning-body div.tool-calls-disclosure');
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -540,25 +549,23 @@ def test_runs_fold_at_rest(
|
||||
page.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("7 steps")
|
||||
|
||||
# D5: at rest NOTHING inside is open — the ``done`` settle
|
||||
# (closeAllRuns) folded every run (a closed <details> has no
|
||||
# ``open`` attribute).
|
||||
# D5 refinement (owner 2026-10-06): the inner runs are NOT
|
||||
# collapsible — ZERO <details> inside the container (the
|
||||
# per-run blocks are plain divs, always visible while the
|
||||
# container is open).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.tool-calls-disclosure[open]"
|
||||
)
|
||||
page.locator("#messages .msg.brain details.reasoning details")
|
||||
).to_have_count(0)
|
||||
|
||||
# The inner runs stay in the DOM inside the fold — opening the
|
||||
# container (the native toggle) reveals the 4 "Thinking" + 3
|
||||
# "Tool call (1)" lines (the timeline stays readable at rest).
|
||||
# "Tool call (1)" labels (the timeline stays readable at rest;
|
||||
# the D5 refinement — no second toggle: the blocks are always
|
||||
# visible once the container opens).
|
||||
page.locator("#messages .msg.brain .reasoning-summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking > summary").first
|
||||
page.locator("#messages .msg.brain .thinking .thinking-summary").first
|
||||
).to_be_visible()
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-summary").first
|
||||
@@ -652,12 +659,7 @@ def test_restored_timeline_and_record(
|
||||
f"the restored in-container sequence is not the timeline: {seq}"
|
||||
)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.tool-calls-disclosure[open]"
|
||||
)
|
||||
page.locator("#messages .msg.brain details.reasoning details")
|
||||
).to_have_count(0)
|
||||
# The restored answer (the restore is synchronous at boot).
|
||||
expect(
|
||||
@@ -778,24 +780,17 @@ def test_shared_page_timeline(
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the shared in-container sequence is not the timeline: {seq}"
|
||||
)
|
||||
# D5 refinement (owner 2026-10-06): the inner runs are NOT
|
||||
# collapsible — ZERO <details> inside the container (the
|
||||
# per-run blocks are plain divs).
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.tool-calls-disclosure[open]"
|
||||
)
|
||||
anon.locator("#messages .msg.brain details.reasoning details")
|
||||
).to_have_count(0)
|
||||
|
||||
# The ``Step N`` markers are visible on a summary click (expand
|
||||
# ONE — the native <summary> is a real focusable toggle; phase
|
||||
# 161: the container's own summary first — the inner runs'
|
||||
# summaries sit inside its closed fold).
|
||||
# The ``Step N`` markers are visible on the container's summary
|
||||
# click (the D5 refinement — ONE toggle: the inner blocks are
|
||||
# always visible once the container opens; no second toggle).
|
||||
anon.locator("#messages .msg.brain .reasoning-summary").click()
|
||||
anon.locator("#messages .msg.brain details.thinking > summary").first.click()
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.thinking").first
|
||||
).to_have_attribute("open")
|
||||
first_text = anon.locator("#messages .msg.brain .thinking-text").first
|
||||
expect(first_text).to_be_visible()
|
||||
expect(first_text).to_contain_text(STEP_MARKERS[0])
|
||||
|
||||
@@ -10,12 +10,18 @@ item breaking mid-word (``Rea``/``ding``) and the path wrapping to
|
||||
three lines indented to the right of that broken label.
|
||||
|
||||
Phase 117's fix (frontend-only, D1–D6 in ``00_phase.md``): the
|
||||
per-call lines ride in ONE native ``<details>`` disclosure — a compact
|
||||
"Tool calls (N)" summary, open while the turn is live and FOLDED when
|
||||
the answer begins (the first ``delta``), on ``done``, on stop/abort,
|
||||
and on restore — and the lines themselves are deboxed inline-flow
|
||||
text (no card, no flex: the label + ``<code>`` run as one continuous
|
||||
sentence and the path wraps to the left edge).
|
||||
per-call lines ride in ONE compact "Tool calls (N)" disclosure, open
|
||||
while the turn is live and FOLDED when the answer begins (the first
|
||||
``delta``), on ``done``, on stop/abort, and on restore — and the lines
|
||||
themselves are deboxed inline-flow text (no card, no flex: the label
|
||||
+ ``<code>`` run as one continuous sentence and the path wraps to the
|
||||
left edge). Phase 161 (D2): the disclosure sits INSIDE the ONE
|
||||
reasoning container (``details.reasoning``), which is the only
|
||||
collapsible. The D5 refinement (owner 2026-10-06) goes one step
|
||||
further: the per-run disclosure is now a PLAIN div (no ``<details>``)
|
||||
— always visible while the container is open; the container is the
|
||||
ONE disclosure (open while the turn is live, folded at done/stop/
|
||||
restore).
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
@@ -41,22 +47,26 @@ already folded (phase 117, D3: the fold rides the first delta).
|
||||
|
||||
Test → phase mapping (Playwright Mapping Rule):
|
||||
1. ``test_tool_calls_fold_to_one_line_after_turn`` — pin 1: a
|
||||
completed 3-call turn renders ONE visible "Tool calls (3)" summary;
|
||||
the disclosure is CLOSED (no ``open`` attribute); the three
|
||||
``.tool-call`` lines are present in the DOM but folded (the first
|
||||
line is not visible); the answer is present.
|
||||
completed 3-call turn renders ONE visible "Tool calls (3)" summary
|
||||
(revealed by opening the container — the ONE disclosure, folded at
|
||||
rest); the in-container runs are plain divs (zero ``<details>``
|
||||
nested in the container — the D5 refinement); the three
|
||||
``.tool-call`` lines are present in the DOM but hidden (the
|
||||
container is folded); the answer is present.
|
||||
2. ``test_summary_click_expands_the_calls`` — pin 2: tapping the
|
||||
native ``<summary>`` (a real focusable toggle, AA) opens the
|
||||
disclosure; the three lines become visible, in order, with the
|
||||
phase-37/94 pinned text.
|
||||
container's native ``<summary>`` (the ONE real focusable toggle,
|
||||
AA) opens the container; the three lines become visible, in order,
|
||||
with the phase-37/94 pinned text.
|
||||
3. ``test_expanded_line_is_deboxed_inline_flow`` — pin 3: the
|
||||
expanded "Reading" line's computed ``display`` is NOT ``flex``
|
||||
(the label + path are one inline run, not two flex items — the
|
||||
debox), and the label runs directly into the path in the same run.
|
||||
4. ``test_restored_turn_renders_folded`` — pin 4: a same-context
|
||||
RELOAD (the phase-14/50 persisted conversation restores) renders
|
||||
the turn FOLDED — closed disclosure, "Tool calls (3)" summary,
|
||||
three lines in the DOM, first line hidden — no auto-expand on load.
|
||||
the turn FOLDED — folded container, plain div runs (zero nested
|
||||
``<details>``), "Tool calls (3)" summary (revealed by opening the
|
||||
container), three lines in the DOM, first line hidden — no
|
||||
auto-expand on load.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -232,11 +242,13 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _disclosure_open(page: Page) -> bool:
|
||||
"""The disclosure's open state: a closed ``<details>`` has NO
|
||||
def _container_open(page: Page) -> bool:
|
||||
"""The container's open state: a closed ``<details>`` has NO
|
||||
``open`` attribute (``get_attribute`` → ``None``); an open one
|
||||
carries it (any value — the native boolean attribute is empty)."""
|
||||
return page.get_attribute(DISCLOSURE, "open") is not None
|
||||
carries it (any value — the native boolean attribute is empty).
|
||||
Phase 161 (D2): the container is the ONE disclosure — the
|
||||
per-run disclosure is a plain div (the D5 refinement)."""
|
||||
return page.get_attribute(REASONING, "open") is not None
|
||||
|
||||
|
||||
def _submit_tools_turn(page: Page, app_url: str) -> None:
|
||||
@@ -264,14 +276,11 @@ def _submit_tools_turn(page: Page, app_url: str) -> None:
|
||||
|
||||
|
||||
def _expand_disclosure(page: Page) -> None:
|
||||
"""Tap the summary — the native ``<summary>`` is a real focusable
|
||||
toggle (AA), so a plain click is the user contract. Phase 161
|
||||
(D2): at rest the disclosure sits INSIDE the folded reasoning
|
||||
container, so the user flow is two native toggles — open the
|
||||
container first (its own summary — a direct child, so the inner
|
||||
runs' summaries are not matched), then the disclosure's summary."""
|
||||
"""Tap the container's summary — the native ``<summary>`` is a
|
||||
real focusable toggle (AA), so a plain click is the user
|
||||
contract. The D5 refinement: ONE toggle — the per-run disclosure
|
||||
is a plain div, always visible while the container is open."""
|
||||
page.locator(f"{REASONING} > summary").click()
|
||||
page.locator(SUMMARY).click()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -301,6 +310,11 @@ def test_tool_calls_fold_to_one_line_after_turn(
|
||||
container = page.locator(REASONING)
|
||||
expect(container).to_have_count(1)
|
||||
expect(container).not_to_have_attribute("open")
|
||||
# Folded at rest: the record's lines are hidden (the D5 refinement —
|
||||
# the at-rest fold is the container's; the per-run runs are plain
|
||||
# divs that render on reveal).
|
||||
expect(page.locator(LINES)).to_have_count(3)
|
||||
expect(page.locator(LINES).first).not_to_be_visible()
|
||||
page.locator(f"{REASONING} > summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
|
||||
@@ -309,13 +323,15 @@ def test_tool_calls_fold_to_one_line_after_turn(
|
||||
expect(summary).to_be_visible()
|
||||
expect(summary).to_contain_text("Tool calls (3)")
|
||||
|
||||
# Folded at rest: a closed <details> carries no `open` attribute.
|
||||
assert page.get_attribute(DISCLOSURE, "open") is None
|
||||
# The D5 refinement: the in-container runs are plain divs — the
|
||||
# per-run disclosure is NOT a <details> (the container is the ONE
|
||||
# disclosure).
|
||||
expect(page.locator(f"{REASONING} details")).to_have_count(0)
|
||||
|
||||
# The lines are present in the DOM (the permanent record) but
|
||||
# folded: the FIRST line is not rendered.
|
||||
# The lines are present in the DOM (the permanent record) and
|
||||
# render on reveal — always visible inside the open container (D5).
|
||||
expect(page.locator(LINES)).to_have_count(3)
|
||||
expect(page.locator(LINES).first).not_to_be_visible()
|
||||
expect(page.locator(LINES).first).to_be_visible()
|
||||
|
||||
# The answer is present (the mock's deterministic single-read quote).
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
|
||||
@@ -339,13 +355,13 @@ def test_summary_click_expands_the_calls(
|
||||
page.set_default_timeout(30_000)
|
||||
_reset_db(_seed)
|
||||
_submit_tools_turn(page, app_url)
|
||||
assert not _disclosure_open(page) # folded after the turn (pin 1)
|
||||
assert not _container_open(page) # folded after the turn (pin 1)
|
||||
|
||||
_expand_disclosure(page)
|
||||
|
||||
# The native boolean attribute is now present (any value — it is
|
||||
# empty in the DOM; "true" is the attribute-present shorthand).
|
||||
assert page.get_attribute(DISCLOSURE, "open") is not None
|
||||
assert _container_open(page)
|
||||
|
||||
# All three lines, in order, visible with their pinned text.
|
||||
lines = page.locator(LINES)
|
||||
@@ -447,15 +463,17 @@ def test_restored_turn_renders_folded(
|
||||
expect(container.locator("> summary")).to_be_visible()
|
||||
expect(container.locator("> summary")).to_contain_text("Reasoning")
|
||||
|
||||
# The disclosure is CLOSED (no auto-expand on load) — and the
|
||||
# in-container record is revealed by opening the container (the
|
||||
# two-native-toggle user flow).
|
||||
assert page.get_attribute(DISCLOSURE, "open") is None
|
||||
# No auto-expand on load (the container is folded) — and the
|
||||
# in-container record is revealed by opening the ONE toggle.
|
||||
assert not _container_open(page)
|
||||
# The D5 refinement: the restored runs are plain divs (no nested
|
||||
# <details>).
|
||||
expect(page.locator(f"{REASONING} details")).to_have_count(0)
|
||||
page.locator(f"{REASONING} > summary").click()
|
||||
expect(page.locator(SUMMARY)).to_be_visible()
|
||||
expect(page.locator(SUMMARY)).to_contain_text("Tool calls (3)")
|
||||
|
||||
# The three lines are in the DOM (the permanent record) but folded:
|
||||
# the first line is not rendered — no auto-expand.
|
||||
# The three lines are in the DOM (the permanent record) and render
|
||||
# on reveal — always visible inside the open container (D5).
|
||||
expect(page.locator(LINES)).to_have_count(3)
|
||||
expect(page.locator(LINES).first).not_to_be_visible()
|
||||
expect(page.locator(LINES).first).to_be_visible()
|
||||
|
||||
@@ -935,27 +935,46 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// is no longer "processing"; settle its "(Ns)" suffix (the
|
||||
// visible indication moves to the thinking block).
|
||||
settleToolLine();
|
||||
// Phase 17: model reasoning — stream it live into the collapsible
|
||||
// Thinking block. No setUiState here: the UI state stays
|
||||
// Phase 17: model reasoning — stream it live into the per-run
|
||||
// Thinking block (the D5 refinement, owner 2026-10-06 — a
|
||||
// plain div, always visible; the container is the ONE
|
||||
// collapsible). No setUiState here: the UI state stays
|
||||
// "thinking" (the button stays the enabled "Stop" control —
|
||||
// phase 48 — #send-status unchanged) — the live block simply
|
||||
// replaces the typing dots as the visible feedback.
|
||||
// Phase 158 (D1, owner direction 2026-10-04): PER-RUN blocks —
|
||||
// the D15 "re-open the one shared block" is superseded. Each
|
||||
// CONTIGUOUS thinking run streams into its OWN
|
||||
// details.thinking (ensureThinkingRun: a frame of a new kind
|
||||
// closes the previous run and opens a new block; D5 — the new
|
||||
// block is OPEN while its frames stream). A post-delta
|
||||
// thinking frame (the next agent round: the model answered,
|
||||
// called a tool, then thinks again) lands in a NEW block above
|
||||
// the answer — not a re-open of the block that sits on top of
|
||||
// it (the owner's "thinking resumed above the answer" awkwardness
|
||||
// is gone; the runs stay in arrival order, D2).
|
||||
// CONTIGUOUS thinking run streams into its OWN block
|
||||
// (ensureThinkingRun: a frame of a new kind ends the previous
|
||||
// run and opens a new block — always visible inside the
|
||||
// container; D5 refinement). A post-delta thinking frame (the
|
||||
// next agent round: the model answered, called a tool, then
|
||||
// thinks again) lands in a NEW block — not a re-open of the
|
||||
// block that sits on top of it (the owner's "thinking resumed
|
||||
// above the answer" awkwardness is gone; the runs stay in
|
||||
// arrival order, D2).
|
||||
thinkingAcc += ev.text || "";
|
||||
sawThinking = true;
|
||||
clearTurnTimeout(); // the stream is alive — as the first delta says
|
||||
if (!wrap) wrap = addMessage("brain", "");
|
||||
removeTyping(); // the live block replaces the dots as feedback
|
||||
// Phase 161 (D3): the container window's PRE-update pin
|
||||
// capture — BEFORE ensureThinkingRun appends the (new) block:
|
||||
// the append's own height growth must not leak into the
|
||||
// "pre" reading (an append taller than the 32px band would
|
||||
// read as "the user scrolled up" and kill the follow at the
|
||||
// first frame of every run after the first — the D5
|
||||
// refinement, owner 2026-10-06, exposed the ordering defect:
|
||||
// the old details collapse used to reset the window to a
|
||||
// fitting state, masking it). The phase-17 pre-measure
|
||||
// convention, verbatim.
|
||||
// convention, verbatim. A turn's FIRST frame (the container
|
||||
// did not exist yet — reasoningWrapEl is null) is always
|
||||
// pinned: the fresh window starts at its bottom (st=0), and
|
||||
// a first chunk taller than the 320px window must still
|
||||
// land on the tail, not the top.
|
||||
const rbody = reasoningWrapEl?.querySelector(".reasoning-body");
|
||||
const rPinned = rbody ? isReasoningPinned(rbody) : true;
|
||||
const block = ensureThinkingRun(wrap);
|
||||
// Phase 161 (D6): the container's live status — "Thinking…"
|
||||
// while a thinking frame streams (a tool frame overwrites it
|
||||
@@ -970,18 +989,17 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// grows the window's content below the old bottom, so measuring
|
||||
// the distance AFTER the update reads the chunk's height, not
|
||||
// the user's position, and the follow died at the first
|
||||
// 2-newline gap. Pre-update, the distance is where the user
|
||||
// actually is. Phase 158: the same pin logic, now operating on
|
||||
// THIS block (per-run — each run's block pins its own tail;
|
||||
// the pre-update capture is verbatim).
|
||||
// 2-newline gap. The container capture above is taken BEFORE
|
||||
// the block append for the same reason (the D5 refinement's
|
||||
// exposed ordering defect). Phase 158: the same pin logic, now
|
||||
// operating on THIS block (per-run — each run's block pins its
|
||||
// own tail; the pre-update capture is verbatim). The D5
|
||||
// refinement (owner 2026-10-06): the in-container block is a
|
||||
// plain div (block.open is undefined → the gate is false) and
|
||||
// its .thinking-text is not scrollable, so this capture
|
||||
// no-ops there — the container window's follow below is the
|
||||
// live scroll surface.
|
||||
const pinned = block.open && isThinkingNearBottom(textEl);
|
||||
// Phase 161 (D3): the container window's PRE-update pin
|
||||
// capture — the phase-17 pre-measure convention, BEFORE the
|
||||
// re-render grows the content (the inner per-block follow
|
||||
// above stays — inside the container the .thinking-text is
|
||||
// no longer scrollable, so it no-ops there; task 03).
|
||||
const rbody = reasoningWrapEl?.querySelector(".reasoning-body");
|
||||
const rPinned = !!rbody && isReasoningPinned(rbody);
|
||||
textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe
|
||||
if (pinned) {
|
||||
// Follow the live tail only while the user was pinned to the
|
||||
@@ -1063,24 +1081,37 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
?.setAttribute("aria-label", toolStatus);
|
||||
}
|
||||
// Phase 158 (D1): PER-RUN disclosure — each CONTIGUOUS tool-call
|
||||
// run streams into its OWN details.tool-calls-disclosure
|
||||
// (ensureToolRun: a frame of a new kind closes the previous run
|
||||
// and opens a new disclosure; D5 — OPEN while its frames stream),
|
||||
// inserted BEFORE the bubble in arrival order (D2). The record
|
||||
// run streams into its OWN block (ensureToolRun: a frame of a
|
||||
// new kind ends the previous run and opens a new disclosure —
|
||||
// the D5 refinement, owner 2026-10-06: a plain div, always
|
||||
// visible inside the container; the container is the ONE
|
||||
// collapsible), inserted in arrival order (D2). The record
|
||||
// rides the run's `records` too — the SAME toolAcc objects, so
|
||||
// the phase-95 tool_result stamp (truncated / chars_shown /
|
||||
// chars_total) flows into the segments payload with no extra
|
||||
// wiring.
|
||||
// Phase 161 (D3): the container window's PRE-update pin
|
||||
// capture — BEFORE ensureToolRun appends the (new) block:
|
||||
// the append's own height growth must not leak into the
|
||||
// "pre" reading (an append taller than the 32px band would
|
||||
// read as "the user scrolled up" and kill the follow — the
|
||||
// D5 refinement, owner 2026-10-06, exposed the ordering
|
||||
// defect: the old details collapse used to reset the window
|
||||
// to a fitting state, masking it). The phase-17 pre-measure
|
||||
// convention, verbatim.
|
||||
// convention, verbatim. A turn's FIRST frame (the container
|
||||
// did not exist yet — reasoningWrapEl is null) is always
|
||||
// pinned: the fresh window starts at its bottom (st=0), and
|
||||
// a first line taller than the 320px window must still land
|
||||
// on the tail, not the top.
|
||||
const tbody = reasoningWrapEl?.querySelector(".reasoning-body");
|
||||
const tPinned = tbody ? isReasoningPinned(tbody) : true;
|
||||
const disc = ensureToolRun(wrap);
|
||||
currentRun.run.records.push(toolRecord);
|
||||
// Phase 161 (D6): "Calling <name>…" — the display name the
|
||||
// tool line already shows (the phase-70 legacy-name mapping
|
||||
// produced `name` before this point).
|
||||
setReasoningStatus(`Calling ${name}…`);
|
||||
// Phase 161 (D3): the container window's PRE-update pin
|
||||
// capture — before the line append grows the content.
|
||||
const tbody = reasoningWrapEl?.querySelector(".reasoning-body");
|
||||
const tPinned = !!tbody && isReasoningPinned(tbody);
|
||||
const line = appendToolLine(
|
||||
disc,
|
||||
name,
|
||||
@@ -1132,18 +1163,22 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
acc += ev.text || "";
|
||||
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
||||
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
||||
// Phase 158 (D5): the first delta FOLDS the open run (the
|
||||
// active block closes; a later thinking frame opens a NEW run
|
||||
// above the bubble — the D15 re-open of the shared block is
|
||||
// gone). Idempotent — a no-op without an open run.
|
||||
// Phase 158 (D5): the first delta ENDS the active run (the
|
||||
// D5 refinement, owner 2026-10-06 — the in-container runs are
|
||||
// non-collapsible divs, so nothing folds; the run simply
|
||||
// stops accumulating frames — a later thinking frame opens a
|
||||
// NEW run in the container — the D15 re-open of the shared
|
||||
// block is gone). Idempotent — a no-op without an active run.
|
||||
closeCurrentRun();
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||
// No page scroll (phase 42): the answer never follows the viewport.
|
||||
} else if (ev.type === "done") {
|
||||
sawDone = true;
|
||||
// Phase 158 (D5): the turn is over — fold EVERY open run
|
||||
// (defensive: by construction at most the last run is open; a
|
||||
// late double-close is a no-op). Replaces the phase-117
|
||||
// Phase 158 (D5): the turn is over — the D5 refinement (owner
|
||||
// 2026-10-06) made the in-container runs non-collapsible divs,
|
||||
// so this is the DEFENSIVE legacy guard (the pre-phase-161
|
||||
// details blocks the restore path creates, already closed — a
|
||||
// no-op in every live path). Replaces the phase-117
|
||||
// closeToolCalls fold + the D15 block close.
|
||||
closeAllRuns(wrap);
|
||||
// Phase 161 (D2): the turn settled — fold the container too
|
||||
@@ -1468,13 +1503,21 @@ def test_run_turn_is_pinned_at_phase_159_state() -> None:
|
||||
the live container, like segRuns; the D6 status writes + the D3
|
||||
pre/post follow in the thinking/tool frame branches; the
|
||||
``settleReasoning(wrap)`` fold at the done / stop / failed
|
||||
settle sites — the container's live-path contract, D1/D2/D3/D6):
|
||||
the full source must match the pin — the ``reask`` path, the
|
||||
turn-local resets, the SSE contract, the save points, and the
|
||||
sanctioned additions (the task-01 ``lastUserWrap`` assignment +
|
||||
the idle-settle mark pass, the task-03 aborted-settle mark pair,
|
||||
the phase-160 done-settle pair, and the phase-161 live-container
|
||||
additions) included."""
|
||||
settle sites — the container's live-path contract, D1/D2/D3/D6)
|
||||
and the D5 refinement's (owner 2026-10-06) sanctioned changes
|
||||
(the per-run blocks are plain divs — ``ensureThinkingRun`` /
|
||||
``ensureToolRun`` create ``div``s through the run builders, never
|
||||
open/foldable; the container window's PRE-update pin capture
|
||||
moves BEFORE the block append — the append's height growth must
|
||||
not leak into the "pre" reading; the first-frame edge case — a
|
||||
fresh window is always pinned; the legacy block-level pin
|
||||
capture stays, no-op'ing on the divs): the full source must
|
||||
match the pin — the ``reask`` path, the turn-local resets, the
|
||||
SSE contract, the save points, and the sanctioned additions (the
|
||||
task-01 ``lastUserWrap`` assignment + the idle-settle mark
|
||||
pass, the task-03 aborted-settle mark pair, the phase-160
|
||||
done-settle pair, the phase-161 live-container additions, and
|
||||
the D5 refinement) included."""
|
||||
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
|
||||
|
||||
|
||||
|
||||
@@ -145,20 +145,25 @@ def test_thinking_event_is_a_first_class_turn_branch() -> None:
|
||||
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 (phase 161, D1: appended
|
||||
into the reasoning container's ``.reasoning-body`` — the
|
||||
per-run bubble anchor is superseded; 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)."""
|
||||
run's block or creates a NEW one (the D5 refinement, owner
|
||||
2026-10-06 — a plain ``div.thinking`` through the run builder,
|
||||
NEVER open/foldable: the container is the ONE disclosure); it is
|
||||
appended into the reasoning container's ``.reasoning-body`` (phase
|
||||
161, D1 — the per-run bubble anchor is superseded; a frame of a
|
||||
new kind ends the previous run first); ``closeCurrentRun`` is a
|
||||
no-op without a run and ends the active one (nothing folds — the
|
||||
D5 refinement; 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 ensureThinkingRun")
|
||||
assert fn != -1, "ensureThinkingRun must exist (near addTyping/removeTyping)"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "closeCurrentRun()" in body, ("a frame of a new kind closes the "
|
||||
assert "closeCurrentRun()" in body, ("a frame of a new kind ends the "
|
||||
"previous run (D1)")
|
||||
assert "block.open = true" in body, "D5: created open — the stream is the show"
|
||||
assert "createThinkingRunEl()" in body, (
|
||||
"D5 refinement: a plain div — never collapsible (the container "
|
||||
"is the ONE disclosure)"
|
||||
)
|
||||
assert (
|
||||
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
|
||||
".appendChild(block)" in body
|
||||
@@ -169,15 +174,20 @@ def test_thinking_run_helpers_are_idempotent() -> None:
|
||||
fn2 = js.find("function closeCurrentRun")
|
||||
assert fn2 != -1, "closeCurrentRun must exist"
|
||||
body2 = js[fn2 : js.find("\n}\n", fn2)]
|
||||
assert "currentRun.el.open = false" in body2
|
||||
assert ".open" not in body2, (
|
||||
"D5 refinement: nothing folds — the run simply ends (the "
|
||||
"in-container runs are non-collapsible divs)"
|
||||
)
|
||||
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 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)."""
|
||||
thinking created the wrap first) and ends the active run — phase 158
|
||||
(D5): ``closeCurrentRun()`` (the D5 refinement, owner 2026-10-06 —
|
||||
the in-container runs are non-collapsible divs, so nothing folds;
|
||||
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"')
|
||||
@@ -190,8 +200,10 @@ def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None:
|
||||
|
||||
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 EVERY open run closed (phase 158, D5:
|
||||
``closeAllRuns``)."""
|
||||
guard keys off it) and settles the runs (phase 158, D5:
|
||||
``closeAllRuns`` — the D5 refinement, owner 2026-10-06 — the
|
||||
in-container runs are non-collapsible divs, so the defensive
|
||||
details query no-ops in every live path)."""
|
||||
js = _js()
|
||||
done_idx = js.find('ev.type === "done"')
|
||||
error_idx = js.find('ev.type === "error"')
|
||||
|
||||
@@ -300,8 +300,18 @@ def test_segments_keep_arrival_order_above_the_bubble() -> None:
|
||||
)
|
||||
assert "??" not in t, ("the old anchor chain is GONE — the container "
|
||||
"append is the only anchor")
|
||||
assert "block.open = true" in t
|
||||
assert "disc.open = true" in d
|
||||
assert "createThinkingRunEl()" in t, (
|
||||
"D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert "createToolRunEl()" in d, (
|
||||
"D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert ".open =" not in t, (
|
||||
"D5 refinement: the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert ".open =" not in d, (
|
||||
"D5 refinement: the in-container runs are non-collapsible divs"
|
||||
)
|
||||
|
||||
|
||||
def test_tool_call_style_is_accent_and_contrast_safe() -> None:
|
||||
|
||||
@@ -211,9 +211,12 @@ def test_per_run_inserts_append_into_the_reasoning_body() -> None:
|
||||
``.reasoning-body`` — ``ensureThinkingRun`` / ``ensureToolRun``
|
||||
insert via ``ensureReasoningWrap(wrap).querySelector(
|
||||
".reasoning-body").appendChild(…)`` (arrival order preserved: a
|
||||
new run appends after the previous run's block). EVERYTHING ELSE
|
||||
in both functions is unchanged (closeCurrentRun, ``open = true``
|
||||
D5, the segRuns push, currentRun)."""
|
||||
new run appends after the previous run's block). The D5
|
||||
refinement (owner 2026-10-06): the per-run elements are plain
|
||||
``div``s through the run builders (never open/foldable — the
|
||||
container is the ONE disclosure); everything else in both
|
||||
functions is unchanged (closeCurrentRun, the segRuns push,
|
||||
currentRun)."""
|
||||
js = _js()
|
||||
t = _fn(js, "ensureThinkingRun")
|
||||
assert (
|
||||
@@ -221,7 +224,9 @@ def test_per_run_inserts_append_into_the_reasoning_body() -> None:
|
||||
".appendChild(block)" in t
|
||||
), "the thinking run appends into the container's body"
|
||||
assert "closeCurrentRun()" in t
|
||||
assert "block.open = true" in t, "D5: the per-run open/fold is unchanged"
|
||||
assert "createThinkingRunEl()" in t, (
|
||||
"D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert "segRuns.push(run)" in t
|
||||
assert "currentRun =" in t
|
||||
d = _fn(js, "ensureToolRun")
|
||||
@@ -230,8 +235,16 @@ def test_per_run_inserts_append_into_the_reasoning_body() -> None:
|
||||
".appendChild(disc)" in d
|
||||
), "the tool run appends into the container's body"
|
||||
assert "closeCurrentRun()" in d
|
||||
assert "disc.open = true" in d, "D5: the per-run open/fold is unchanged"
|
||||
assert "createToolRunEl()" in d, (
|
||||
"D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert "segRuns.push(run)" in d
|
||||
assert ".open =" not in t, (
|
||||
"D5 refinement: the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert ".open =" not in d, (
|
||||
"D5 refinement: the in-container runs are non-collapsible divs"
|
||||
)
|
||||
|
||||
|
||||
def test_the_per_run_bubble_anchor_is_gone() -> None:
|
||||
@@ -484,10 +497,13 @@ def test_restore_per_run_blocks_append_into_the_reasoning_body() -> None:
|
||||
append into the container's ``.reasoning-body`` — the per-run
|
||||
``insertBefore(…, bubble)`` anchor is GONE from
|
||||
``restoreTimeline`` (the container sits before the bubble; the
|
||||
runs nest inside it, arrival order preserved). EVERYTHING ELSE
|
||||
in the loop is unchanged (the per-run create, the closed state,
|
||||
the tool-run seal, the well-formedness guards, the truncation
|
||||
note)."""
|
||||
runs nest inside it, arrival order preserved). The D5
|
||||
refinement (owner 2026-10-06, parity with app.js): the
|
||||
in-container runs are plain ``div``s through the run builders
|
||||
(never collapsible — the container is the ONE disclosure,
|
||||
created COLLAPSED by the restore); everything else in the loop
|
||||
is unchanged (the tool-run seal, the well-formedness guards,
|
||||
the truncation note)."""
|
||||
body = _fn(_js(), "restoreTimeline")
|
||||
assert "runsEl.appendChild(block)" in body, (
|
||||
"the thinking block appends into the container's body"
|
||||
@@ -501,12 +517,19 @@ def test_restore_per_run_blocks_append_into_the_reasoning_body() -> None:
|
||||
assert "insertBefore(toolDisc" not in body, (
|
||||
"the per-run bubble anchor is GONE from restoreTimeline (tool)"
|
||||
)
|
||||
# the per-run create + the closed state are unchanged.
|
||||
assert 'summary.textContent = "Thinking"' in body, (
|
||||
"the per-run thinking block structure is unchanged"
|
||||
# the per-run create is the div builder (the D5 refinement).
|
||||
assert "createThinkingRunEl()" in body, (
|
||||
"D5 refinement: the in-container run is a plain div (never "
|
||||
"collapsible — the container is the ONE disclosure)"
|
||||
)
|
||||
assert "createToolRunEl()" in body, (
|
||||
"D5 refinement: the in-container run is a plain div (never "
|
||||
"collapsible)"
|
||||
)
|
||||
assert "block.open = true" not in body, "the in-container runs never open"
|
||||
assert "toolDisc.open" not in body, (
|
||||
"D5 refinement: the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert "block.open = true" not in body, "D5: created COLLAPSED (unchanged)"
|
||||
assert "toolDisc.open = false" in body
|
||||
assert "if (toolDisc) toolDisc = null" in body, "the tool-run seal is unchanged"
|
||||
|
||||
|
||||
@@ -768,15 +791,20 @@ def test_inner_html_is_absent_from_both_wrappers() -> None:
|
||||
"innerHTML ABSENT from the shared.js builder (the house rule)"
|
||||
)
|
||||
# the ONLY HTML write in each timeline function is the
|
||||
# escape-first renderMarkdown into .thinking-text.
|
||||
# escape-first renderMarkdown into .thinking-text (the shared
|
||||
# builder's local textEl; app.js's restore targets the div
|
||||
# block's .thinking-text through querySelector).
|
||||
for fn_name, js in (("restoreTimeline", _js()), ("addSegments", _js_shared())):
|
||||
body = _fn(js, fn_name)
|
||||
writes = re.findall(r"\b(\w+)\.innerHTML\s*=\s*([^;\n]+)", body)
|
||||
writes = re.findall(r"([\w.$()\"'-]+\.?)\.innerHTML\s*=\s*([^;\n]+)", body)
|
||||
assert len(writes) == 1, (
|
||||
f"{fn_name}: exactly ONE innerHTML write (the structure is "
|
||||
f"createElement): {writes}"
|
||||
)
|
||||
assert writes[0][0] == "textEl", "the single write is into .thinking-text"
|
||||
assert (
|
||||
writes[0][0].rstrip(".") == "textEl"
|
||||
or 'querySelector(".thinking-text")' in writes[0][0]
|
||||
), "the single write is into .thinking-text"
|
||||
assert writes[0][1].strip() == "renderMarkdown(entry.text)", (
|
||||
"the single write is the escape-first renderMarkdown"
|
||||
)
|
||||
@@ -889,29 +917,37 @@ def test_the_reasoning_body_is_the_one_bounded_live_window() -> None:
|
||||
|
||||
|
||||
def test_the_inner_runs_are_compacted_to_one_scroll_surface() -> None:
|
||||
"""Task 03 item 5: the inner-run compacting (D3 — ONE scroll
|
||||
surface, the nested border weight removed):
|
||||
``details.reasoning details.thinking`` is de-bordered (a 2px
|
||||
``--line`` indent keeps the family rhythm, transparent
|
||||
background, tight margins); the inner ``.thinking-text``
|
||||
320px window is DISABLED (``max-height: none`` +
|
||||
``overflow-y: visible``) so the container's window is the single
|
||||
scroll surface; the nested ``.tool-calls-disclosure`` keeps its
|
||||
compact margin (the documented-intent rule)."""
|
||||
"""Task 03 item 5 + the D5 refinement (owner 2026-10-06): the
|
||||
inner runs nest as PLAIN DIVS inside ONE scroll surface —
|
||||
``details.reasoning .thinking`` (the family's brand-soft rail,
|
||||
the 2px weight — distinct from the tool run's neutral rail) and
|
||||
``details.reasoning .tool-calls-disclosure`` (the neutral
|
||||
``--line`` rail), de-bordered (transparent background, no nested
|
||||
box); the inner ``.thinking-text`` 320px window is DISABLED
|
||||
(``max-height: none`` + ``overflow-y: visible``) so the
|
||||
container's window is the single scroll surface. The runs are
|
||||
distinct from each other by LABEL + RAIL: the brand-ink
|
||||
"Thinking" label vs the accent-ink "Tool call(s) (N)" label.
|
||||
"""
|
||||
css = _css()
|
||||
run = _rule(css, "details.reasoning details.thinking")
|
||||
assert "border: 0" in run, ("the nested border weight is removed")
|
||||
assert "border-left: 2px solid var(--line)" in run
|
||||
assert "border-radius: 0" in run
|
||||
assert "margin: 0.25rem 0" in run
|
||||
assert "background: transparent" in run
|
||||
inner_text = _rule(css, "details.reasoning details.thinking .thinking-text")
|
||||
run = _rule(css, "details.reasoning .thinking")
|
||||
assert "border-left: 2px solid var(--brand-soft)" in run, (
|
||||
"the thinking run carries the brand-soft rail"
|
||||
)
|
||||
inner_text = _rule(css, "details.reasoning .thinking .thinking-text")
|
||||
assert "max-height: none" in inner_text, (
|
||||
"the inner 320px window is disabled"
|
||||
)
|
||||
assert "overflow-y: visible" in inner_text
|
||||
disc = _rule(css, "details.reasoning .tool-calls-disclosure")
|
||||
assert "margin: 0.25rem 0" in disc
|
||||
assert "border-left: 2px solid var(--line)" in disc, (
|
||||
"the tool run carries the neutral rail (distinct from the "
|
||||
"thinking run's brand-soft rail)"
|
||||
)
|
||||
label = _rule(css, "details.reasoning .thinking .thinking-summary")
|
||||
assert "var(--brand-ink)" in label, (
|
||||
"the thinking label is brand-ink (the family pairing)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_reasoning_still_is_in_the_house_reduced_motion_block() -> None:
|
||||
@@ -949,8 +985,11 @@ def test_the_legacy_no_container_rules_are_untouched() -> None:
|
||||
# line-anchored: the nested details.reasoning selector is a
|
||||
# different rule).
|
||||
assert len(re.findall(r"(?m)^\.tool-calls-disclosure \{", css)) == 1
|
||||
disc = _rule(css, ".tool-calls-disclosure")
|
||||
assert "margin: 0.25rem 0" in disc
|
||||
disc = re.search(
|
||||
r"(?m)^\.tool-calls-disclosure \{([^}]*)\}", css
|
||||
)
|
||||
assert disc, "styles.css must style .tool-calls-disclosure (base)"
|
||||
assert "margin: 0.25rem 0" in disc.group(1)
|
||||
chevron = _rule(css, ".tool-calls-summary::before")
|
||||
assert 'content: "▸"' in chevron
|
||||
assert "margin-right: 0.5rem" in chevron
|
||||
|
||||
@@ -78,27 +78,34 @@ def _branch(js: str, open_type: str, close_type: str) -> str:
|
||||
# ---------- the run builders (D1/D2/D3/D5) ----------
|
||||
|
||||
|
||||
def test_ensure_thinking_run_builds_a_new_open_block_in_the_container() -> None:
|
||||
"""D1/D3/D5: ``ensureThinkingRun`` returns the active thinking
|
||||
run's block or — a NEW ``details.thinking`` (the phase-17 static
|
||||
structure via ``createThinkingBlock``) created OPEN (D5),
|
||||
appended into the phase-161 reasoning container's
|
||||
def test_ensure_thinking_run_builds_a_new_div_block_in_the_container() -> None:
|
||||
"""D1/D3 + the D5 refinement (owner 2026-10-06):
|
||||
``ensureThinkingRun`` returns the active thinking run's block or
|
||||
— a NEW plain ``div.thinking`` (the run builder —
|
||||
``createThinkingRunEl``: the static ``.thinking-summary``
|
||||
"Thinking" literal + the ``.thinking-text``; NEVER open/foldable
|
||||
— the container is the ONE disclosure, always visible while it
|
||||
is open), appended into the phase-161 reasoning container's
|
||||
``.reasoning-body`` (D1 — the container sits before the bubble,
|
||||
a new run appends after the previous run's block, so arrival
|
||||
order is preserved; the per-run bubble anchor is superseded). The
|
||||
plain ``Thinking`` literal (D3 — the
|
||||
phase-17 literal, not a numbered variant) rides the static
|
||||
template; the fresh run joins segRuns + currentRun."""
|
||||
order is preserved; the per-run bubble anchor is superseded).
|
||||
The plain ``Thinking`` literal (D3 — the
|
||||
phase-17 literal, not a numbered variant) rides the builder;
|
||||
the fresh run joins segRuns + currentRun."""
|
||||
js = _js()
|
||||
body = _fn(js, "ensureThinkingRun")
|
||||
assert 'currentRun?.kind === "thinking"' in body, (
|
||||
"the active thinking run is extended, not recreated"
|
||||
)
|
||||
assert "closeCurrentRun()" in body, (
|
||||
"a frame of a new kind CLOSES the previous run first (D1)"
|
||||
"a frame of a new kind ends the previous run first (D1)"
|
||||
)
|
||||
assert "createThinkingRunEl()" in body, (
|
||||
"the D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert ".open =" not in body, (
|
||||
"the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert "createThinkingBlock()" in body, "the shared builder is reused"
|
||||
assert "block.open = true" in body, "D5: created OPEN while its frames stream"
|
||||
assert (
|
||||
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
|
||||
".appendChild(block)" in body
|
||||
@@ -139,13 +146,15 @@ def test_create_thinking_block_is_the_phase_17_static_template_without_data() ->
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_tool_run_builds_a_new_open_disclosure_in_the_container() -> None:
|
||||
"""D1/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), appended into the
|
||||
phase-161 reasoning container's ``.reasoning-body`` (D1 — the
|
||||
def test_ensure_tool_run_builds_a_new_div_disclosure_in_the_container() -> None:
|
||||
"""D1 + the D5 refinement (owner 2026-10-06): ``ensureToolRun``
|
||||
returns the active tool run's disclosure or — a NEW plain
|
||||
``div.tool-calls-disclosure`` (the run builder —
|
||||
``createToolRunEl``: the static ``.tool-calls-summary`` + the
|
||||
.tool-calls list, role=list + aria-label "Tool calls"; NEVER
|
||||
open/foldable — the container is the ONE disclosure, always
|
||||
visible while it is open), appended into the phase-161
|
||||
reasoning container's ``.reasoning-body`` (D1 — the
|
||||
container sits before the bubble, arrival order preserved; the
|
||||
per-run bubble anchor is superseded). One disclosure per
|
||||
CONTIGUOUS tool-call run; the per-run "Tool call(s) (N)" count
|
||||
@@ -154,8 +163,12 @@ def test_ensure_tool_run_builds_a_new_open_disclosure_in_the_container() -> None
|
||||
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 "createToolRunEl()" in body, (
|
||||
"the D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert ".open =" not in body, (
|
||||
"the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert (
|
||||
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
|
||||
".appendChild(disc)" in body
|
||||
@@ -321,14 +334,19 @@ def test_the_arm_follows_the_line_append_with_the_new_signature() -> None:
|
||||
|
||||
|
||||
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)."""
|
||||
"""The D5 refinement's (owner 2026-10-06) fold side:
|
||||
``closeCurrentRun`` ENDS the ACTIVE run (nothing folds — the
|
||||
in-container runs are non-collapsible divs; idempotent; a
|
||||
no-op without a run) and ``closeAllRuns`` closes EVERY open
|
||||
``details.thinking`` + ``details.tool-calls-disclosure`` in the
|
||||
wrap (the DEFENSIVE legacy guard — the pre-phase-161
|
||||
details blocks the restore path creates, already closed; a
|
||||
no-op in every live path)."""
|
||||
js = _js()
|
||||
cur = _fn(js, "closeCurrentRun")
|
||||
assert "currentRun.el.open = false" in cur
|
||||
assert ".open" not in cur, (
|
||||
"D5 refinement: nothing folds — the run simply ends"
|
||||
)
|
||||
assert "currentRun = null" in cur, "the run is cleared (a later close is a no-op)"
|
||||
all_runs = _fn(js, "closeAllRuns")
|
||||
assert "details.thinking, details.tool-calls-disclosure" in all_runs
|
||||
@@ -581,34 +599,35 @@ def test_the_pre_phase_narrative_is_replaced() -> None:
|
||||
|
||||
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). Phase 161 (task 02, D5/D6): the per-run blocks
|
||||
nest inside ONE details.reasoning container — the shared
|
||||
task-01 builder, created CLOSED and inserted ONCE before the
|
||||
bubble (the per-run bubble anchor is superseded; arrival order
|
||||
inside the container COLLAPSED — a ``kind:"thinking"`` entry
|
||||
gets its OWN plain ``div.thinking`` (the D5 refinement — the
|
||||
run builder, never collapsible), and CONSECUTIVE
|
||||
``kind:"tool"`` entries group into ONE plain
|
||||
``div.tool-calls-disclosure`` (the run builder — the static
|
||||
label + the .tool-calls list; innerHTML ABSENT). Phase 161
|
||||
(task 02, D5/D6): the per-run blocks nest inside ONE
|
||||
details.reasoning container — the shared task-01 builder,
|
||||
created CLOSED and inserted ONCE before the bubble (the
|
||||
per-run bubble anchor is superseded; arrival order
|
||||
preserved). The per-run count rides ``appendToolLine``'s summary
|
||||
update; a kind change seals the tool run (one disclosure per
|
||||
run, D1)."""
|
||||
body = _fn(_js(), "restoreTimeline")
|
||||
assert "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 "createThinkingRunEl()" in body, (
|
||||
"the D5 refinement: the in-container run is a plain div "
|
||||
"(never collapsible — the container is the ONE disclosure)"
|
||||
)
|
||||
assert "createToolRunEl()" in body, (
|
||||
"the D5 refinement: the in-container run is a plain div "
|
||||
"(never collapsible)"
|
||||
)
|
||||
assert "block.open = true" not in body, "the in-container runs never open"
|
||||
assert "toolDisc.open" not in body, (
|
||||
"the in-container runs are non-collapsible divs"
|
||||
)
|
||||
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
|
||||
# change, append into the container's body.
|
||||
assert "if (toolDisc) toolDisc = null" in body, (
|
||||
"a thinking entry seals the tool run — ONE disclosure per run (D1)"
|
||||
)
|
||||
@@ -682,11 +701,14 @@ def _single_escape_first_write(body: str) -> None:
|
||||
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)
|
||||
writes = re.findall(r"([\w.$()\"'-]+\.?)\.innerHTML\s*=\s*([^;\n]+)", body)
|
||||
assert len(writes) == 1, (
|
||||
f"exactly ONE innerHTML write (the structure is createElement): {writes}"
|
||||
)
|
||||
assert writes[0][0] == "textEl", "the single write is into .thinking-text"
|
||||
assert (
|
||||
writes[0][0].rstrip(".") == "textEl"
|
||||
or 'querySelector(".thinking-text")' in writes[0][0]
|
||||
), "the single write is into .thinking-text"
|
||||
assert writes[0][1].strip() == "renderMarkdown(entry.text)", (
|
||||
"the single write is the escape-first renderMarkdown"
|
||||
)
|
||||
@@ -694,19 +716,22 @@ def _single_escape_first_write(body: str) -> None:
|
||||
|
||||
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
|
||||
createElement/textContent (the in-container runs ride the
|
||||
innerHTML-absent D5-refinement run builders — the static
|
||||
label via textContent; the .tool-calls list, role=list +
|
||||
aria-label "Tool calls" — the task-02 pin). The ONLY HTML
|
||||
write in the function is the escape-first renderMarkdown into
|
||||
``.thinking-text`` (the existing restore convention — the
|
||||
phase-17 pattern; renderMarkdown's escape-first contract keeps
|
||||
storage XSS-safe)."""
|
||||
body = _fn(_js(), "restoreTimeline")
|
||||
_single_escape_first_write(body)
|
||||
assert "document.createElement(\"summary\")" in body, (
|
||||
"the summary is createElement + textContent"
|
||||
assert "createThinkingRunEl()" in body, (
|
||||
"the in-container run rides the innerHTML-absent run builder"
|
||||
)
|
||||
assert "createToolRunEl()" in body, (
|
||||
"the in-container run rides the innerHTML-absent run builder"
|
||||
)
|
||||
assert "document.createElement(\"div\")" in body, "the .thinking-text is createElement"
|
||||
|
||||
|
||||
def test_the_restore_dispatch_gives_segments_precedence_over_the_legacy_path() -> None:
|
||||
@@ -756,31 +781,37 @@ def test_the_restore_dispatch_gives_segments_precedence_over_the_legacy_path() -
|
||||
|
||||
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 nesting inside the phase-161
|
||||
details.reasoning container (the local parity builder, created
|
||||
CLOSED and inserted ONCE before the bubble — the per-run bubble
|
||||
anchor is superseded, arrival order preserved) — all created
|
||||
CLOSED (no ``open = true`` anywhere; the addThinkingBlock /
|
||||
addToolLines collapsed convention)."""
|
||||
COLLAPSED): the same structure (a per-run plain div.thinking
|
||||
with the static ``.thinking-summary`` "Thinking" label via
|
||||
textContent + the phase-117 disclosure structure as a plain
|
||||
div — ``.tool-calls-summary`` + the .tool-calls list,
|
||||
role=list + aria-label "Tool calls"; the D5 refinement —
|
||||
NEVER collapsible, always visible while the container is
|
||||
open), the same LINE BYTES as ``addToolLines`` (the four
|
||||
pinned label literals + the phase-70 legacy names + the
|
||||
phase-139 readRangeSuffix + the phase-95 truncation note), the
|
||||
per-run ``Tool call(s) (N)`` count, and nesting inside the
|
||||
phase-161 details.reasoning container (the local parity
|
||||
builder, created CLOSED and inserted ONCE before the bubble —
|
||||
the per-run bubble anchor is superseded, arrival order
|
||||
preserved) — all created COLLAPSED (the container is the ONE
|
||||
disclosure; the addThinkingBlock / addToolLines collapsed
|
||||
convention)."""
|
||||
body = _fn(_js_shared(), "addSegments")
|
||||
assert "document.createElement(\"details\")" in body, "the structure is createElement"
|
||||
assert "document.createElement(\"div\")" 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 "block.open" not in body, (
|
||||
"D5 refinement: the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert 'label.textContent = "Thinking"' in body, "the plain Thinking literal (D3)"
|
||||
assert 'textEl.className = "thinking-text"' in body
|
||||
assert "renderMarkdown(entry.text)" in body, "the escape-first renderer (parity)"
|
||||
# the phase-117 disclosure structure.
|
||||
# the phase-117 disclosure structure (the D5 refinement — a plain
|
||||
# div, never collapsible).
|
||||
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 "toolDisc.open" not in body, (
|
||||
"D5 refinement: the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert 'summary.className = "tool-calls-summary"' in body
|
||||
assert 'container.className = "tool-calls"' in body
|
||||
assert 'setAttribute("role", "list")' in body
|
||||
|
||||
@@ -62,23 +62,29 @@ def _fn(src: str, name: str) -> str:
|
||||
return src[fn : src.find("\n}\n", fn)]
|
||||
|
||||
|
||||
def test_tool_run_builds_an_open_details_disclosure() -> None:
|
||||
"""Phase 158 (D1/D5) + 161 (D1): ``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) — appended into the
|
||||
phase-161 reasoning container's ``.reasoning-body`` in arrival
|
||||
order (D1 — the container sits above the bubble; the per-run
|
||||
bubble anchor is superseded). 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)."""
|
||||
def test_tool_run_builds_a_div_disclosure_in_the_container() -> None:
|
||||
"""Phase 158 (D1) + 161 (D1) + the D5 refinement (owner
|
||||
2026-10-06): ``ensureToolRun`` creates a plain
|
||||
``div.tool-calls-disclosure`` via ``createToolRunEl`` — a
|
||||
``span.tool-calls-summary`` (first child — the static label, NOT
|
||||
a control: the container is the ONE disclosure) + the existing
|
||||
``.tool-calls`` list (role=list + aria-label "Tool calls",
|
||||
second child) — appended into the phase-161 reasoning
|
||||
container's ``.reasoning-body`` in arrival order (D1 — the
|
||||
container sits above the bubble; the per-run bubble anchor is
|
||||
superseded). The run is ALWAYS VISIBLE while the container is
|
||||
open (never open/foldable — no ``.open`` anywhere); 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
|
||||
disc = _fn(js, "createToolRunEl")
|
||||
assert "document.createElement(\"div\")" in disc, (
|
||||
"the D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert "className = \"tool-calls-disclosure\"" in disc
|
||||
assert "document.createElement(\"summary\")" in disc
|
||||
assert "document.createElement(\"span\")" in disc, (
|
||||
"the label is a static span (not a <summary> control)"
|
||||
)
|
||||
assert "className = \"tool-calls-summary\"" in disc
|
||||
# The list keeps its exact phase-37 shape, nested in the disclosure.
|
||||
assert "className = \"tool-calls\"" in disc
|
||||
@@ -89,11 +95,9 @@ def test_tool_run_builds_an_open_details_disclosure() -> None:
|
||||
"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 "createToolRunEl()" in run
|
||||
assert ".open =" not in run, (
|
||||
"D5 refinement: the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert 'insertBefore(disc, body.querySelector(".bubble"))' not in run, (
|
||||
"the per-run bubble anchor is GONE (phase 161 D1)"
|
||||
@@ -186,22 +190,33 @@ def test_run_anchors_are_the_container_in_arrival_order() -> None:
|
||||
append into its ``.reasoning-body``) — 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)."""
|
||||
wrong order — arrival order is the contract), and the runs are
|
||||
ALWAYS VISIBLE inside the open container (the D5 refinement —
|
||||
never open/foldable)."""
|
||||
js = _js()
|
||||
run = _fn(js, "ensureThinkingRun")
|
||||
assert (
|
||||
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
|
||||
".appendChild(block)" in run
|
||||
), "phase 161 (D1): the thinking run anchors on the container"
|
||||
assert "block.open = true" in run
|
||||
assert "createThinkingRunEl()" in run, (
|
||||
"the D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert ".open =" not in run, (
|
||||
"the in-container runs are non-collapsible divs"
|
||||
)
|
||||
assert "??" not in run, "the old anchor chain is GONE"
|
||||
d = _fn(js, "ensureToolRun")
|
||||
assert (
|
||||
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
|
||||
".appendChild(disc)" in d
|
||||
), "phase 161 (D1): the tool run anchors on the container"
|
||||
assert "disc.open = true" in d
|
||||
assert "createToolRunEl()" in d, (
|
||||
"the D5 refinement: a plain div — never collapsible"
|
||||
)
|
||||
assert ".open =" not in d, (
|
||||
"the in-container runs are non-collapsible divs"
|
||||
)
|
||||
|
||||
|
||||
def test_shared_page_add_tool_lines_builds_a_closed_disclosure() -> None:
|
||||
@@ -277,12 +292,16 @@ def test_tool_call_code_rule_is_dechipped() -> None:
|
||||
|
||||
|
||||
def test_tool_calls_disclosure_and_summary_rules_exist() -> None:
|
||||
"""Phase 117 (D2/D4): the NEW ``.tool-calls-disclosure`` and
|
||||
``.tool-calls-summary`` rules exist — the summary is a native
|
||||
focusable toggle carrying the accent TEXT (``var(--accent-ink)``,
|
||||
"""Phase 117 (D2/D4) + the D5 refinement (owner 2026-10-06): the
|
||||
NEW ``.tool-calls-disclosure`` and ``.tool-calls-summary`` rules
|
||||
exist — the label carries the accent TEXT (``var(--accent-ink)``,
|
||||
≈10.4:1 on the surface; text + color, never color alone, B5),
|
||||
modeled on the details.thinking family, with no new color literal
|
||||
(the phase-92 zero-literal invariant)."""
|
||||
(the phase-92 zero-literal invariant). The D5 refinement: the
|
||||
INTERACTIVE treatment (cursor + the chevron) is scoped to the
|
||||
legacy ``<summary>`` element — the in-container run's label is a
|
||||
static span, not a control (the container is the ONE
|
||||
disclosure)."""
|
||||
css = _css()
|
||||
assert re.search(r"\.tool-calls-disclosure \{", css), (
|
||||
"the .tool-calls-disclosure rule must exist"
|
||||
@@ -293,4 +312,9 @@ def test_tool_calls_disclosure_and_summary_rules_exist() -> None:
|
||||
assert "var(--accent-ink)" in summary, (
|
||||
"the accent rides the summary text (≈10.4:1 on the surface)"
|
||||
)
|
||||
assert "cursor: pointer" in summary, "a toggle affordance"
|
||||
toggle = re.search(r"summary\.tool-calls-summary \{([^}]*)\}", css)
|
||||
assert toggle, (
|
||||
"the D5 refinement: the toggle affordance is scoped to the "
|
||||
"legacy <summary> (the in-container label is a static span)"
|
||||
)
|
||||
assert "cursor: pointer" in toggle.group(1), "a toggle affordance"
|
||||
|
||||
Reference in New Issue
Block a user