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
387 lines
17 KiB
Python
387 lines
17 KiB
Python
"""Unit: the phase-37 "calling tool" frontend contract (task 05).
|
|
|
|
No new Python app logic exists for this task — the behavior lives in
|
|
``frontend/assets/app.js`` + ``styles.css`` and is E2E-gated by the story
|
|
suite (task 06). Like the other frontend-adjacent unit files, this module
|
|
pins the JS/CSS markers the story depends on, so a silent regression in
|
|
the tool branch, the persistence shape, or the tool-line styling is
|
|
catched without a browser. Phase 68 extends the pins with the
|
|
``search_documents`` status/line contract. Phase 70 extends the pins to
|
|
the harness-aligned names (``ls`` / ``read`` / ``grep``) in both
|
|
``app.js`` and the shared page's local copy (``shared.js``) — the legacy
|
|
names (``list_documents`` / ``read_document`` / ``search_documents``)
|
|
must keep rendering exactly as before for persisted turns (no
|
|
migration).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
SHARED_JS = FRONTEND / "assets" / "shared.js"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
|
|
def _js() -> str:
|
|
return APP_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _shared_js() -> str:
|
|
return SHARED_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _css() -> str:
|
|
return STYLES_CSS.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_tool_branch_is_a_first_class_turn_branch() -> None:
|
|
"""The turn handler must branch on `tool` frames BETWEEN the
|
|
thinking and delta branches: the stream stays alive (guard clears),
|
|
the brain wrap is created on demand, and the label state is
|
|
applied only while the UI state is still "thinking" (a late frame
|
|
after the first delta just appends the line — never a crash)."""
|
|
js = _js()
|
|
thinking_idx = js.find('ev.type === "thinking"')
|
|
tool_idx = js.find('ev.type === "tool"')
|
|
delta_idx = js.find('ev.type === "delta"')
|
|
assert -1 < thinking_idx < tool_idx < delta_idx, (
|
|
"the turn handler must branch on tool frames"
|
|
)
|
|
branch = js[tool_idx:delta_idx]
|
|
assert "toolAcc.push(toolRecord)" in branch, (
|
|
"every tool frame is recorded for persistence — the record stays"
|
|
" {name, argument}-generic, no per-tool shape (phase 68: the"
|
|
" search tool rides the same accumulator; phase 139: the range"
|
|
" fields join additively — a non-range record keeps the exact"
|
|
" pre-phase shape)"
|
|
)
|
|
assert "clearTurnTimeout()" in branch, "a tool frame proves the stream is alive"
|
|
assert 'addMessage("brain", "")' in branch, "first frame creates the brain wrap"
|
|
assert "uiState === UI_STATE.thinking" in branch, (
|
|
"label updates only while the state is still thinking"
|
|
)
|
|
assert (
|
|
"const line = appendToolLine("
|
|
in branch
|
|
) and "armToolLineClock(wrap, line)" in branch, (
|
|
"the run's disclosure gets the line; the clock targets it"
|
|
)
|
|
assert "ensureToolRun(wrap)" in branch, (
|
|
"phase 158 (D1): the tool frame opens/extends the run's "
|
|
"disclosure"
|
|
)
|
|
# No setUiState in the branch: the state stays "thinking" (never stale).
|
|
assert "setUiState" not in branch, (
|
|
"the tool branch must keep uiState=thinking (button stays disabled)"
|
|
)
|
|
|
|
|
|
def test_calling_tool_label_strings() -> None:
|
|
"""The 'calling tool' label strings the story keys off: the
|
|
status/typing-indicator labels. Phase 48 (owner-locked 2026-08-29)
|
|
revised the phase-37 contract: the button no longer relabels to
|
|
"Calling tool…" — it stays the enabled "Stop" control for the whole
|
|
in-flight turn (no sendLabel write in the branch); the calling-tool
|
|
status lives in #send-status + the typing-indicator aria-label only.
|
|
Phase 39 centralizes the brand prefix: the name resolves from
|
|
window.BOR_BRAND at call time via brand() (the default name renders
|
|
the same bytes). Phase 70: the ternary keys off the harness-aligned
|
|
names (read / grep / ls) and still carries the legacy names
|
|
(read_document / search_documents) — a pre-remap label stays
|
|
accurate."""
|
|
js = _js()
|
|
tool_idx = js.find('ev.type === "tool"')
|
|
delta_idx = js.find('ev.type === "delta"')
|
|
branch = js[tool_idx:delta_idx]
|
|
assert "sendLabel" not in branch, "phase 48: the button keeps its Stop label"
|
|
assert "`${brand()} is listing documents`" in branch
|
|
assert (
|
|
"`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`"
|
|
in branch
|
|
), ("phase 139: the read status carries the requested-range suffix (A2)")
|
|
# Phase 70: the read status — new + legacy name, locked
|
|
# name+argument gate, first in the ternary.
|
|
assert 'name === "read" || name === "read_document") && argument' in branch, (
|
|
"the read status requires the name (new or legacy) AND a string argument"
|
|
)
|
|
# The search status — new + legacy name, sitting BETWEEN the read
|
|
# branch and the listing fallback in the ternary. Phase 146
|
|
# (task 02): the label gains the additive scope — `` in <path>``
|
|
# ONLY when the frame carried one (a bare grep renders the exact
|
|
# pre-phase label — the template's empty-string fallback).
|
|
assert 'name === "grep" || name === "search_documents") && argument' in branch
|
|
assert (
|
|
"`${brand()} is searching for ${argument}"
|
|
"${framePath ? ` in ${framePath}` : \"\"}`" in branch
|
|
)
|
|
# Phase 70: the scoped ls status mirrors the scoped tool line; the
|
|
# unscoped listing stays the final fallback.
|
|
assert 'name === "ls" && argument' in branch
|
|
assert "`${brand()} is listing documents in ${argument}`" in branch
|
|
read = branch.find("is reading")
|
|
search = branch.find("is searching for")
|
|
listing = branch.find("is listing documents")
|
|
assert -1 < read < search < listing, "ternary order: read → search → listing"
|
|
assert "sendStatus.textContent = toolStatus" in branch, (
|
|
"the #send-status live region announces what Brain is doing"
|
|
)
|
|
assert 'setAttribute("aria-label", toolStatus)' in branch, (
|
|
"the typing indicator label follows the tool state"
|
|
)
|
|
# The elapsed-seconds hint keeps running through tool frames: the
|
|
# branch must not stop/restart the clock.
|
|
assert "stopThinkingClock" not in branch
|
|
assert "startThinkingClock" not in branch
|
|
|
|
|
|
def test_tool_lines_render_into_the_bubble_wrap() -> None:
|
|
"""Phase 158 (D1): the tool lines render into the run's
|
|
disclosure — ``createToolDisclosure`` builds the phase-117
|
|
structure (a ``.tool-calls`` list, role=list + accessible name
|
|
"Tool calls") and ``ensureToolRun`` appends it into the
|
|
phase-161 reasoning container's ``.reasoning-body`` (D1: the
|
|
container sits ABOVE the answer bubble — the per-run bubble
|
|
anchor is superseded; arrival order preserved); each line is a
|
|
.tool-call listitem with the exact marks: 🔎 Listing documents /
|
|
📄 Reading <code>path</code>. The path goes through textContent
|
|
(storage can never inject HTML)."""
|
|
js = _js()
|
|
fn = js.find("function createToolDisclosure")
|
|
disc = js[fn : js.find("\n}\n", fn)]
|
|
assert 'listEl.className = "tool-calls"' in disc
|
|
assert 'setAttribute("role", "list")' in disc
|
|
assert 'setAttribute("aria-label", "Tool calls")' in disc, (
|
|
"the list keeps its exact phase-37 shape"
|
|
)
|
|
fn2 = js.find("function ensureToolRun")
|
|
run = js[fn2 : js.find("\n}\n", fn2)]
|
|
assert (
|
|
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
|
|
".appendChild(disc)" in run
|
|
), (
|
|
"phase 161 (D1): the disclosure nests in the container, which "
|
|
"sits above the answer (arrival order preserved)"
|
|
)
|
|
fn = js.find("function appendToolLine")
|
|
assert fn != -1, "appendToolLine must exist"
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert 'querySelector(".tool-calls")' in body, "the line appends to the run's list"
|
|
assert 'className = "tool-call"' in body
|
|
assert 'setAttribute("role", "listitem")' in body
|
|
assert 'line.textContent = "📄 Reading "' in body
|
|
assert 'line.textContent = "🔎 Listing documents"' in body
|
|
assert "code.textContent = argument" in body, (
|
|
"the path is data — textContent, never innerHTML"
|
|
)
|
|
# Phase 70: the harness-aligned names key the branches, with the
|
|
# legacy names kept — a persisted turn from before the remap
|
|
# (read_document / search_documents / list_documents) renders
|
|
# unchanged (no migration).
|
|
assert '(name === "read" || name === "read_document") && argument' in body, (
|
|
"read (new) and read_document (legacy) both render the Reading line"
|
|
)
|
|
assert '(name === "grep" || name === "search_documents") && argument' in body, (
|
|
"grep (new) and search_documents (legacy) both render the Searching line"
|
|
)
|
|
assert 'line.textContent = "🔎 Searching for "' in body
|
|
grep_part = body.split('name === "grep"', 1)[1]
|
|
assert 'document.createElement("code")' in grep_part, (
|
|
"the pattern gets the same <code> treatment as the read path"
|
|
)
|
|
assert "code.textContent = argument" in grep_part, (
|
|
"the pattern is data — textContent, never innerHTML"
|
|
)
|
|
# Phase 70: the scoped ls line — the scope through textContent, and
|
|
# the unscoped "Listing documents" stays the final else (legacy
|
|
# list_documents, and a nameless/unknown frame, land there too).
|
|
assert 'name === "ls" && argument' in body
|
|
assert 'line.textContent = "🔎 Listing documents in "' in body
|
|
ls_part = body.split('name === "ls" && argument', 1)[1]
|
|
assert 'document.createElement("code")' in ls_part, (
|
|
"the scope gets the same <code> treatment as the read path"
|
|
)
|
|
assert "code.textContent = argument" in ls_part, (
|
|
"the scope is data — textContent, never innerHTML"
|
|
)
|
|
assert 'line.textContent = "🔎 Listing documents"' in ls_part, (
|
|
"the unscoped listing fallback remains the final else"
|
|
)
|
|
assert "innerHTML" not in body, (
|
|
"no HTML injection surface on tool lines — textContent only"
|
|
)
|
|
|
|
|
|
def test_tool_branch_is_append_only_and_interleaving_safe() -> None:
|
|
"""Append-only, the same rule as thinking: multiple calls append
|
|
multiple lines in order, and a tool frame after the first delta
|
|
(should not happen in v1) still appends — the branch has no early
|
|
return gated on acc/delta state."""
|
|
js = _js()
|
|
tool_idx = js.find('ev.type === "tool"')
|
|
delta_idx = js.find('ev.type === "delta"')
|
|
branch = js[tool_idx:delta_idx]
|
|
assert "if (aborted) return" not in branch, (
|
|
"the aborted guard lives at the dispatch top, not per branch"
|
|
)
|
|
assert "acc" not in branch.split("appendToolLine")[0].replace("toolAcc", ""), (
|
|
"the tool branch must not depend on accumulated answer text"
|
|
)
|
|
|
|
|
|
def test_tool_frames_persist_next_to_thinking() -> None:
|
|
"""The `done` save point gains an optional `tools` key next to
|
|
`thinking` (empty turns persist exactly as before — `undefined`
|
|
drops the key from the JSON), and the accumulator is turn-scoped
|
|
in handleSend."""
|
|
js = _js()
|
|
assert "let toolAcc = []" in js
|
|
done_idx = js.find('ev.type === "done"')
|
|
error_idx = js.find('ev.type === "error"')
|
|
assert -1 < done_idx < error_idx
|
|
done_block = js[done_idx:error_idx]
|
|
assert "thinking: thinkingAcc || undefined" in done_block
|
|
assert "tools: toolAcc.length ? toolAcc : undefined" in done_block, (
|
|
"tools persisted next to thinking, optional like it"
|
|
)
|
|
|
|
|
|
def test_tool_lines_re_render_on_restore() -> None:
|
|
"""Phase 14 convention: a stored brain record with `tools` re-renders
|
|
the lines on load through the SAME append helper (after the thinking
|
|
re-render, before the deflected/sources additions)."""
|
|
js = _js()
|
|
fn = js.find("function renderStoredMessage")
|
|
assert fn != -1
|
|
end = js.find("function restoreConversation")
|
|
body = js[fn:end]
|
|
assert "Array.isArray(m.tools)" in body
|
|
assert (
|
|
"appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
|
), (
|
|
"phase 139: the stored range + the phase-146 stored scope ride the "
|
|
"SAME helper (the caller passes the run's disclosure — phase 158)"
|
|
)
|
|
thinking_restore = body.find("if (m.thinking)")
|
|
tools_restore = body.find("Array.isArray(m.tools)")
|
|
assert -1 < thinking_restore < tools_restore, (
|
|
"tools restore sits after the thinking restore (same order as live)"
|
|
)
|
|
assert "typeof t.name !== \"string\"" in body, "malformed entries skipped"
|
|
|
|
|
|
def test_segments_keep_arrival_order_above_the_bubble() -> None:
|
|
"""Phase 158 (D2) + 161 (D1): both run builders append their
|
|
element into the reasoning container's ``.reasoning-body`` —
|
|
later appends land after earlier ones, so document order is
|
|
ARRIVAL order (a tool frame preceding the first thinking frame
|
|
lands the scratchpad BELOW the tool lines — the timeline order;
|
|
the old "scratchpad on top" anchor chain is gone). The
|
|
container itself sits before the bubble (the ONE anchor)."""
|
|
js = _js()
|
|
fn = js.find("function ensureThinkingRun")
|
|
t = js[fn : js.find("\n}\n", fn)]
|
|
fn2 = js.find("function ensureToolRun")
|
|
d = js[fn2 : js.find("\n}\n", fn2)]
|
|
assert (
|
|
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
|
|
".appendChild(block)" in t
|
|
), "the thinking run appends into the container's body"
|
|
assert (
|
|
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
|
|
".appendChild(disc)" in d
|
|
), "the tool run appends into the container's body"
|
|
assert "insertBefore" not in t, (
|
|
"the per-run bubble anchor is GONE (phase 161 D1)"
|
|
)
|
|
assert "insertBefore" not in d, (
|
|
"the per-run bubble anchor is GONE (phase 161 D1)"
|
|
)
|
|
assert "??" not in t, ("the old anchor chain is GONE — the container "
|
|
"append is the only anchor")
|
|
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:
|
|
"""styles.css (phase 117 debox): .tool-call is a deboxed
|
|
inline-flow row — the label + the mono `code` path are ONE
|
|
continuous wrapping run (no flex, no card), the accent rides the
|
|
TEXT color (distinct from the brand-ink Thinking block), and the
|
|
`code` is inline mono text with no chip; the wrapper stacks lines
|
|
without shifting the column."""
|
|
css = _css()
|
|
assert ".tool-calls" in css
|
|
assert ".tool-call" in css
|
|
m = re.search(r"\.tool-call \{([^}]*)\}", css)
|
|
assert m, "the .tool-call rule must exist"
|
|
row = m.group(1)
|
|
assert "var(--accent-ink)" in row, (
|
|
"accent color distinguishes it from the thinking block (≈10.4:1 on surface)"
|
|
)
|
|
code = re.search(r"\.tool-call code \{([^}]*)\}", css)
|
|
assert code, "the path `code` must be styled"
|
|
assert "var(--mono)" in code.group(1)
|
|
assert "var(--ink)" in code.group(1) # ≈11.5:1 on --brand-soft
|
|
assert "gap" in css.split(".tool-calls {")[1].split("}")[0], (
|
|
"lines stack with a gap — append-only, no reflow"
|
|
)
|
|
|
|
|
|
def test_shared_page_tool_lines_cover_new_and_legacy_names() -> None:
|
|
"""Phase 70: the shared page's local copy (``addToolLines``) renders
|
|
the harness-aligned names — read → Reading, grep → Searching for,
|
|
ls → Listing documents, scoped ls → Listing documents in <scope> —
|
|
and keeps the legacy branches (read_document / search_documents), so
|
|
a conversation saved before the remap renders exactly as before (no
|
|
migration). Every argument through textContent; the lines carry no
|
|
innerHTML at all."""
|
|
js = _shared_js()
|
|
fn = js.find("function addToolLines")
|
|
assert fn != -1, "addToolLines must exist in shared.js"
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert '(t.name === "read" || t.name === "read_document") && argument' in body, (
|
|
"read (new) and read_document (legacy) both render the Reading line"
|
|
)
|
|
assert '(t.name === "grep" || t.name === "search_documents") && argument' in body, (
|
|
"grep (new) and search_documents (legacy) both render the Searching line"
|
|
)
|
|
assert 'line.textContent = "📄 Reading "' in body
|
|
assert 'line.textContent = "🔎 Searching for "' in body
|
|
assert 't.name === "ls" && argument' in body
|
|
assert 'line.textContent = "🔎 Listing documents in "' in body
|
|
ls_part = body.split('t.name === "ls" && argument', 1)[1]
|
|
assert 'document.createElement("code")' in ls_part, (
|
|
"the scope gets the same <code> treatment as the read path"
|
|
)
|
|
assert "code.textContent = argument" in ls_part, (
|
|
"the scope is data — textContent, never innerHTML"
|
|
)
|
|
assert 'line.textContent = "🔎 Listing documents"' in ls_part, (
|
|
"the unscoped listing fallback remains the final else (legacy"
|
|
" list_documents renders unchanged)"
|
|
)
|
|
assert body.count("code.textContent = argument") == 3, (
|
|
"all three argument-bearing lines (read / grep / ls) are textContent-only"
|
|
)
|
|
assert "innerHTML" not in body, (
|
|
"no HTML injection surface on shared tool lines — textContent only"
|
|
)
|
|
|
|
|
|
def test_no_cdn_added() -> None:
|
|
"""AGENTS.md rule 6: the tool state adds no external script/link."""
|
|
index = (FRONTEND / "index.html").read_text(encoding="utf-8")
|
|
assert 'src="http' not in index and 'href="http' not in index
|