"""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 `` # 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 path. 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 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 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 — 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 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