All verification complete — every gate green. Final report: **Phase 117 final verification pass — all criteria verified** (tasks 01–04 were already in `complete/`; I re-verified the full gate myself, no code changes needed) - **Verified:** `appendToolLine` wraps lines in `details.tool-calls-disclosure` (open live, `closeToolCalls` at delta/done/stop + restore-closed, count via textContent); `addToolLines` parity (closed); `.tool-call` deboxed / `code` dechipped; new disclosure/summary CSS; only the one pinned CSS assert updated - `uv run pytest` → **2466 passed** - `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (>90% floor) - E2E in isolation: `test_tool_call_compact.py` **4 passed** (fold/expand/deboxed-flow/restored-folded); `test_agent_document_tools.py` **4 passed**; `test_big_read_progress.py` **4 passed** (live disclosure open + `.tool-elapsed` visible); `test_thinking_display.py` **5 passed**; `test_smoke.py` **3 passed** - `uv run ruff check . && uv run pyright` → clean (pyright 0 errors, 0 warnings) - Completion criteria: pins 1–4 ✅ (E2E above); live "calling tool" state byte-identical ✅ (`test_frontend_tool_states.py` 10 passed incl. `test_calling_tool_label_strings`); scope ✅ — `git diff` limited to the 3 frontend assets + 2 unit tests + E2E story + phase files, **0 changes in `app/`** - Notable: `TODO.md` carries a **pre-existing uncommitted owner edit** (new retrieval-context idea) already in the tree before this pass — left untouched, will ride along in the harness commit; pre-existing committed quirk: the brain-wave commit's styles.css comment mislabels itself "Phase 117" (cosmetic, out of this diff) - No commit made (harness commits + moves the phase); **next pending phase: none** — `todo/` holds only phase 117
320 lines
15 KiB
Python
320 lines
15 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({ name, argument })" 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)"
|
|
)
|
|
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 "appendToolLine(wrap, name, argument)" in branch
|
|
# 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}`" in branch
|
|
# 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.
|
|
assert 'name === "grep" || name === "search_documents") && argument' in branch
|
|
assert "`${brand()} is searching for ${argument}`" 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:
|
|
"""appendToolLine: first frame creates the .tool-calls list (role=list
|
|
+ accessible name) BEFORE the bubble — below an existing Thinking
|
|
block — and each line is a .tool-call listitem with the exact marks:
|
|
🔎 Listing documents / 📄 Reading <code>path</code>. The path goes
|
|
through textContent (storage can never inject HTML)."""
|
|
js = _js()
|
|
fn = js.find("function appendToolLine")
|
|
assert fn != -1, "appendToolLine must exist"
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert 'querySelector(".tool-calls")' in body, "idempotent per wrap"
|
|
assert 'className = "tool-calls"' in body
|
|
assert 'setAttribute("role", "list")' in body
|
|
assert 'setAttribute("aria-label", "Tool calls")' in body
|
|
assert 'insertBefore(container, body.querySelector(".bubble"))' in body, (
|
|
"the list sits ABOVE the answer"
|
|
)
|
|
assert '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(wrap, t.name, arg)" in body
|
|
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_thinking_block_stays_on_top_of_tool_lines() -> None:
|
|
"""If a tool frame precedes the first thinking frame, the Thinking
|
|
block is still created ABOVE the tool lines (scratchpad on top),
|
|
not below them."""
|
|
js = _js()
|
|
fn = js.find("function ensureThinkingBlock")
|
|
assert fn != -1
|
|
body = js[fn : js.find("\n}\n", fn)]
|
|
assert 'querySelector(".tool-calls")' in body, (
|
|
"the thinking anchor must account for existing tool lines"
|
|
)
|
|
assert 'querySelector(".bubble")' in body
|
|
assert "insertBefore" in body
|
|
assert "block.open = true" in body
|
|
|
|
|
|
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
|