feat(agent): align the document tools with the harness-trained shape — ls, read(path), grep(pattern, path?)
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user