feat(agent): align the document tools with the harness-trained shape — ls, read(path), grep(pattern, path?)

This commit is contained in:
2026-09-03 11:17:47 -04:00
parent 16f1cfbcaf
commit 801639efcc
55 changed files with 4031 additions and 1466 deletions
+97 -16
View File
@@ -6,7 +6,12 @@ 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.
``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
@@ -15,6 +20,7 @@ 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"
@@ -22,6 +28,10 @@ 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")
@@ -66,7 +76,10 @@ def test_calling_tool_label_strings() -> None:
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)."""
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"')
@@ -74,12 +87,19 @@ def test_calling_tool_label_strings() -> None:
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"
# 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")
@@ -120,21 +140,41 @@ def test_tool_lines_render_into_the_bubble_wrap() -> None:
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
# 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
search_part = body.split('name === "search_documents"', 1)[1]
assert 'document.createElement("code")' in search_part, (
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 search_part, (
assert "code.textContent = argument" in grep_part, (
"the pattern is data — textContent, never innerHTML"
)
assert 'line.textContent = "🔎 Listing documents"' in search_part, (
"the listing fallback remains the final else"
# 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"
)
@@ -231,6 +271,47 @@ def test_tool_call_style_is_accent_and_contrast_safe() -> None:
)
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")