238 lines
10 KiB
Python
238 lines
10 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.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
|
|
def _js() -> str:
|
|
return APP_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)."""
|
|
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 68: the search status — locked name+argument gate, sitting
|
|
# BETWEEN the read branch and the listing fallback in the ternary.
|
|
assert "name === \"search_documents\" && argument" in branch, (
|
|
"the search status requires the name AND a string argument"
|
|
)
|
|
assert "`${brand()} is searching for ${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"
|
|
)
|
|
assert "name === \"read_document\" && argument" in body
|
|
# Phase 68: the search branch mirrors the read branch — the same
|
|
# name+argument gate, a <code> element, and the pattern through
|
|
# textContent (never markup); the listing stays the final else.
|
|
assert "name === \"search_documents\" && argument" in body
|
|
assert 'line.textContent = "🔎 Searching for "' in body
|
|
search_part = body.split('name === "search_documents"', 1)[1]
|
|
assert 'document.createElement("code")' in search_part, (
|
|
"the pattern gets the same <code> treatment as the read path"
|
|
)
|
|
assert "code.textContent = argument" in search_part, (
|
|
"the pattern is data — textContent, never innerHTML"
|
|
)
|
|
assert 'line.textContent = "🔎 Listing documents"' in search_part, (
|
|
"the listing fallback remains the final else"
|
|
)
|
|
|
|
|
|
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: .tool-call is an inline row with the accent palette
|
|
(distinct from the brand-ink Thinking block) and mono `code` styling
|
|
for the path; 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 "display: flex" in row, "inline row: icon + text"
|
|
assert "var(--accent-ink)" in row, (
|
|
"accent color distinguishes it from the thinking block (≈10.4:1 on surface)"
|
|
)
|
|
assert "var(--accent-line)" in row, "accent left border"
|
|
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_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
|